Skip to content

Commit 1e4822f

Browse files
authored
Merge pull request #898 from skgsergio/feat/esp32-unified-ota
feat: Add ESP32 WiFi Unified OTA update support
2 parents 1d3bdf1 + 5721859 commit 1e4822f

File tree

6 files changed

+747
-3
lines changed

6 files changed

+747
-3
lines changed

meshtastic/__main__.py

Lines changed: 46 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+
ota_mode=admin_pb2.OTAMode.OTA_WIFI,
470+
ota_file_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
@@ -1912,10 +1948,18 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
19121948

19131949
group.add_argument(
19141950
"--reboot-ota",
1915-
help="Tell the destination node to reboot into factory firmware (ESP32)",
1951+
help="Tell the destination node to reboot into factory firmware (ESP32, firmware version <2.7.18)",
19161952
action="store_true",
19171953
)
19181954

1955+
group.add_argument(
1956+
"--ota-update",
1957+
help="Perform an OTA update on the local node (ESP32, firmware version >=2.7.18, WiFi/TCP only for now). "
1958+
"Specify the path to the firmware file.",
1959+
metavar="FIRMWARE_FILE",
1960+
action="store",
1961+
)
1962+
19191963
group.add_argument(
19201964
"--enter-dfu",
19211965
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
@@ -655,7 +655,7 @@ def commitSettingsTransaction(self):
655655
return self._sendAdmin(p, onResponse=onResponse)
656656

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

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

meshtastic/ota.py

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

meshtastic/tests/test_main.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import platform
77
import re
88
import sys
9+
import tempfile
910
from unittest.mock import mock_open, MagicMock, patch
1011

1112
import pytest
@@ -2900,3 +2901,68 @@ def test_main_set_ham_empty_string(capsys):
29002901
out, _ = capsys.readouterr()
29012902
assert "ERROR: Ham radio callsign cannot be empty or contain only whitespace characters" in out
29022903
assert excinfo.value.code == 1
2904+
2905+
2906+
# OTA-related tests
2907+
@pytest.mark.unit
2908+
@pytest.mark.usefixtures("reset_mt_config")
2909+
def test_main_ota_update_file_not_found(capsys):
2910+
"""Test --ota-update with non-existent file"""
2911+
sys.argv = [
2912+
"",
2913+
"--ota-update",
2914+
"/nonexistent/firmware.bin",
2915+
"--host",
2916+
"192.168.1.100",
2917+
]
2918+
mt_config.args = sys.argv
2919+
2920+
with pytest.raises(SystemExit) as pytest_wrapped_e:
2921+
main()
2922+
2923+
assert pytest_wrapped_e.type == SystemExit
2924+
assert pytest_wrapped_e.value.code == 1
2925+
2926+
2927+
@pytest.mark.unit
2928+
@pytest.mark.usefixtures("reset_mt_config")
2929+
@patch("meshtastic.ota.ESP32WiFiOTA")
2930+
@patch("meshtastic.__main__.meshtastic.util.our_exit")
2931+
def test_main_ota_update_retries(mock_our_exit, mock_ota_class, capsys):
2932+
"""Test --ota-update retries on failure"""
2933+
# Create a temporary firmware file
2934+
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
2935+
f.write(b"fake firmware data")
2936+
firmware_file = f.name
2937+
2938+
try:
2939+
sys.argv = ["", "--ota-update", firmware_file, "--host", "192.168.1.100"]
2940+
mt_config.args = sys.argv
2941+
2942+
# Mock the OTA class to fail all 5 retries
2943+
mock_ota = MagicMock()
2944+
mock_ota_class.return_value = mock_ota
2945+
mock_ota.hash_bytes.return_value = b"\x00" * 32
2946+
mock_ota.hash_hex.return_value = "a" * 64
2947+
mock_ota.update.side_effect = Exception("Connection failed")
2948+
2949+
# Mock isinstance to return True
2950+
with patch("meshtastic.__main__.isinstance", return_value=True):
2951+
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
2952+
mock_iface = MagicMock()
2953+
mock_iface.hostname = "192.168.1.100"
2954+
mock_iface.localNode = MagicMock(autospec=Node)
2955+
mock_tcp.return_value = mock_iface
2956+
2957+
with patch("time.sleep"):
2958+
main()
2959+
2960+
# Should have exhausted all retries and called our_exit
2961+
# Note: our_exit might be called twice - once for TCP check, once for failure
2962+
assert mock_our_exit.call_count >= 1
2963+
# Check the last call was for OTA failure
2964+
last_call_args = mock_our_exit.call_args[0][0]
2965+
assert "OTA update failed" in last_call_args
2966+
2967+
finally:
2968+
os.unlink(firmware_file)

meshtastic/tests/test_node.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,41 @@ def test_setOwner_valid_names(caplog):
15741574
assert re.search(r'p.set_owner.short_name:VN:', caplog.text, re.MULTILINE)
15751575

15761576

1577+
@pytest.mark.unit
1578+
def test_start_ota_local_node():
1579+
"""Test startOTA on local node"""
1580+
iface = MagicMock(autospec=MeshInterface)
1581+
anode = Node(iface, 1234567890, noProto=True)
1582+
# Set up as local node
1583+
iface.localNode = anode
1584+
1585+
amesg = admin_pb2.AdminMessage()
1586+
with patch("meshtastic.admin_pb2.AdminMessage", return_value=amesg):
1587+
with patch.object(anode, "_sendAdmin") as mock_send_admin:
1588+
test_hash = b"\x01\x02\x03" * 8 # 24 bytes hash
1589+
anode.startOTA(ota_mode=admin_pb2.OTAMode.OTA_WIFI, ota_file_hash=test_hash)
1590+
1591+
# Verify the OTA request was set correctly
1592+
assert amesg.ota_request.reboot_ota_mode == admin_pb2.OTAMode.OTA_WIFI
1593+
assert amesg.ota_request.ota_hash == test_hash
1594+
mock_send_admin.assert_called_once_with(amesg)
1595+
1596+
1597+
@pytest.mark.unit
1598+
def test_start_ota_remote_node_raises_error():
1599+
"""Test startOTA on remote node raises ValueError"""
1600+
iface = MagicMock(autospec=MeshInterface)
1601+
local_node = Node(iface, 1234567890, noProto=True)
1602+
remote_node = Node(iface, 9876543210, noProto=True)
1603+
iface.localNode = local_node
1604+
1605+
test_hash = b"\x01\x02\x03" * 8
1606+
with pytest.raises(ValueError, match="startOTA only possible in local node"):
1607+
remote_node.startOTA(
1608+
ota_mode=admin_pb2.OTAMode.OTA_WIFI, ota_file_hash=test_hash
1609+
)
1610+
1611+
15771612
# TODO
15781613
# @pytest.mark.unitslow
15791614
# def test_waitForConfig():

0 commit comments

Comments
 (0)