Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
252 changes: 252 additions & 0 deletions bin/eth-ota-upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/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()
4 changes: 3 additions & 1 deletion src/DebugConfiguration.h
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
// Default Bluetooth PIN
#define defaultBLEPin 123456

#if HAS_ETHERNET && !defined(USE_WS5500)
#if HAS_ETHERNET && defined(WIZNET_5500_EVB_PICO2)
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5500 auto-detect
#elif HAS_ETHERNET && !defined(USE_WS5500)
#include <RAK13800_W5100S.h>
#endif // HAS_ETHERNET

Expand Down
6 changes: 5 additions & 1 deletion src/mesh/api/ethServerAPI.h
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#pragma once

#include "ServerAPI.h"
#ifndef USE_WS5500
#if !defined(USE_WS5500)
#if defined(WIZNET_5500_EVB_PICO2)
#include <Ethernet.h>
#else
#include <RAK13800_W5100S.h>
#endif

/**
* Provides both debug printing and, if the client starts sending protobufs to us, switches to send/receive protobufs
Expand Down
33 changes: 32 additions & 1 deletion src/mesh/eth/ethClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
#include "main.h"
#include "mesh/api/ethServerAPI.h"
#include "target_specific.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
#include "mesh/eth/ethOTA.h"
#endif
#ifdef WIZNET_5500_EVB_PICO2
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5100/W5200/W5500
#else
#include <RAK13800_W5100S.h>
#endif
#include <SPI.h>

#if HAS_NETWORKING
Expand Down Expand Up @@ -69,13 +76,21 @@ static int32_t reconnectETH()
delay(100);
#endif

#ifdef WIZNET_5500_EVB_PICO2 // Re-configure SPI0 for the on-board W5500
SPI.setRX(ETH_SPI0_MISO);
SPI.setSCK(ETH_SPI0_SCK);
SPI.setTX(ETH_SPI0_MOSI);
SPI.begin();
Ethernet.init(PIN_ETHERNET_SS);
#else
#ifdef RAK11310
ETH_SPI_PORT.setSCK(PIN_SPI0_SCK);
ETH_SPI_PORT.setTX(PIN_SPI0_MOSI);
ETH_SPI_PORT.setRX(PIN_SPI0_MISO);
ETH_SPI_PORT.begin();
#endif
Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS);
#endif

int status = 0;
if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) {
Expand Down Expand Up @@ -136,6 +151,10 @@ static int32_t reconnectETH()
}
#endif

#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
initEthOTA();
#endif

ethStartupComplete = true;
}
}
Expand All @@ -162,6 +181,10 @@ static int32_t reconnectETH()
}
#endif

#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
ethOTALoop();
#endif

return 5000; // every 5 seconds
}

Expand All @@ -182,13 +205,21 @@ bool initEthernet()
digitalWrite(PIN_ETHERNET_RESET, HIGH); // Reset Time.
#endif

#ifdef WIZNET_5500_EVB_PICO2 // Configure SPI0 for the on-board W5500
SPI.setRX(ETH_SPI0_MISO);
SPI.setSCK(ETH_SPI0_SCK);
SPI.setTX(ETH_SPI0_MOSI);
SPI.begin();
Ethernet.init(PIN_ETHERNET_SS);
#else
#ifdef RAK11310 // Initialize the SPI port
ETH_SPI_PORT.setSCK(PIN_SPI0_SCK);
ETH_SPI_PORT.setTX(PIN_SPI0_MOSI);
ETH_SPI_PORT.setRX(PIN_SPI0_MISO);
ETH_SPI_PORT.begin();
#endif
Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS);
#endif

uint8_t mac[6];

Expand All @@ -201,7 +232,7 @@ bool initEthernet()

if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) {
LOG_INFO("Start Ethernet DHCP");
status = Ethernet.begin(mac);
status = Ethernet.begin(mac, 10000); // 10s timeout instead of default 60s
} else if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_STATIC) {
LOG_INFO("Start Ethernet Static");
Ethernet.begin(mac, config.network.ipv4_config.ip, config.network.ipv4_config.dns, config.network.ipv4_config.gateway,
Expand Down
Loading