* feat: add Raspberry Pi Pico 2 + W5500 + E22-900M30S variant Adds community variant for Raspberry Pi Pico 2 (RP2350, 4 MB flash) with external WIZnet W5500 Ethernet module and EBYTE E22-900M30S LoRa module (SX1262, 30 dBm PA, 868/915 MHz). Key details: - LoRa on SPI1: GP10/11/12/13 (SCK/MOSI/MISO/CS), RST=GP15, DIO1=GP14, BUSY=GP2, RXEN=GP3 (held HIGH via SX126X_ANT_SW) - W5500 on SPI0: GP16/17/18/19/20 (MISO/CS/SCK/MOSI/RST) - SX126X_DIO2_AS_RF_SWITCH: DIO2→TXEN bridge on module handles PA - SX126X_DIO3_TCXO_VOLTAGE 1.8: TCXO support via EBYTE_E22 flags - DHCP timeout reduced to 10 s to avoid blocking LoRa startup - GPS on UART1/Serial2: GP8 TX, GP9 RX - Reuses WIZNET_5500_EVB_PICO2 code paths for Ethernet init Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * pico2_w5500_e22: rename define and address review feedback Rename WIZNET_5500_EVB_PICO2 to PICO2_W5500_E22 so the variant-specific define matches the variant directory name and isn't confused with an on-board EVB SKU. Review fixes from PR #10135: - Gate the 10 s Ethernet DHCP timeout behind PICO2_W5500_E22 so other Ethernet builds keep the default 60 s behavior; apply the same timeout to reconnectETH() for consistency. - Drop the unused -D EBYTE_E22 flag; EBYTE_E22_900M30S already selects TX_GAIN_LORA / SX126X_MAX_POWER in src/configuration.h. - Rewrite "on-board W5500" comments to describe the external module. - Correct README TX_GAIN_LORA value (7, not 10) and drop the EBYTE_E22 row. * fix(pico2_w5500_e22): drop DEBUG_RP2040_PORT=Serial The arduino-pico framework hooks _write() when DEBUG_RP2040_PORT=Serial is set and dumps raw debug bytes onto USB CDC, corrupting any binary protobuf stream sent through StreamAPI (e.g. `meshtastic --port COMx`). The variant excludes BT and WiFi, so the primary client transport is Ethernet TCP via ethServerAPI — unaffected — but users who configure the node over USB serial would see protobuf decode failures from debug-byte interleaving. Removing the flag restores clean USB CDC. Debug output can still be enabled per-build by adding -D DEBUG_RP2040_PORT=Serial1 to redirect to UART0 instead of USB CDC. * feat: add Ethernet OTA support for RP2350/W5500 boards Adds over-the-air firmware update capability for RP2350-based boards with a WIZnet W5500 Ethernet module (e.g. pico2_w5500_e22). Protocol (MOTA): - SHA256 challenge-response authentication with a configurable PSK (override via USERPREFS_OTA_PSK; default key ships in source) - 12-byte header: magic "MOTA" + firmware size + CRC32 - Firmware received in 1 KB chunks, verified with CRC32, written via Updater (picoOTA), then device reboots to apply - Constant-time hash comparison prevents timing attacks on auth - 30s inactivity timeout + 5s cooldown after failed auth - Response codes 0x00-0x08 map 1:1 to OTAResponse enum Firmware side: - ethOTA.cpp / ethOTA.h: OTA TCP server on port 4243 - ethClient.cpp: wire initEthOTA/ethOTALoop into reconnect loop - main-rp2xx0.cpp: hardware watchdog (8s, paused during debug) - pico2_w5500_e22/platformio.ini: HAS_ETHERNET_OTA flag, filesystem_size bumped to 0.75m for OTA staging Host side: - bin/eth-ota-upload.py: Python uploader with progress and full result-code mapping (matches OTAResponse 0x00-0x08) * style(eth): clang-format ethOTA.cpp per repo .clang-format Reformat to the repo trunk clang-format config (IndentWidth 4, ColumnLimit 130). Resolves the Trunk Check 'Incorrect formatting' failure on PR #10136. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
253 lines
7.7 KiB
Python
253 lines
7.7 KiB
Python
#!/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()
|