Skip to content

Commit 9b2b0bd

Browse files
committed
feat: Add ESP32 WiFi Unified OTA update support
1 parent cdf893e commit 9b2b0bd

File tree

3 files changed

+184
-3
lines changed

3 files changed

+184
-3
lines changed

meshtastic/__main__.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
except ImportError as e:
3838
have_test = False
3939

40+
import meshtastic.ota
4041
import meshtastic.util
4142
import meshtastic.serial_interface
4243
import meshtastic.tcp_interface
@@ -60,7 +61,7 @@
6061
have_powermon = False
6162
powermon_exception = e
6263
meter = None
63-
from meshtastic.protobuf import channel_pb2, config_pb2, portnums_pb2, mesh_pb2
64+
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, portnums_pb2, mesh_pb2
6465
from meshtastic.version import get_active_version
6566

6667
logger = logging.getLogger(__name__)
@@ -452,6 +453,41 @@ def onConnected(interface):
452453
waitForAckNak = True
453454
interface.getNode(args.dest, False, **getNode_kwargs).rebootOTA()
454455

456+
if args.ota_update:
457+
closeNow = True
458+
waitForAckNak = True
459+
460+
if not isinstance(interface, meshtastic.tcp_interface.TCPInterface):
461+
meshtastic.util.our_exit(
462+
"Error: OTA update currently requires a TCP connection to the node (use --host)."
463+
)
464+
465+
ota = meshtastic.ota.ESP32WiFiOTA(args.ota_update, interface.hostname)
466+
467+
print(f"Triggering OTA update on {interface.hostname}...")
468+
interface.getNode(args.dest, False, **getNode_kwargs).startOTA(
469+
mode=admin_pb2.OTA_WIFI,
470+
hash=ota.hash_bytes()
471+
)
472+
473+
print("Waiting for device to reboot into OTA mode...")
474+
time.sleep(5)
475+
476+
retries = 5
477+
while retries > 0:
478+
try:
479+
ota.update()
480+
break
481+
482+
except Exception as e:
483+
retries -= 1
484+
if retries == 0:
485+
meshtastic.util.our_exit(f"\nOTA update failed: {e}")
486+
487+
time.sleep(2)
488+
489+
print("\nOTA update completed successfully!")
490+
455491
if args.enter_dfu:
456492
closeNow = True
457493
waitForAckNak = True
@@ -1904,10 +1940,17 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
19041940

19051941
group.add_argument(
19061942
"--reboot-ota",
1907-
help="Tell the destination node to reboot into factory firmware (ESP32)",
1943+
help="Tell the destination node to reboot into factory firmware (ESP32, firmware version <2.7.18)",
19081944
action="store_true",
19091945
)
19101946

