Skip to content

Commit 015e2e2

Browse files
Add independent Ethernet/WiFi IP config and primary network interface selection
- Add separate static IP/gateway/subnet configuration for Ethernet interface independent of WiFi static IP configuration - Add primary network interface selection to control which interface lwIP uses for outbound connections (MQTT, NTP, internet access) - Implement setPrimaryNetworkInterface() using netif_set_default() to set lwIP default netif based on user selection - Keep both interfaces active simultaneously when Ethernet connects - Show both interface IPs in UI when both are active - Update WiFi & Network settings UI with Ethernet IP config section and Primary Network Interface selector - Ethernet IP config section hidden when Ethernet Type is None Resolves: ethernet static IP not applying independently of WiFi config Related: #5247 Known limitations: - mDNS resolves to primary interface IP only; per-interface mDNS hostname registration is a candidate for follow-on work - Dual interface operation has been validated on boards using ETH_CLOCK_GPIO0_IN (external clock source). Boards using ETH_CLOCK_GPIO17_OUT may experience instability when WiFi and Ethernet operate simultaneously due to ESP32 PLL instability described in #4703. A future enhancement could detect the board clock mode and conditionally disable WiFi on affected boards, preserving existing single-interface behaviour for those users.
1 parent 0f34973 commit 015e2e2

8 files changed

Lines changed: 296 additions & 23 deletions

File tree

wled00/cfg.cpp

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,22 @@ bool deserializeConfig(JsonObject doc, bool fromFS) {
135135
#ifdef WLED_USE_ETHERNET
136136
JsonObject ethernet = doc[F("eth")];
137137
CJSON(ethernetType, ethernet["type"]);
138-
// NOTE: Ethernet configuration takes priority over other use of pins
138+
// AI: deserialize ethernet static IP configuration.
139+
// Each address is stored as a 4-element JSON array, one byte per octet,
140+
// matching the same pattern used for WiFi static IP (nw.ins[n].ip/gw/sn).
141+
JsonArray eth_ip = ethernet[F("eip")];
142+
JsonArray eth_gw = ethernet[F("egw")];
143+
JsonArray eth_sn = ethernet[F("esn")];
144+
if (!eth_ip.isNull()) {
145+
for (size_t i = 0; i < 4; i++) {
146+
CJSON(ethStaticIP[i], eth_ip[i]);
147+
CJSON(ethStaticGW[i], eth_gw[i]);
148+
CJSON(ethStaticSN[i], eth_sn[i]);
149+
}
150+
}
151+
// AI: deserialize primary network interface selection.
152+
// false = WiFi is default gateway (default), true = Ethernet is default gateway.
153+
CJSON(ethPrimaryInterface, ethernet[F("epi")]);
139154
initEthernet();
140155
#endif
141156

@@ -935,6 +950,21 @@ void serializeConfig(JsonObject root) {
935950
break;
936951
}
937952
}
953+
// AI: serialize ethernet static IP configuration.
954+
// Stored as 4-element JSON arrays, one byte per octet, consistent
955+
// with the WiFi static IP serialisation pattern in nw.ins[n].ip/gw/sn.
956+
// Only written when an ethernet board type is configured.
957+
JsonArray eth_ip = ethernet.createNestedArray(F("eip"));
958+
JsonArray eth_gw = ethernet.createNestedArray(F("egw"));
959+
JsonArray eth_sn = ethernet.createNestedArray(F("esn"));
960+
for (size_t i = 0; i < 4; i++) {
961+
eth_ip.add(ethStaticIP[i]);
962+
eth_gw.add(ethStaticGW[i]);
963+
eth_sn.add(ethStaticSN[i]);
964+
}
965+
// AI: serialize primary network interface selection.
966+
// false = WiFi is primary, true = Ethernet is primary.
967+
ethernet[F("epi")] = ethPrimaryInterface;
938968
#endif
939969

940970
JsonObject hw = root.createNestedObject(F("hw"));

