Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions meshtastic/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
except ImportError as e:
have_test = False

import meshtastic.ota
import meshtastic.util
import meshtastic.serial_interface
import meshtastic.tcp_interface
Expand All @@ -60,7 +61,7 @@
have_powermon = False
powermon_exception = e
meter = None
from meshtastic.protobuf import channel_pb2, config_pb2, portnums_pb2, mesh_pb2
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, portnums_pb2, mesh_pb2
from meshtastic.version import get_active_version

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

if args.ota_update:
closeNow = True
waitForAckNak = True

if not isinstance(interface, meshtastic.tcp_interface.TCPInterface):
meshtastic.util.our_exit(
"Error: OTA update currently requires a TCP connection to the node (use --host)."
)

ota = meshtastic.ota.ESP32WiFiOTA(args.ota_update, interface.hostname)

print(f"Triggering OTA update on {interface.hostname}...")
interface.getNode(args.dest, False, **getNode_kwargs).startOTA(
mode=admin_pb2.OTA_WIFI,
hash=ota.hash_bytes()
)
Comment thread
skgsergio marked this conversation as resolved.

print("Waiting for device to reboot into OTA mode...")
time.sleep(5)

retries = 5
while retries > 0:
try:
ota.update()
break

except Exception as e:
retries -= 1
if retries == 0:
meshtastic.util.our_exit(f"\nOTA update failed: {e}")

time.sleep(2)

print("\nOTA update completed successfully!")

if args.enter_dfu:
closeNow = True
waitForAckNak = True
Expand Down Expand Up @@ -1904,10 +1940,17 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars

group.add_argument(
"--reboot-ota",
help="Tell the destination node to reboot into factory firmware (ESP32)",
help="Tell the destination node to reboot into factory firmware (ESP32, firmware version <2.7.18)",
action="store_true",
)

group.add_argument(
"--ota-update",
help="Perform a OTA update on the destination node (ESP32, firmware version >=2.7.18, WiFi/TCP only for now). Specify the path to the firmware file.",
Comment thread
thebentern marked this conversation as resolved.
Outdated
Comment thread
skgsergio marked this conversation as resolved.
Outdated
metavar="FIRMWARE_FILE",
action="store",
)

group.add_argument(
"--enter-dfu",
help="Tell the destination node to enter DFU mode (NRF52)",
Expand Down
18 changes: 17 additions & 1 deletion meshtastic/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ def commitSettingsTransaction(self):
return self._sendAdmin(p, onResponse=onResponse)

def rebootOTA(self, secs: int = 10):
"""Tell the node to reboot into factory firmware."""
"""Tell the node to reboot into factory firmware (firmware < 2.7.18)."""
self.ensureSessionKey()
p = admin_pb2.AdminMessage()
p.reboot_ota_seconds = secs
Expand All @@ -667,6 +667,22 @@ def rebootOTA(self, secs: int = 10):
onResponse = self.onAckNak
return self._sendAdmin(p, onResponse=onResponse)

def startOTA(
self,
mode: admin_pb2.OTAMode.ValueType,
hash: bytes,
):
"""Tell the node to start OTA mode (firmware >= 2.7.18)."""
if self != self.iface.localNode:
raise Exception("startOTA only possible in local node")

self.ensureSessionKey()
p = admin_pb2.AdminMessage()
p.ota_request.reboot_ota_mode=mode
p.ota_request.ota_hash=hash

Comment thread
skgsergio marked this conversation as resolved.
return self._sendAdmin(p)

def enterDFUMode(self):
"""Tell the node to enter DFU mode (NRF52)."""
self.ensureSessionKey()
Expand Down
122 changes: 122 additions & 0 deletions meshtastic/ota.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import os
import hashlib
import socket
import logging
from typing import Optional, Callable


logger = logging.getLogger(__name__)


def _file_sha256(filename: str):
"""Calculate SHA256 hash of a file."""
sha256_hash = hashlib.sha256()

with open(filename, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)

return sha256_hash

class ESP32WiFiOTA:
"""ESP32 WiFi Unified OTA updates."""

def __init__(self, filename: str, hostname: str, port: int = 3232):
self._filename = filename
self._hostname = hostname
self._port = port
self._socket: Optional[socket.socket] = None

if not os.path.exists(self._filename):
raise Exception(f"File {self._filename} does not exist")

self._file_hash = _file_sha256(self._filename)

def _read_line(self) -> str:
"""Read a line from the socket."""
if not self._socket:
raise Exception("Socket not connected")

line = b""
while not line.endswith(b"\n"):
char = self._socket.recv(1)

if not char:
raise Exception("Connection closed while waiting for response")

line += char

return line.decode("utf-8").strip()

def hash_bytes(self) -> bytes:
"""Return the hash as bytes."""
return self._file_hash.digest()

def hash_hex(self) -> str:
"""Return the hash as a hex string."""
return self._file_hash.hexdigest()

def update(self, progress_callback: Optional[Callable[[int, int], None]] = None):
"""Perform the OTA update."""
with open(self._filename, "rb") as f:
data = f.read()
size = len(data)

logger.info(f"Starting OTA update with {self._filename} ({size} bytes, hash {self.hash_hex()})")

self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(15)
try:
self._socket.connect((self._hostname, self._port))
logger.debug(f"Connected to {self._hostname}:{self._port}")

# Send start command
self._socket.sendall(f"OTA {size} {self.hash_hex()}\n".encode("utf-8"))

# Wait for OK from the device
while True:
response = self._read_line()
if response == "OK":
break
elif response == "ERASING":
logger.info("Device is erasing flash...")
elif response.startswith("ERR "):
raise Exception(f"Device reported error: {response}")
else:
logger.warning(f"Unexpected response: {response}")

# Stream firmware
sent_bytes = 0
chunk_size = 1024
while sent_bytes < size:
chunk = data[sent_bytes : sent_bytes + chunk_size]
self._socket.sendall(chunk)
sent_bytes += len(chunk)

if progress_callback:
progress_callback(sent_bytes, size)
else:
print(f"[{sent_bytes / size * 100:5.1f}%] Sent {sent_bytes} of {size} bytes...", end="\r")

if not progress_callback:
print()

# Wait for OK from device
logger.info("Firmware sent, waiting for verification...")
while True:
response = self._read_line()

if response == "OK":
logger.info("OTA update completed successfully!")
break
elif response == "ACK":
continue
elif response.startswith("ERR "):
raise Exception(f"OTA update failed: {response}")
else:
logger.warning(f"Unexpected final response: {response}")

finally:
if self._socket:
self._socket.close()
self._socket = None
Loading