feat: add Ethernet OTA support for RP2350/W5500 boards (#10136)

* 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>
This commit is contained in:
Carlos Valdes
2026-06-18 16:28:10 +02:00
committed by GitHub
co-authored by GitHub Claude Sonnet 4.6
parent 68af6b277c
commit 4f0e2dde98
6 changed files with 597 additions and 0 deletions
+11
View File
@@ -6,6 +6,9 @@
#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 USE_ARDUINO_ETHERNET
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5100/W5200/W5500
// Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present.
@@ -154,6 +157,10 @@ static int32_t reconnectETH()
}
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
initEthOTA();
#endif
ethStartupComplete = true;
}
}
@@ -180,6 +187,10 @@ static int32_t reconnectETH()
}
#endif
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
ethOTALoop();
#endif
return 5000; // every 5 seconds
}
+293
View File
@@ -0,0 +1,293 @@
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
#include "ethOTA.h"
#include <ErriezCRC32.h>
#include <SHA256.h>
#include <Updater.h>
#ifdef ARCH_RP2040
#include <hardware/watchdog.h>
#define FEED_WATCHDOG() watchdog_update()
#else
#define FEED_WATCHDOG() ((void)0)
#endif
/// Protocol header sent by the upload tool
struct __attribute__((packed)) OTAHeader {
uint8_t magic[4]; // "MOTA" (Meshtastic OTA)
uint32_t firmwareSize; // Size of the firmware payload in bytes (little-endian)
uint32_t crc32; // CRC32 of the entire firmware payload
};
/// Response codes sent back to the client
enum OTAResponse : uint8_t {
OTA_OK = 0x00,
OTA_ERR_CRC = 0x01,
OTA_ERR_SIZE = 0x02,
OTA_ERR_WRITE = 0x03,
OTA_ERR_MAGIC = 0x04,
OTA_ERR_BEGIN = 0x05,
OTA_ACK = 0x06, // ACK uses ASCII ACK character
OTA_ERR_AUTH = 0x07,
OTA_ERR_TIMEOUT = 0x08,
};
static const uint32_t OTA_TIMEOUT_MS = 30000; // 30s inactivity timeout
static const size_t OTA_CHUNK_SIZE = 1024; // 1KB receive buffer
static const uint32_t OTA_AUTH_COOLDOWN_MS = 5000; // 5s cooldown after failed auth
static const size_t OTA_NONCE_SIZE = 32;
static const size_t OTA_HASH_SIZE = 32;
// OTA PSK — override via USERPREFS_OTA_PSK in userPrefs.jsonc
// USERPREFS_OTA_PSK is stringified by PlatformIO (wrapped in quotes), so we
// use a char[] and sizeof-1 to exclude the trailing NUL byte from the hash.
#ifdef USERPREFS_OTA_PSK
static const char otaPSKString[] = USERPREFS_OTA_PSK;
static const uint8_t *const otaPSK = reinterpret_cast<const uint8_t *>(otaPSKString);
static const size_t otaPSKSize = sizeof(otaPSKString) - 1;
#else
// Default PSK (CHANGE THIS for production deployments)
static const uint8_t otaPSK[] = {0x6d, 0x65, 0x73, 0x68, 0x74, 0x61, 0x73, 0x74, 0x69, 0x63, 0x5f, 0x6f, 0x74, 0x61, 0x5f, 0x64,
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x70, 0x73, 0x6b, 0x5f, 0x76, 0x31, 0x21, 0x21, 0x21};
// = "meshtastic_ota_default_psk_v1!!!"
static const size_t otaPSKSize = sizeof(otaPSK);
#endif
static EthernetServer *otaServer = nullptr;
static uint32_t lastAuthFailure = 0;
static bool readExact(EthernetClient &client, uint8_t *buf, size_t len)
{
size_t received = 0;
uint32_t lastActivity = millis();
while (received < len) {
if (!client.connected()) {
return false;
}
int avail = client.available();
if (avail > 0) {
size_t toRead = min((size_t)avail, len - received);
size_t got = client.read(buf + received, toRead);
received += got;
lastActivity = millis();
} else {
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
return false;
}
delay(1);
}
FEED_WATCHDOG();
}
return true;
}
/// Compute SHA256(nonce || psk) for challenge-response authentication
static void computeAuthHash(const uint8_t *nonce, size_t nonceLen, const uint8_t *psk, size_t pskLen, uint8_t *hashOut)
{
SHA256 sha;
sha.reset();
sha.update(nonce, nonceLen);
sha.update(psk, pskLen);
sha.finalize(hashOut, OTA_HASH_SIZE);
}
/// Challenge-response authentication. Returns true if client is authenticated.
static bool authenticateClient(EthernetClient &client)
{
// Rate-limit after failed auth — close silently so the error byte is not
// misinterpreted as part of the nonce by a re-trying client.
if (lastAuthFailure != 0 && (millis() - lastAuthFailure) < OTA_AUTH_COOLDOWN_MS) {
LOG_WARN("ETH OTA: Auth cooldown active, rejecting connection");
client.stop();
return false;
}
// Generate random nonce
uint8_t nonce[OTA_NONCE_SIZE];
for (size_t i = 0; i < OTA_NONCE_SIZE; i += 4) {
uint32_t r = random();
size_t remaining = OTA_NONCE_SIZE - i;
memcpy(nonce + i, &r, min((size_t)4, remaining));
}
// Send nonce to client
client.write(nonce, OTA_NONCE_SIZE);
// Read client's response: SHA256(nonce || PSK)
uint8_t clientHash[OTA_HASH_SIZE];
if (!readExact(client, clientHash, OTA_HASH_SIZE)) {
LOG_WARN("ETH OTA: Timeout reading auth response");
lastAuthFailure = millis();
return false;
}
// Compute expected hash
uint8_t expectedHash[OTA_HASH_SIZE];
computeAuthHash(nonce, OTA_NONCE_SIZE, otaPSK, otaPSKSize, expectedHash);
// Constant-time comparison to prevent timing attacks
uint8_t diff = 0;
for (size_t i = 0; i < OTA_HASH_SIZE; i++) {
diff |= clientHash[i] ^ expectedHash[i];
}
if (diff != 0) {
LOG_WARN("ETH OTA: Authentication failed");
client.write(OTA_ERR_AUTH);
lastAuthFailure = millis();
return false;
}
// Auth success — send ACK
client.write(OTA_ACK);
LOG_INFO("ETH OTA: Authentication successful");
return true;
}
static void handleOTAClient(EthernetClient &client)
{
LOG_INFO("ETH OTA: Client connected from %u.%u.%u.%u", client.remoteIP()[0], client.remoteIP()[1], client.remoteIP()[2],
client.remoteIP()[3]);
// Step 1: Challenge-response authentication
if (!authenticateClient(client)) {
return;
}
// Step 2: Read 12-byte header
OTAHeader hdr;
if (!readExact(client, (uint8_t *)&hdr, sizeof(hdr))) {
LOG_WARN("ETH OTA: Timeout reading header");
return;
}
// Validate magic
if (memcmp(hdr.magic, "MOTA", 4) != 0) {
LOG_WARN("ETH OTA: Invalid magic");
client.write(OTA_ERR_MAGIC);
return;
}
LOG_INFO("ETH OTA: Firmware size=%u, CRC32=0x%08X", hdr.firmwareSize, hdr.crc32);
// Sanity check on size (must be > 0 and fit in LittleFS)
if (hdr.firmwareSize == 0 || hdr.firmwareSize > 1024 * 1024) {
LOG_WARN("ETH OTA: Invalid firmware size");
client.write(OTA_ERR_SIZE);
return;
}
// Begin the update — this opens firmware.bin on LittleFS
if (!Update.begin(hdr.firmwareSize)) {
LOG_ERROR("ETH OTA: Update.begin() failed, error=%u", Update.getError());
client.write(OTA_ERR_BEGIN);
return;
}
// ACK the header — client can start sending firmware data
client.write(OTA_ACK);
// Receive firmware in chunks
uint8_t buf[OTA_CHUNK_SIZE];
size_t remaining = hdr.firmwareSize;
uint32_t crc = CRC32_INITIAL;
uint32_t lastActivity = millis();
size_t totalReceived = 0;
while (remaining > 0) {
if (!client.connected()) {
LOG_WARN("ETH OTA: Client disconnected during transfer");
Update.end(false);
return;
}
int avail = client.available();
if (avail <= 0) {
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
LOG_WARN("ETH OTA: Timeout during transfer (%u/%u bytes)", totalReceived, hdr.firmwareSize);
client.write(OTA_ERR_TIMEOUT);
Update.end(false);
return;
}
delay(1);
FEED_WATCHDOG();
continue;
}
size_t toRead = min((size_t)avail, min(remaining, sizeof(buf)));
size_t got = client.read(buf, toRead);
if (got == 0)
continue;
// Write to Updater (LittleFS firmware.bin)
size_t written = Update.write(buf, got);
if (written != got) {
LOG_ERROR("ETH OTA: Write failed (wrote %u of %u), error=%u", written, got, Update.getError());
client.write(OTA_ERR_WRITE);
Update.end(false);
return;
}
crc = crc32Update(buf, got, crc);
remaining -= got;
totalReceived += got;
lastActivity = millis();
FEED_WATCHDOG();
// Progress log every ~10%
if (totalReceived % (hdr.firmwareSize / 10 + 1) < got) {
LOG_INFO("ETH OTA: %u%% (%u/%u bytes)", (uint32_t)(100ULL * totalReceived / hdr.firmwareSize), totalReceived,
hdr.firmwareSize);
}
}
// Verify CRC32
uint32_t computedCRC = crc32Final(crc);
if (computedCRC != hdr.crc32) {
LOG_ERROR("ETH OTA: CRC mismatch (expected=0x%08X, computed=0x%08X)", hdr.crc32, computedCRC);
client.write(OTA_ERR_CRC);
Update.end(false);
return;
}
// Finalize — this calls picoOTA.commit() which stages the update for the
// bootloader
if (!Update.end(true)) {
LOG_ERROR("ETH OTA: Update.end() failed, error=%u", Update.getError());
client.write(OTA_ERR_WRITE);
return;
}
LOG_INFO("ETH OTA: Update staged successfully (%u bytes). Rebooting...", hdr.firmwareSize);
client.write(OTA_OK);
client.flush();
delay(500);
// Reboot — the built-in bootloader will apply the update from LittleFS
rp2040.reboot();
}
void initEthOTA()
{
if (!otaServer) {
otaServer = new EthernetServer(ETH_OTA_PORT);
otaServer->begin();
LOG_INFO("ETH OTA: Server listening on TCP port %d", ETH_OTA_PORT);
}
}
void ethOTALoop()
{
if (!otaServer)
return;
EthernetClient client = otaServer->accept();
if (client) {
handleOTAClient(client);
client.stop();
}
}
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "configuration.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
#ifdef USE_ARDUINO_ETHERNET
#include <Ethernet.h>
#else
#include <RAK13800_W5100S.h>
#endif
#define ETH_OTA_PORT 4243
/// Initialize the Ethernet OTA server (call after Ethernet is connected)
void initEthOTA();
/// Poll for incoming OTA connections (call periodically from ethClient
/// reconnect loop)
void ethOTALoop();
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
+15
View File
@@ -3,6 +3,7 @@
#include "hardware/xosc.h"
#include <hardware/clocks.h>
#include <hardware/pll.h>
#include <hardware/watchdog.h>
#include <pico/stdlib.h>
#include <pico/unique_id.h>
@@ -99,6 +100,10 @@ void getMacAddr(uint8_t *dmac)
void rp2040Setup()
{
if (watchdog_caused_reboot()) {
LOG_WARN("Rebooted by watchdog!");
}
/* Sets a random seed to make sure we get different random numbers on each boot. */
uint32_t seed = 0;
if (!HardwareRNG::seed(seed)) {
@@ -128,6 +133,16 @@ void rp2040Setup()
#endif
}
void rp2040Loop()
{
static bool watchdog_running = false;
if (!watchdog_running) {
watchdog_enable(8000, true); // 8s timeout; pauses during debug
watchdog_running = true;
}
watchdog_update();
}
void enterDfuMode()
{
reset_usb_boot(0, 0);