#!/usr/bin/env python3 """ Meshtastic Ethernet OTA Upload Tool Uploads firmware to RP2350-based Meshtastic devices via Ethernet (W5500). Compresses firmware with GZIP and sends it over TCP using the MOTA protocol. Authenticates using SHA256 challenge-response with a pre-shared key (PSK). Usage: python bin/eth-ota-upload.py --host 192.168.1.100 firmware.bin python bin/eth-ota-upload.py --host 192.168.1.100 --psk mySecretKey firmware.bin python bin/eth-ota-upload.py --host 192.168.1.100 --psk-hex 6d65736874... firmware.bin """ import argparse import gzip import hashlib import socket import struct import sys import time # Default PSK matching the firmware default: "meshtastic_ota_default_psk_v1!!!" DEFAULT_PSK = b"meshtastic_ota_default_psk_v1!!!" def crc32(data: bytes) -> int: """Compute CRC32 matching ErriezCRC32 (standard CRC32 with final XOR).""" import binascii return binascii.crc32(data) & 0xFFFFFFFF def load_firmware(path: str) -> bytes: """Load firmware file, compressing with GZIP if not already compressed.""" # Reject UF2 files — OTA requires raw .bin firmware if path.lower().endswith(".uf2"): bin_path = path.rsplit(".", 1)[0] + ".bin" print(f"ERROR: UF2 files cannot be used for OTA updates.") print(f" The Updater/picoOTA expects raw .bin firmware.") print(f" Try: {bin_path}") sys.exit(1) with open(path, "rb") as f: data = f.read() # Check if already GZIP compressed (magic bytes 1f 8b) if data[:2] == b"\x1f\x8b": print(f"Firmware already GZIP compressed: {len(data):,} bytes") return data print(f"Firmware raw size: {len(data):,} bytes") compressed = gzip.compress(data, compresslevel=9) ratio = len(compressed) / len(data) * 100 print(f"GZIP compressed: {len(compressed):,} bytes ({ratio:.1f}%)") return compressed def authenticate(sock: socket.socket, psk: bytes) -> bool: """Perform SHA256 challenge-response authentication with the device.""" # Receive 32-byte nonce from server nonce = b"" while len(nonce) < 32: chunk = sock.recv(32 - len(nonce)) if not chunk: print("ERROR: Connection closed during authentication") return False nonce += chunk # Compute SHA256(nonce || PSK) h = hashlib.sha256() h.update(nonce) h.update(psk) response = h.digest() # Send 32-byte response sock.sendall(response) # Wait for auth result (1 byte) result = sock.recv(1) if not result: print("ERROR: No authentication response") return False if result[0] == 0x06: # ACK print("Authentication successful.") return True elif result[0] == 0x07: # OTA_ERR_AUTH print("ERROR: Authentication failed — wrong PSK") return False else: print(f"ERROR: Unexpected auth response 0x{result[0]:02X}") return False def upload_firmware(host: str, port: int, firmware: bytes, psk: bytes, timeout: float) -> bool: """Upload firmware over TCP using the MOTA protocol with PSK authentication.""" fw_crc = crc32(firmware) fw_size = len(firmware) print(f"Connecting to {host}:{port}...") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: sock.connect((host, port)) print("Connected.") # Step 1: Authenticate print("Authenticating...") if not authenticate(sock, psk): return False # Step 2: Send 12-byte MOTA header: magic(4) + size(4) + crc32(4) header = struct.pack("<4sII", b"MOTA", fw_size, fw_crc) sock.sendall(header) print(f"Header sent: size={fw_size:,}, CRC32=0x{fw_crc:08X}") # Wait for ACK (1 byte) ack = sock.recv(1) if not ack or ack[0] != 0x06: error_codes = { 0x02: "Size error", 0x04: "Invalid magic", 0x05: "Update.begin() failed", } code = ack[0] if ack else 0xFF msg = error_codes.get(code, f"Unknown error 0x{code:02X}") print(f"ERROR: Server rejected header: {msg}") return False print("Header accepted. Uploading firmware...") # Send firmware in 1KB chunks chunk_size = 1024 sent = 0 start_time = time.time() while sent < fw_size: end = min(sent + chunk_size, fw_size) chunk = firmware[sent:end] sock.sendall(chunk) sent = end # Progress bar pct = sent * 100 // fw_size bar_len = 40 filled = bar_len * sent // fw_size bar = "█" * filled + "░" * (bar_len - filled) elapsed = time.time() - start_time speed = sent / elapsed if elapsed > 0 else 0 sys.stdout.write(f"\r [{bar}] {pct:3d}% {sent:,}/{fw_size:,} ({speed/1024:.1f} KB/s)") sys.stdout.flush() elapsed = time.time() - start_time print(f"\n Transfer complete in {elapsed:.1f}s") # Wait for final result (1 byte) print("Waiting for verification...") result = sock.recv(1) if not result: print("ERROR: No response from device") return False result_codes = { 0x00: "OK — Update staged, device rebooting", 0x01: "CRC mismatch", 0x02: "Size error", 0x03: "Write error", 0x04: "Magic mismatch", 0x05: "Updater.begin() failed", 0x07: "Auth failed", 0x08: "Timeout", } code = result[0] msg = result_codes.get(code, f"Unknown result 0x{code:02X}") if code == 0x00: print(f"SUCCESS: {msg}") return True else: print(f"ERROR: {msg}") return False except socket.timeout: print("ERROR: Connection timed out") return False except ConnectionRefusedError: print(f"ERROR: Connection refused by {host}:{port}") return False except OSError as e: print(f"ERROR: {e}") return False finally: sock.close() def main(): parser = argparse.ArgumentParser( description="Upload firmware to Meshtastic RP2350 devices via Ethernet OTA" ) parser.add_argument("firmware", help="Path to firmware .bin or .bin.gz file") parser.add_argument("--host", required=True, help="Device IP address") parser.add_argument( "--port", type=int, default=4243, help="OTA port (default: 4243)" ) parser.add_argument( "--timeout", type=float, default=60.0, help="Socket timeout in seconds (default: 60)", ) psk_group = parser.add_mutually_exclusive_group() psk_group.add_argument( "--psk", type=str, help="Pre-shared key as UTF-8 string (default: meshtastic_ota_default_psk_v1!!!)", ) psk_group.add_argument( "--psk-hex", type=str, help="Pre-shared key as hex string (e.g., 6d65736874...)", ) args = parser.parse_args() # Resolve PSK if args.psk: psk = args.psk.encode("utf-8") elif args.psk_hex: try: psk = bytes.fromhex(args.psk_hex) except ValueError: print("ERROR: Invalid hex string for --psk-hex") sys.exit(1) else: psk = DEFAULT_PSK print("Meshtastic Ethernet OTA Upload") print("=" * 40) firmware = load_firmware(args.firmware) if upload_firmware(args.host, args.port, firmware, psk, args.timeout): print("\nDevice is rebooting with new firmware.") sys.exit(0) else: print("\nUpload failed.") sys.exit(1) if __name__ == "__main__": main()