wled00/data/settings_wifi.htm

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,14 @@
133133
Identity:<br><input type="text" id="EI${i}" name="EI${i}" maxlength="64" value="${ident}"><br>
134134
</div>`;
135135
}
136+
// AI: removed "Also used by Ethernet" note from WiFi static IP label
137+
// WiFi and Ethernet now have independent IP configurations
136138
var b = `<div id="net${i}"><hr class="sml">
137139
Network name (SSID${i==0?", empty to not connect":""}):<br><input type="text" id="CS${i}" name="CS${i}" maxlength="32" value="${ssid}" ${i>0?"required":""}><br>
138140
${encryptionTypeField}
139141
Network password:<br><input type="password" name="PW${i}" maxlength="64" value="${pass}"><br>
140142
BSSID (optional):<br><input type="text" id="BS${i}" name="BS${i}" maxlength="12" value="${bssid}"><br>
141-
Static IP (leave at 0.0.0.0 for DHCP)${i==0?"<br>Also used by Ethernet":""}:<br>
143+
Static IP (leave at 0.0.0.0 for DHCP):<br>
142144
<input name="IP${i}0" type="number" class="s" min="0" max="255" value="${ip&0xFF}" required>.<input name="IP${i}1" type="number" class="s" min="0" max="255" value="${(ip>>8)&0xFF}" required>.<input name="IP${i}2" type="number" class="s" min="0" max="255" value="${(ip>>16)&0xFF}" required>.<input name="IP${i}3" type="number" class="s" min="0" max="255" value="${(ip>>24)&0xFF}" required><br>
143145
Static gateway:<br>
144146
<input name="GW${i}0" type="number" class="s" min="0" max="255" value="${gw&0xFF}" required>.<input name="GW${i}1" type="number" class="s" min="0" max="255" value="${(gw>>8)&0xFF}" required>.<input name="GW${i}2" type="number" class="s" min="0" max="255" value="${(gw>>16)&0xFF}" required>.<input name="GW${i}3" type="number" class="s" min="0" max="255" value="${(gw>>24)&0xFF}" required><br>
@@ -166,6 +168,8 @@
166168
function tE() {
167169
// keep the hidden input with MAC addresses, only toggle visibility of the list UI
168170
gId('rlc').style.display = d.Sf.RE.checked ? 'block' : 'none';
171+
// AI: also refresh ethernet IP section visibility on page init
172+
toggleEthIP();
169173
}
170174
// reset remotes: initialize empty list (called from xml.cpp)
171175
function rstR() {
@@ -207,6 +211,18 @@
207211
}
208212
}
209213

214+
// AI: toggleEthIP() shows or hides the Ethernet static IP configuration
215+
// section based on whether an ethernet board type is selected.
216+
// Called on page init (via tE) and on ethernet type dropdown change.
217+
function toggleEthIP() {
218+
const ethSelect = d.Sf.ETH;
219+
if (!ethSelect) return;
220+
const ethIpSection = gId('ethip');
221+
if (!ethIpSection) return;
222+
// AI: value "0" means "None" - no ethernet board selected, hide IP config
223+
ethIpSection.style.display = (ethSelect.value !== "0") ? 'block' : 'none';
224+
}
225+
210226
</script>
211227
</head>
212228
<body>
@@ -228,7 +244,8 @@ <h3>Wireless network</h3>
228244
</div>
229245
<div class="sec" id="ethd">
230246
<h3>Ethernet Type</h3>
231-
<select name="ETH">
247+
<!-- AI: added onchange="toggleEthIP()" to show/hide ethernet IP section -->
248+
<select name="ETH" onchange="toggleEthIP()">
232249
<option value="0">None</option>
233250
<option value="6">IoTorero/ESP32Deux/RGB2Go</option>
234251
<option value="9">ABC! WLED V43 & compatible</option>
@@ -246,15 +263,36 @@ <h3>Ethernet Type</h3>
246263
<option value="1">WT32-ETH01</option>
247264
<option value="13">Gledopto</option>
248265
</select><br><br>
249-
</div>
266+
<!-- AI: below section was generated by an AI -->
267+
<div id="ethip" style="display:none;">
268+
<h3>Ethernet IP Configuration</h3>
269+
Static IP (leave at 0.0.0.0 for DHCP):<br>
270+
<input name="EIP0" type="number" class="s" min="0" max="255" value="0" required>.<input name="EIP1" type="number" class="s" min="0" max="255" value="0" required>.<input name="EIP2" type="number" class="s" min="0" max="255" value="0" required>.<input name="EIP3" type="number" class="s" min="0" max="255" value="0" required><br>
271+
Static gateway (leave at 0.0.0.0 for no gateway):<br>
272+
<input name="EGW0" type="number" class="s" min="0" max="255" value="0" required>.<input name="EGW1" type="number" class="s" min="0" max="255" value="0" required>.<input name="EGW2" type="number" class="s" min="0" max="255" value="0" required>.<input name="EGW3" type="number" class="s" min="0" max="255" value="0" required><br>
273+
Static subnet mask:<br>
274+
<input name="ESN0" type="number" class="s" min="0" max="255" value="255" required>.<input name="ESN1" type="number" class="s" min="0" max="255" value="255" required>.<input name="ESN2" type="number" class="s" min="0" max="255" value="255" required>.<input name="ESN3" type="number" class="s" min="0" max="255" value="0" required><br><br>
275+
276+
<hr class="sml">
277+
278+
<!-- AI: primary network interface selector shown when ethernet board is selected -->
279+
<h3>Primary Network Interface</h3>
280+
Select the primary network WLED should use for internet and for access to network services like NTP, MQTT, etc.<br>
281+
<input type="radio" name="EPI" value="0" checked> WiFi
282+
&nbsp;&lt;--&gt;&nbsp;
283+
<input type="radio" name="EPI" value="1"> Ethernet<br><br>
284+
<!-- AI: end -->
285+
</div>
286+
</div>
250287
<div class="sec">
251288
<h3>DNS & mDNS</h3>
252289
DNS server address:<br>
253290
<input name="D0" type="number" class="s" min="0" max="255" required>.<input name="D1" type="number" class="s" min="0" max="255" required>.<input name="D2" type="number" class="s" min="0" max="255" required>.<input name="D3" type="number" class="s" min="0" max="255" required><br>
254291
<br>
255292
mDNS address (leave empty for no mDNS):<br>
256293
http:// <input type="text" name="CM" maxlength="32"> .local<br>
257-
Client IP: <span class="sip"> Not connected </span> <br>
294+
<!-- AI: updated label to reflect dual interface operation -->
295+
Active IP(s): <span class="sip"> Not connected </span>
258296
</div>
259297
<div class="sec">
260298
<h3>Configure Access Point</h3>