1947+
group.add_argument(
1948+
"--ota-update",
1949+
help="Perform a OTA update on the destination node (WiFi/TCP only for now, firmware version >=2.7.18). Specify the path to the firmware file.",
1950+
metavar="FIRMWARE_FILE",
1951+
action="store",
1952+
)
1953+
19111954
group.add_argument(
19121955
"--enter-dfu",
19131956
help="Tell the destination node to enter DFU mode (NRF52)",

meshtastic/node.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ def commitSettingsTransaction(self):
654654
return self._sendAdmin(p, onResponse=onResponse)
655655

656656
def rebootOTA(self, secs: int = 10):
657-
"""Tell the node to reboot into factory firmware."""
657+
"""Tell the node to reboot into factory firmware (firmware < 2.7.18)."""
658658
self.ensureSessionKey()
659659
p = admin_pb2.AdminMessage()
660660
p.reboot_ota_seconds = secs
@@ -667,6 +667,22 @@ def rebootOTA(self, secs: int = 10):
667667
onResponse = self.onAckNak
668668
return self._sendAdmin(p, onResponse=onResponse)
669669

670+
def startOTA(
671+
self,
672+
mode: admin_pb2.OTAMode.ValueType,
673+
hash: bytes,
674+
):
675+
"""Tell the node to start OTA mode (firmware >= 2.7.18)."""
676+
if self != self.iface.localNode:
677+
raise Exception("startOTA only possible in local node")
678+
679+
self.ensureSessionKey()
680+
p = admin_pb2.AdminMessage()
681+
p.ota_request.reboot_ota_mode=mode
682+
p.ota_request.ota_hash=hash
683+
684+
return self._sendAdmin(p)
685+
670686
def enterDFUMode(self):
671687
"""Tell the node to enter DFU mode (NRF52)."""
672688
self.ensureSessionKey()

meshtastic/ota.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import os
2+
import hashlib
3+
import socket
4+
import logging
5+
from typing import Optional, Callable
6+
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def _file_sha256(filename: str):
12+
"""Calculate SHA256 hash of a file."""
13+
sha256_hash = hashlib.sha256()
14+
15+
with open(filename, "rb") as f:
16+
for byte_block in iter(lambda: f.read(4096), b""):
17+
sha256_hash.update(byte_block)
18+
19+
return sha256_hash
20+
21+
class ESP32WiFiOTA:
22+
"""ESP32 WiFi Unified OTA updates."""
23+
24+
def __init__(self, filename: str, hostname: str, port: int = 3232):
25+
self._filename = filename
26+
self._hostname = hostname
27+
self._port = port
28+
self._socket: Optional[socket.socket] = None
29+
30+
if not os.path.exists(self._filename):
31+
raise Exception(f"File {self._filename} does not exist")
32+
33+
self._file_hash = _file_sha256(self._filename)
34+
35+
def _read_line(self) -> str:
36+
"""Read a line from the socket."""
37+
if not self._socket:
38+
raise Exception("Socket not connected")
39+
40+
line = b""
41+
while not line.endswith(b"\n"):
42+
char = self._socket.recv(1)
43+
44+
if not char:
45+
raise Exception("Connection closed while waiting for response")
46+
47+
line += char
48+
49+
return line.decode("utf-8").strip()
50+
51+
def hash_bytes(self) -> bytes:
52+
"""Return the hash as bytes."""
53+
return self._file_hash.digest()
54+
55+
def hash_hex(self) -> str:
56+
"""Return the hash as a hex string."""
57+
return self._file_hash.hexdigest()
58+
59+
def update(self, progress_callback: Optional[Callable[[int, int], None]] = None):
60+
"""Perform the OTA update."""
61+
with open(self._filename, "rb") as f:
62+
data = f.read()
63+
size = len(data)
64+
65+
logger.info(f"Starting OTA update with {self._filename} ({size} bytes, hash {self.hash_hex()})")
66+
67+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68+
self._socket.settimeout(15)
69+
try:
70+
self._socket.connect((self._hostname, self._port))
71+
logger.debug(f"Connected to {self._hostname}:{self._port}")
72+
73+
# Send start command
74+
self._socket.sendall(f"OTA {size} {self.hash_hex()}\n".encode("utf-8"))
75+
76+
# Wait for OK from the device
77+
while True:
78+
response = self._read_line()
79+
if response == "OK":
80+
break
81+
elif response == "ERASING":
82+
logger.info("Device is erasing flash...")
83+
elif response.startswith("ERR "):
84+
raise Exception(f"Device reported error: {response}")
85+
else:
86+
logger.warning(f"Unexpected response: {response}")
87+
88+
# Stream firmware
89+
sent_bytes = 0
90+
chunk_size = 1024
91+
while sent_bytes < size:
92+
chunk = data[sent_bytes : sent_bytes + chunk_size]
93+
self._socket.sendall(chunk)
94+
sent_bytes += len(chunk)
95+
96+
if progress_callback:
97+
progress_callback(sent_bytes, size)
98+
else:
99+
print(f"[{sent_bytes / size * 100:5.1f}%] Sent {sent_bytes} of {size} bytes...", end="\r")
100+
101+
if not progress_callback:
102+
print()
103+
104+
# Wait for OK from device
105+
logger.info("Firmware sent, waiting for verification...")
106+
while True:
107+
response = self._read_line()
108+
109+
if response == "OK":
110+
logger.info("OTA update completed successfully!")
111+
break
112+
elif response == "ACK":
113+
continue
114+
elif response.startswith("ERR "):
115+
raise Exception(f"OTA update failed: {response}")
116+
else:
117+
logger.warning(f"Unexpected final response: {response}")
118+
119+
finally:
120+
if self._socket:
121+
self._socket.close()
122+
self._socket = None

0 commit comments

Comments
 (0)