feat: Add Ethernet runtime configuration CLI

- Add ip/gw/subnet/dns get/set commands with SPIFFS persistence
- Implement reconfigureEthernet() method for runtime config
- Fix TCP_CONSOLE_PORT conditional compilation
- Update platformio.ini for consistency (USE_ETHERNET)
- Document Ethernet settings and get bridge.type command
- Fixes #2196 #2197 hardware and console dependencies
This commit is contained in:
Piero Andreini 2026-04-05 12:07:11 +02:00
parent ea72dd0f97
commit 4542e6c86a
12 changed files with 295 additions and 16 deletions

View file

@ -1,7 +1,6 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"pioarduino.pioarduino-ide",
"platformio.platformio-ide"
],
"unwantedRecommendations": [

View file

@ -19,6 +19,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore
- [GPS](#gps-when-gps-support-is-compiled-in)
- [Sensors](#sensors-when-sensor-support-is-compiled-in)
- [Bridge](#bridge-when-bridge-support-is-compiled-in)
- [Ethernet](#ethernet-when-use_ethernet-is-compiled-in)
---
@ -1047,3 +1048,69 @@ region save
**Note:** Returns an error on boards without power management support.
---
### Ethernet (When USE_ETHERNET is compiled in)
#### View or change this node's Ethernet IP address
**Usage:**
- `get ip`
- `set ip <address>`
**Parameters:**
- `address`: IPv4 address (e.g., `192.168.254.21`)
**Set by build flag:** `ETH_STATIC_IP`
**Default:** `0.0.0.0` (uses DHCP if not configured)
**Note:** Requires reboot to apply. Set to `0.0.0.0` to revert to DHCP. If `ETH_STATIC_IP` is defined in build flags and DHCP fails, that compile-time address is used as fallback.
---
#### View or change this node's Ethernet gateway
**Usage:**
- `get gw`
- `set gw <address>`
**Parameters:**
- `address`: IPv4 gateway address (e.g., `192.168.254.254`)
**Set by build flag:** `ETH_GATEWAY`
**Default:** `0.0.0.0`
**Note:** Requires reboot to apply
---
#### View or change this node's Ethernet subnet mask
**Usage:**
- `get subnet`
- `set subnet <address>`
**Parameters:**
- `address`: Subnet mask in dotted decimal notation (e.g., `255.255.255.0`)
**Set by build flag:** `ETH_SUBNET`
**Default:** `0.0.0.0`
**Note:** Requires reboot to apply
---
#### View or change this node's Ethernet DNS server
**Usage:**
- `get dns`
- `set dns <address>`
**Parameters:**
- `address`: IPv4 DNS address (e.g., `8.8.8.8`)
**Set by build flag:** `ETH_DNS`
**Default:** `0.0.0.0`
**Note:** Requires reboot to apply. Configures DNS1 (primary DNS server).
---

View file

@ -3,7 +3,7 @@
#include "MyMesh.h"
#if defined(ESP32) && defined(TCP_CONSOLE_PORT)
#if defined(TCP_CONSOLE_PORT)
#include <helpers/esp32/TCPConsole.h>
TCPConsole tcp_console(nullptr); // prefs set in setup()
#endif
@ -39,7 +39,7 @@ void setup() {
board.begin();
#if defined(ESP32) && defined(TCP_CONSOLE_PORT)
#if defined(TCP_CONSOLE_PORT)
tcp_console.begin();
#endif
@ -104,7 +104,18 @@ void setup() {
the_mesh.begin(fs);
tcp_console.setPrefs(the_mesh.getNodePrefs());
the_mesh.begin(fs);
#ifdef USE_ETHERNET
NodePrefs* prefs = the_mesh.getNodePrefs();
if (prefs->eth_ip != 0) {
board.reconfigureEthernet(prefs->eth_ip, prefs->eth_gateway, prefs->eth_subnet);
}
#endif
#if defined(TCP_CONSOLE_PORT)
tcp_console.setPrefs(the_mesh.getNodePrefs());
#endif
#ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);

View file

@ -3,7 +3,7 @@
#include "MyMesh.h"
#if defined(ESP32) && defined(TCP_CONSOLE_PORT)
#if defined(TCP_CONSOLE_PORT)
#include <helpers/esp32/TCPConsole.h>
TCPConsole tcp_console(nullptr); // prefs set in setup()
#endif
@ -81,7 +81,16 @@ void setup() {
the_mesh.begin(fs);
#ifdef USE_ETHERNET
NodePrefs* prefs = the_mesh.getNodePrefs();
if (prefs->eth_ip != 0) {
board.reconfigureEthernet(prefs->eth_ip, prefs->eth_gateway, prefs->eth_subnet);
}
#endif
#if defined(TCP_CONSOLE_PORT)
tcp_console.setPrefs(the_mesh.getNodePrefs());
#endif
#ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);

View file

@ -66,6 +66,7 @@ public:
virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; }
virtual uint8_t getShutdownReason() const { return 0; }
virtual const char* getShutdownReasonString(uint8_t reason) { return "Not available"; }
virtual void reconfigureEthernet(uint32_t ip, uint32_t gw, uint32_t subnet) { /* no op */ }
};
/**

View file

@ -26,6 +26,38 @@ static bool isValidName(const char *n) {
return true;
}
// Helper functions for IP address conversion
static uint32_t ipStringToUint32(const char* ip_str) {
uint32_t ip = 0;
uint8_t parts[4] = {0, 0, 0, 0};
sscanf(ip_str, "%hhu.%hhu.%hhu.%hhu", &parts[0], &parts[1], &parts[2], &parts[3]);
ip = ((uint32_t)parts[0] << 24) | ((uint32_t)parts[1] << 16) | ((uint32_t)parts[2] << 8) | parts[3];
return ip;
}
static void uint32ToIPString(uint32_t ip, char* buffer, size_t size) {
uint8_t b1 = (ip >> 24) & 0xFF;
uint8_t b2 = (ip >> 16) & 0xFF;
uint8_t b3 = (ip >> 8) & 0xFF;
uint8_t b4 = ip & 0xFF;
snprintf(buffer, size, "%d.%d.%d.%d", b1, b2, b3, b4);
}
// Reject 0.x.x.x and multicast/reserved (>= 224.x.x.x). 0 is valid (means DHCP/clear).
// Guards against stale bytes from older prefs-file layouts ending up at the netif.
static bool isValidUnicastIp(uint32_t ip) {
if (ip == 0) return true;
uint8_t first = (ip >> 24) & 0xFF;
return (first >= 1 && first <= 223);
}
// Valid subnet mask = contiguous 1s followed by contiguous 0s.
static bool isValidSubnetMask(uint32_t mask) {
if (mask == 0) return true;
uint32_t inv = ~mask;
return (inv & (inv + 1)) == 0;
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
if (fs->exists("/com_prefs")) {
loadPrefsInt(fs, "/com_prefs"); // new filename
@ -87,8 +119,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
// next: 291
file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.read((uint8_t *)&_prefs->eth_ip, sizeof(_prefs->eth_ip)); // 291
file.read((uint8_t *)&_prefs->eth_gateway, sizeof(_prefs->eth_gateway)); // 295
file.read((uint8_t *)&_prefs->eth_subnet, sizeof(_prefs->eth_subnet)); // 299
file.read((uint8_t *)&_prefs->eth_dns1, sizeof(_prefs->eth_dns1)); // 303
file.read((uint8_t *)&_prefs->eth_dns2, sizeof(_prefs->eth_dns2)); // 307
// 311
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@ -119,6 +156,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
// sanitise settings
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
// Sanitise eth_* fields: an older firmware version may have written different
// data at these offsets. Drop anything that isn't a plausible unicast IP / mask
// so we don't push garbage into esp_netif_set_ip_info() at boot.
if (!isValidUnicastIp(_prefs->eth_ip)) _prefs->eth_ip = 0;
if (!isValidUnicastIp(_prefs->eth_gateway)) _prefs->eth_gateway = 0;
if (!isValidUnicastIp(_prefs->eth_dns1)) _prefs->eth_dns1 = 0;
if (!isValidUnicastIp(_prefs->eth_dns2)) _prefs->eth_dns2 = 0;
if (!isValidSubnetMask(_prefs->eth_subnet)) _prefs->eth_subnet = 0;
file.close();
}
}
@ -178,9 +224,13 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
// next: 291
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.write((uint8_t *)&_prefs->eth_ip, sizeof(_prefs->eth_ip)); // 291
file.write((uint8_t *)&_prefs->eth_gateway, sizeof(_prefs->eth_gateway)); // 295
file.write((uint8_t *)&_prefs->eth_subnet, sizeof(_prefs->eth_subnet)); // 299
file.write((uint8_t *)&_prefs->eth_dns1, sizeof(_prefs->eth_dns1)); // 303
file.write((uint8_t *)&_prefs->eth_dns2, sizeof(_prefs->eth_dns2)); // 307
// 311
file.close();
}
}
@ -725,6 +775,44 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
_prefs->adc_multiplier = 0.0f;
strcpy(reply, "Error: unsupported by this board");
};
#ifdef USE_ETHERNET
} else if (memcmp(config, "ip ", 3) == 0) {
uint32_t ip = ipStringToUint32(&config[3]);
if (ip == UINT32_MAX || (ip != 0 && !isValidUnicastIp(ip))) {
strcpy(reply, "Error: invalid IP");
} else {
_prefs->eth_ip = ip;
savePrefs();
strcpy(reply, ip == 0 ? "OK - reboot to apply (will use DHCP)" : "OK - reboot to apply");
}
} else if (memcmp(config, "subnet ", 7) == 0) {
uint32_t subnet = ipStringToUint32(&config[7]);
if (subnet == UINT32_MAX || !isValidSubnetMask(subnet)) {
strcpy(reply, "Error: invalid subnet mask");
} else {
_prefs->eth_subnet = subnet;
savePrefs();
strcpy(reply, "OK - reboot to apply");
}
} else if (memcmp(config, "gw ", 3) == 0) {
uint32_t gw = ipStringToUint32(&config[3]);
if (gw == UINT32_MAX || (gw != 0 && !isValidUnicastIp(gw))) {
strcpy(reply, "Error: invalid IP");
} else {
_prefs->eth_gateway = gw;
savePrefs();
strcpy(reply, "OK - reboot to apply");
}
} else if (memcmp(config, "dns ", 4) == 0) {
uint32_t dns = ipStringToUint32(&config[4]);
if (dns == UINT32_MAX || (dns != 0 && !isValidUnicastIp(dns))) {
strcpy(reply, "Error: invalid IP");
} else {
_prefs->eth_dns1 = dns;
savePrefs();
strcpy(reply, "OK - reboot to apply");
}
#endif
} else {
sprintf(reply, "unknown config: %s", config);
}
@ -840,6 +928,24 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel);
} else if (memcmp(config, "bridge.secret", 13) == 0) {
sprintf(reply, "> %s", _prefs->bridge_secret);
#endif
#ifdef USE_ETHERNET
} else if (memcmp(config, "ip", 2) == 0) {
char ip_str[16];
uint32ToIPString(_prefs->eth_ip, ip_str, sizeof(ip_str));
sprintf(reply, "> %s", ip_str);
} else if (memcmp(config, "subnet", 6) == 0) {
char subnet_str[16];
uint32ToIPString(_prefs->eth_subnet, subnet_str, sizeof(subnet_str));
sprintf(reply, "> %s", subnet_str);
} else if (memcmp(config, "gw", 2) == 0) {
char gw_str[16];
uint32ToIPString(_prefs->eth_gateway, gw_str, sizeof(gw_str));
sprintf(reply, "> %s", gw_str);
} else if (memcmp(config, "dns", 3) == 0) {
char dns_str[16];
uint32ToIPString(_prefs->eth_dns1, dns_str, sizeof(dns_str));
sprintf(reply, "> %s", dns_str);
#endif
} else if (memcmp(config, "bootloader.ver", 14) == 0) {
#ifdef NRF52_PLATFORM

View file

@ -61,6 +61,12 @@ struct NodePrefs { // persisted to file
uint8_t rx_boosted_gain; // power settings
uint8_t path_hash_mode; // which path mode to use when sending
uint8_t loop_detect;
// Ethernet settings
uint32_t eth_ip;
uint32_t eth_gateway;
uint32_t eth_subnet;
uint32_t eth_dns1;
uint32_t eth_dns2;
};
class CommonCLICallbacks {

View file

@ -130,12 +130,15 @@ void TEthEliteBoard::startEthernet() {
ETH.begin(ETH_PHY_W5500, ETH_ADDR, ETH_CS, ETH_INT, -1, SPI2_HOST, ETH_SCLK, ETH_MISO, ETH_MOSI);
delay(100);
#ifdef ETH_STATIC_IP
#ifndef USE_ETHERNET
// Used only if USE_ETHERNET is not enabled
#ifdef ETH_STATIC_IP
IPAddress ip(ETH_STATIC_IP);
IPAddress gw(ETH_GATEWAY);
IPAddress mask(ETH_SUBNET);
IPAddress dns(ETH_DNS);
ETH.config(ip, gw, mask, dns);
#endif
#endif
unsigned long t0 = millis();
@ -161,5 +164,30 @@ void TEthEliteBoard::startEthernet() {
Serial.print("ETH IP "); Serial.println(ETH.localIP());
Serial.println(ETH.linkUp() ? "ETH LINK UP" : "ETH LINK DOWN");
}
void TEthEliteBoard::reconfigureEthernet(uint32_t ip, uint32_t gw, uint32_t subnet) {
if (ip != 0) {
uint8_t b1 = (ip >> 24) & 0xFF;
uint8_t b2 = (ip >> 16) & 0xFF;
uint8_t b3 = (ip >> 8) & 0xFF;
uint8_t b4 = ip & 0xFF;
uint8_t gw1 = (gw >> 24) & 0xFF;
uint8_t gw2 = (gw >> 16) & 0xFF;
uint8_t gw3 = (gw >> 8) & 0xFF;
uint8_t gw4 = gw & 0xFF;
uint8_t sub1 = (subnet >> 24) & 0xFF;
uint8_t sub2 = (subnet >> 16) & 0xFF;
uint8_t sub3 = (subnet >> 8) & 0xFF;
uint8_t sub4 = subnet & 0xFF;
ETH.config(
IPAddress(b1, b2, b3, b4),
IPAddress(gw1, gw2, gw3, gw4),
IPAddress(sub1, sub2, sub3, sub4)
);
Serial.printf("ETH reconfigured to %d.%d.%d.%d\n", b1, b2, b3, b4);
eth_local_ip = ETH.localIP().toString(); // Aggiorna IP locale per OTA
}
}
#endif

View file

@ -73,7 +73,8 @@ public:
void startNetwork();
void startEthernet();
void startWifi();
void reconfigureEthernet(uint32_t ip, uint32_t gw, uint32_t subnet);
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);

View file

@ -73,6 +73,7 @@ public:
void startNetwork();
void startEthernet();
void startWifi();
void reconfigureEthernet(uint32_t ip, uint32_t gw, uint32_t subnet);
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);

View file

@ -18,6 +18,8 @@ build_flags =
-D PIN_GPS_RX=39
-D PIN_GPS_TX=42
-D PIN_GPS_EN=-1
-D PIN_USER_BTN_ANA=7
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/lilygo_t_eth_elite_sx1262>
+<helpers/esp32/TEthEliteBoard.cpp>
@ -55,7 +57,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D USE_ETHERNET
-D ETH_STATIC_IP=192,168,254,20
-D ETH_STATIC_IP=192,168,254,21
-D ETH_GATEWAY=192,168,254,254
-D ETH_SUBNET=255,255,255,0
-D ETH_DNS=8,8,8,8
@ -83,6 +85,30 @@ lib_deps =
${LilyGo_T_ETH_Elite_SX1262.lib_deps}
${esp32_ota.lib_deps}
[env:LilyGo_T_ETH_Elite_SX1262_repeater_bridge_espnow_eth]
extends = LilyGo_T_ETH_Elite_SX1262
upload_speed = 115200
build_flags =
${LilyGo_T_ETH_Elite_SX1262.build_flags}
-D ADVERT_NAME='"T-ETH Elite SX1262 ESPNow Bridge eth"'
-D ADVERT_LAT=0
-D ADVERT_LON=0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
-D USE_ETHERNET
-D ETH_STATIC_IP=192,168,254,21
-D ETH_GATEWAY=192,168,254,254
-D ETH_SUBNET=255,255,255,0
-D ETH_DNS=8,8,8,8
-D TCP_CONSOLE_PORT=4242
build_src_filter = ${LilyGo_T_ETH_Elite_SX1262.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater>
lib_deps =
${LilyGo_T_ETH_Elite_SX1262.lib_deps}
${esp32_ota.lib_deps}
[env:LilyGo_T_ETH_Elite_SX1262_room_server]
extends = LilyGo_T_ETH_Elite_SX1262
build_flags =

View file

@ -17,6 +17,7 @@ build_flags =
-D PIN_GPS_RX=39
-D PIN_GPS_TX=42
-D PIN_GPS_EN=-1
-D PIN_USER_BTN_ANA=7
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/lilygo_t_eth_elite_sx1276>
+<helpers/esp32/TEthEliteBoard.cpp>
@ -54,7 +55,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D USE_ETHERNET
-D ETH_STATIC_IP=192,168,254,20
-D ETH_STATIC_IP=192,168,254,21
-D ETH_GATEWAY=192,168,254,254
-D ETH_SUBNET=255,255,255,0
-D ETH_DNS=8,8,8,8
@ -82,6 +83,29 @@ lib_deps =
${LilyGo_T_ETH_Elite_SX1276.lib_deps}
${esp32_ota.lib_deps}
[env:LilyGo_T_ETH_Elite_SX1276_repeater_bridge_espnow_eth]
extends = LilyGo_T_ETH_Elite_SX1276
build_flags =
${LilyGo_T_ETH_Elite_SX1276.build_flags}
-D ADVERT_NAME='"T-ETH Elite SX1276 ESPNow Bridge eth"'
-D ADVERT_LAT=0
-D ADVERT_LON=0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
-D USE_ETHERNET
-D ETH_STATIC_IP=192,168,254,20
-D ETH_GATEWAY=192,168,254,254
-D ETH_SUBNET=255,255,255,0
-D ETH_DNS=8,8,8,8
-D TCP_CONSOLE_PORT=4242
build_src_filter = ${LilyGo_T_ETH_Elite_SX1276.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater>
lib_deps =
${LilyGo_T_ETH_Elite_SX1276.lib_deps}
${esp32_ota.lib_deps}
[env:LilyGo_T_ETH_Elite_SX1276_room_server]
extends = LilyGo_T_ETH_Elite_SX1276
build_flags =