wled00/fcn_declare.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,13 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs
301301

302302
//network.cpp
303303
bool initEthernet(); // result is informational
304+
#if defined(ARDUINO_ARCH_ESP32) && defined(WLED_USE_ETHERNET)
305+
bool initEthernet();
306+
// AI: sets lwIP primary network interface based on ethPrimaryInterface
307+
// by enabling wled to be dual-homed we need to be deterministic about which interface
308+
// wled uses for outgoing connections to minimise asyncronous routing issues.
309+
void setPrimaryNetworkInterface();
310+
#endif
304311
int getSignalQuality(int rssi);
305312
void fillMAC2Str(char *str, const uint8_t *mac);
306313
void fillStr2MAC(uint8_t *mac, const char *str);

wled00/network.cpp

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55

66
#if defined(ARDUINO_ARCH_ESP32) && defined(WLED_USE_ETHERNET)
7+
#include "lwip/netif.h" // AI: required for netif_set_default() and netif_list
8+
#include "lwip/tcpip.h" // AI: required for LOCK_TCPIP_CORE/UNLOCK_TCPIP_CORE
79
// The following six pins are neither configurable nor
810
// can they be re-assigned through IOMUX / GPIO matrix.
911
// See https://docs.espressif.com/projects/esp-idf/en/latest/esp32/hw-reference/esp32/get-started-ethernet-kit-v1.1.html#ip101gri-phy-interface
@@ -274,18 +276,93 @@ bool initEthernet()
274276
}
275277

276278
// https://github.com/wled/WLED/issues/5247
277-
if (multiWiFi[0].staticIP != (uint32_t)0x00000000 && multiWiFi[0].staticGW != (uint32_t)0x00000000) {
278-
ETH.config(multiWiFi[0].staticIP, multiWiFi[0].staticGW, multiWiFi[0].staticSN, dnsAddress);
279+
// AI: apply ethernet static IP configuration using the new dedicated
280+
// ethernet IP variables (ethStaticIP, ethStaticGW, ethStaticSN) rather than
281+
// sharing the first WiFi network's static IP config as was previously done.
282+
// ethStaticIP of 0.0.0.0 means use DHCP for ethernet.
283+
// Gateway of 0.0.0.0 is valid — means no default route via ethernet,
284+
// lwIP will only install a subnet route for the ethernet interface.
285+
if ((uint32_t)ethStaticIP != 0x00000000) {
286+
// AI: always pass the configured gateway to ETH.config().
287+
// Default route selection between interfaces is handled by netif_set_default()
288+
// in setPrimaryNetworkInterface(). Gateway of 0.0.0.0 is explicitly supported
289+
// for users who want ethernet as a stub interface with no onward routing.
290+
ETH.config(ethStaticIP, ethStaticGW, ethStaticSN, dnsAddress);
291+
DEBUG_PRINTF_P(PSTR("initE: Static IP configured. IP=%d.%d.%d.%d GW=%d.%d.%d.%d PNI=%s\n"),
292+
ethStaticIP[0], ethStaticIP[1], ethStaticIP[2], ethStaticIP[3],
293+
ethStaticGW[0], ethStaticGW[1], ethStaticGW[2], ethStaticGW[3],
294+
ethPrimaryInterface ? "ETH" : "WiFi");
279295
} else {
296+
// AI: no static IP configured, use DHCP for ethernet
280297
ETH.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);
298+
DEBUG_PRINTLN(F("initE: DHCP configured for ethernet"));
281299
}
282300

283301
successfullyConfiguredEthernet = true;
284302
DEBUG_PRINTLN(F("initE: *** Ethernet successfully configured! ***"));
285303
return true;
286304
}
287-
#endif
288305

306+
// AI: setPrimaryNetworkInterface() explicitly sets the lwIP primary
307+
// network interface based on the user's ethPrimaryInterface selection.
308+
// Directly tells lwIP which netif to use for outbound traffic, resolving
309+
// asymmetric routing issues where reply packets were routed out the wrong
310+
// interface when both WiFi and Ethernet are active simultaneously.
311+
// Interfaces are identified by name prefix ('en'=ethernet, 'st'=WiFi STA)
312+
// which works correctly for both static IP and DHCP configurations.
313+
// Called from multiple network events to ensure it fires after whichever
314+
// interface comes up last.
315+
316+
// AI: below section was generated by an AI
317+
void setPrimaryNetworkInterface() {
318+
struct netif *netif_iter;
319+
struct netif *target = nullptr;
320+
struct netif *fallback = nullptr;
321+
322+
// AI: interface name prefixes in arduino-esp32 IDF V4 (Tasmota platform):
323+
// 'en' = ethernet, 'st' = WiFi STA. Validated on IDF 4.4.8.
324+
const char *targetName = ethPrimaryInterface ?
325+
"en" :
326+
"st";
327+
328+
// AI: acquire lwIP TCP/IP core lock before accessing netif_list
329+
// and calling netif_set_default() to avoid thread-safety assertions
330+
LOCK_TCPIP_CORE();
331+
332+
for (netif_iter = netif_list; netif_iter != NULL; netif_iter = netif_iter->next) {
333+
if (!netif_is_up(netif_iter) || netif_iter->ip_addr.u_addr.ip4.addr == 0) continue;
334+
const bool isPreferred = (netif_iter->name[0] == targetName[0] &&
335+
netif_iter->name[1] == targetName[1]);
336+
if (isPreferred) {
337+
target = netif_iter;
338+
break;
339+
}
340+
if (!fallback) fallback = netif_iter;
341+
}
342+
343+
// AI: if preferred interface unavailable, fall back to any ready interface
344+
// prevents outbound traffic being pinned to a dead default netif
345+
if (!target && fallback) {
346+
target = fallback;
347+
DEBUG_PRINTLN(F("setPNI: Preferred interface unavailable, using fallback"));
348+
}
349+
350+
if (target != nullptr) {
351+
netif_set_default(target);
352+
DEBUG_PRINTF_P(PSTR("setPNI: Primary netif set to %c%c%d (%d.%d.%d.%d)\n"),
353+
target->name[0], target->name[1], target->num,
354+
ip4_addr1(&target->ip_addr.u_addr.ip4),
355+
ip4_addr2(&target->ip_addr.u_addr.ip4),
356+
ip4_addr3(&target->ip_addr.u_addr.ip4),
357+
ip4_addr4(&target->ip_addr.u_addr.ip4));
358+
} else {
359+
DEBUG_PRINTLN(F("setPNI: No ready interface found, will retry on next IP event"));
360+
}
361+
362+
UNLOCK_TCPIP_CORE();
363+
}
364+
#endif
365+
// AI: end
289366

290367
//by https://github.com/tzapu/WiFiManager/blob/master/WiFiManager.cpp
291368
int getSignalQuality(int rssi)
@@ -383,6 +460,7 @@ bool isWiFiConfigured() {
383460
#define ARDUINO_EVENT_WIFI_SCAN_DONE SYSTEM_EVENT_SCAN_DONE
384461
#define ARDUINO_EVENT_ETH_START SYSTEM_EVENT_ETH_START
385462
#define ARDUINO_EVENT_ETH_CONNECTED SYSTEM_EVENT_ETH_CONNECTED
463+
#define ARDUINO_EVENT_ETH_GOT_IP SYSTEM_EVENT_ETH_GOT_IP // AI: added for DHCP ethernet IP assignment event
386464
#define ARDUINO_EVENT_ETH_DISCONNECTED SYSTEM_EVENT_ETH_DISCONNECTED
387465
#endif
388466

@@ -431,6 +509,11 @@ void WiFiEvent(WiFiEvent_t event)
431509
break;
432510
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
433511
DEBUG_PRINT(F("WiFi-E: IP address: ")); DEBUG_PRINTLN(Network.localIP());
512+
// AI: re-evaluate primary network interface when WiFi gets its IP
513+
// handles both static IP and DHCP scenarios for WiFi interface
514+
#if defined(ARDUINO_ARCH_ESP32) && defined(WLED_USE_ETHERNET)
515+
setPrimaryNetworkInterface();
516+
#endif
434517
break;
435518
case ARDUINO_EVENT_WIFI_STA_CONNECTED:
436519
// followed by IDLE and SCAN_DONE
@@ -465,17 +548,30 @@ void WiFiEvent(WiFiEvent_t event)
465548
case ARDUINO_EVENT_ETH_CONNECTED:
466549
{
467550
DEBUG_PRINTLN(F("ETH-E: Connected"));
468-
if (!apActive) {
469-
WiFi.disconnect(true); // disable WiFi entirely
470-
}
471-
char hostname[64] = {'\0'}; // any "hostname" within a Fully Qualified Domain Name (FQDN) must not exceed 63 characters
472-
getWLEDhostname(hostname, sizeof(hostname), true); // create DNS name based on mDNS name if set, or fall back to standard WLED server name
551+
// AI: WiFi is intentionally kept active when ethernet connects.
552+
// Previously WiFi was disabled here to prevent routing conflicts, but
553+
// with dual-interface support, netif_set_default() handles routing
554+
// preference between interfaces. Disabling WiFi here would defeat the
555+
// purpose of the feature entirely.
556+
char hostname[64] = {'\0'};
557+
getWLEDhostname(hostname, sizeof(hostname), true);
473558
ETH.setHostname(hostname);
559+
// AI: attempt to set default gateway interface on ethernet connect
560+
setPrimaryNetworkInterface();
474561
showWelcomePage = false;
475562
break;
476563
}
564+
case ARDUINO_EVENT_ETH_GOT_IP:
565+
// AI: ethernet DHCP IP assigned — now safe to set default netif
566+
// this event is the reliable trigger for DHCP ethernet configuration
567+
DEBUG_PRINT(F("ETH-E: Got IP: ")); DEBUG_PRINTLN(ETH.localIP());
568+
setPrimaryNetworkInterface();
569+
break;
477570
case ARDUINO_EVENT_ETH_DISCONNECTED:
478571
DEBUG_PRINTLN(F("ETH-E: Disconnected"));
572+
// AI: re-evaluate primary network interface on ethernet disconnect
573+
// ensures fallback to WiFi if ethernet was the primary interface
574+
setPrimaryNetworkInterface();
479575
// This doesn't really affect ethernet per se,
480576
// as it's only configured once. Rather, it
481577
// may be necessary to reconnect the WiFi when

wled00/set.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,25 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage)
147147

148148
#if defined(ARDUINO_ARCH_ESP32) && defined(WLED_USE_ETHERNET)
149149
ethernetType = request->arg(F("ETH")).toInt();
150+
// AI: read ethernet static IP configuration from form POST.
151+
// Each IP address is submitted as four separate octet fields (0-255),
152+
// reassembled here into IPAddress objects matching the same pattern
153+
// used for WiFi static IP fields (IP{n}0-3, GW{n}0-3, SN{n}0-3).
154+
if (request->hasArg(F("EIP0"))) {
155+
for (int i = 0; i < 4; i++) {
156+
char eip[6], egw[6], esn[6];
157+
snprintf_P(eip, sizeof(eip), PSTR("EIP%d"), i);
158+
snprintf_P(egw, sizeof(egw), PSTR("EGW%d"), i);
159+
snprintf_P(esn, sizeof(esn), PSTR("ESN%d"), i);
160+
ethStaticIP[i] = request->arg(eip).toInt();
161+
ethStaticGW[i] = request->arg(egw).toInt();
162+
ethStaticSN[i] = request->arg(esn).toInt();
163+
}
164+
}
165+
// AI: read primary network interface selection.
166+
// PNI field value 0 = WiFi is primary interface, 1 = Ethernet is primary interface.
167+
// Radio buttons only submit when selected so use hasArg with value check.
168+
ethPrimaryInterface = (request->arg(F("EPI")).toInt() == 1);
150169
initEthernet();
151170
#endif
152171
}

0 commit comments

Comments
 (0)