Skip to content

Latest commit

 

History

History
721 lines (525 loc) · 30.3 KB

API.md

File metadata and controls

721 lines (525 loc) · 30.3 KB

NetworkManager — API Reference

Priority-based network connection manager for ESP32, ESP8266 and AVR. This document describes the public API. For an overview and quick start, see README.md.

Table of Contents


Compile-time macros

Define these before including any library header (in your sketch, or via platformio.ini build_flags). They set fixed buffer sizes, so they must be identical in every translation unit — in a multi-file project, define them globally (PlatformIO build_flags; on ESP8266 a <sketch>.ino.globals.h; on ESP32 a build_opt.h). See Where to define these for the per-toolchain details.

Macro Default Description
NETWORK_MANAGER_MAX_ADAPTERS 2 Maximum number of adapters (valid range 1–8).
NETWORK_MANAGER_RECONNECT_TIMEOUT 60000 Milliseconds with nothing connected before DISCONNECTED is emitted. 0 disables it.
NETWORK_MANAGER_MUTEX_TIMEOUT 1000 Mutex acquisition timeout, ms (ESP32 only; ESP8266/AVR use no lock).
NETWORK_MANAGER_DEFAULT_NTP_SYNC_INTERVAL 3600000 Default SNTP poll interval, ms (1 h). Overridable at runtime with setNtpSyncInterval().
NETWORK_MANAGER_DEFAULT_NTP_RETRY_INTERVAL 30000 AVR only: base retry delay after a failed sync (server already resolved), ms; doubles per failure up to _MAX. No effect on ESP.
NETWORK_MANAGER_DEFAULT_NTP_RETRY_MAX 300000 AVR only: cap for the retry backoff, ms.
NETWORK_ADAPTER_RETRY_INTERVAL 15000 Minimum interval between probe attempts on a failed adapter, ms. Also the first wait after a failed start().
NETWORK_ADAPTER_START_RETRY_MAX 300000 Cap for the wait after repeated failed start() calls, ms (the wait doubles per failure from NETWORK_ADAPTER_RETRY_INTERVAL).
NETWORK_MANAGER_NO_GLOBAL undefined Suppresses the global NetworkManager reference. See the note below.

Adapter-specific macros (define before including the adapter header):

Macro Default Applies to
ESP32_WIFI_ADAPTER_DHCP_TIMEOUT 15000 ESP32 WiFi
ESP8266_WIFI_ADAPTER_DHCP_TIMEOUT 15000 ESP8266 WiFi
ESP8266_ETH_ADAPTER_DHCP_TIMEOUT 15000 ESP8266 Ethernet
ESP8266_ETH_ADAPTER_SPI_FREQUENCY 4000000 ESP8266 Ethernet
AVR_ETH_ADAPTER_DHCP_TIMEOUT 15000 AVR Ethernet
AVR_ETH_ADAPTER_LINK_TIMEOUT 5000 AVR Ethernet
AVR_ETH_ADAPTER_POLL_INTERVAL 500 AVR Ethernet
AVR_ETH_ADAPTER_DHCP_BEGIN_TIMEOUT 8000 AVR Ethernet
AVR_ETH_ADAPTER_DHCP_BEGIN_RESPONSE 4000 AVR Ethernet
ETH_PHY_TYPE — (required) ESP8266 Ethernet — ETH_PHY_W5500 / ETH_PHY_W5100 / ETH_PHY_ENC_28J60
ETH_PHY_CS — (required) ESP8266 & AVR Ethernet — SPI chip-select pin

On ESP32 see the documentation of your board and ESP32 Arcuino Core about using ETH_PHY_* macros. If your board is supported by the ESP32 Arduino core (e.g. Olimex ESP32-POE, ESP32-Gateway etc.), select it in 'Tools -> Board' menu in Arduino IDE and the ETH_PHY_* definitions are provided automatically.

NTP buffer sizing is inherited from NetworkProfile / Host (NTP_PROFILE_ENABLED, NTP_PROFILE_SERVER_COUNT, HOST_FQDN_LEN, …); see Memory & fine-tuning in the README.


NetworkManagerClass

NetworkManager is a singleton; use the global instance NetworkManager (there is no need to construct one). All calls below must be made from the same task that calls update() — normally the Arduino loop().

The NetworkManager name

On ESP32 the Arduino core has a class called NetworkManager of its own, in its Network library, whose global instance is Network. The two coexist: the global here is a reference, and a qualified name such as NetworkManager::setHostname() is looked up among types and namespaces only, where the core's class is still found. NetworkManager.begin() finds the reference. Nothing needs doing.

One spelling is ambiguous — naming the core's class as a plain type in a declaration:

NetworkManager *p = &Network;         // finds the reference; does not compile
class NetworkManager *p = &Network;   // fine

If the name is wanted for something else entirely — another library's type, or your own — define NETWORK_MANAGER_NO_GLOBAL before including this header. The global is then not declared, and the singleton is reached through NetworkManagerClass::instance().

Setup & lifecycle

bool addAdapter(NetworkAdapter& adapter)

Registers an adapter. Adapters are ordered by their profile priority (lower number = higher priority; ties broken by registration order). Returns false if the adapter table is full (NETWORK_MANAGER_MAX_ADAPTERS). Call before begin().

bool setHostname(const char* name)

Sets a device-level hostname on the profile of every adapter added so far, so one identity applies to whichever interface serves. Call after addAdapter() (the manager holds no hostname, so a later-added adapter keeps its default until set). "" resets each adapter to its generated default. A MAC is per-interface, so there is deliberately no setMac() here — set it per profile.

void onEvent(EventCb cb)

Registers the connection-event callback: void cb(NetworkManagerClass::Event, NetworkAdapter&). The callback is invoked from the update() task (see Execution model), so it is safe to call any NetworkManager accessor — including getStatus() — from inside it.

At event DISCONNECTED the adapter passed to the callback is the interface that was last serving before the outage (the primary adapter if none ever served).

void onNtpSync(NtpSyncCb cb)

Registers the NTP-sync callback: void cb(). Invoked once the SNTP client completes a genuine time sync (not an RTC/manual settimeofday). ESP32/ESP8266 only.

bool begin()

Starts the manager: sorts adapters by priority and probes the highest-priority one. Returns false if no adapters were added, or if that adapter's start() failed (e.g. an Ethernet chip that does not answer). In the second case update() goes on with the other adapters at once and retries the failed one later (see Failed start()).

void end()

Stops all adapters and resets internal state.

void update()

Drives the state machine: delivers queued events, applies deferred stops/starts, runs the fallback/restore/reconnect decision, and polls poll-driven adapters. Call every loop iteration from a single task.

Status accessors

All status accessors read a cached snapshot of the active adapter; they make no hardware calls and hold no lock across hardware. When nothing is connected they return INADDR_ANY (0.0.0.0) / an empty snapshot.

bool isConnected() const

true if any adapter currently holds an IP. A point-in-time reading — it may stop being true the moment it returns.

NetworkAdapter* getActiveAdapter() const

The currently serving adapter, or nullptr if none. nullptr means nothing is connected, and nothing else.

For reaching the serving adapter's public interface now, not for keeping. The serving adapter changes on every fallback and restore, so a stored pointer goes on naming the interface that was serving. Ask again each time. Storing an index instead buys nothing — the adapter table is fixed after begin(), so a stale index resolves to the same stale adapter. The pointer is never dangling (addAdapter() requires each adapter to outlive the manager); it may simply no longer be the active one.

A failover can also land between the call and your use of the result. That is inherent, not a consequence of the missing lock: locking inside the manager would end before you acted on the pointer either way. The effect is bounded — a stale adapter answers honestly about itself, so you read 0.0.0.0 and a false link rather than anything corrupt. Where it matters, re-check isConnected() or work from getStatus(), which captures everything at once.

Both take no lock and are safe to call from any task.

IPAddress getLocalIP() const

Active adapter's IP address.

NetworkStatus getStatus() const

A consistent snapshot of the active adapter (interface type, connected flag, IP, mask, gateway, DNS). Captured at the moment the adapter acquires/renews an address.

IPAddress getSubnetMask() const

IPAddress getGatewayIP() const

IPAddress getDns(uint8_t i = 0) const

Individual fields of the active snapshot. i is clamped to [0, NETWORK_PROFILE_DNS_SERVER_COUNT).

bool getHostname(char* buf, size_t len, NetworkProfile::ConfigSource source = ConfigSource::ACTIVE) const

bool getMac(NetworkProfile::MACAddress mac, NetworkProfile::ConfigSource source = ConfigSource::ACTIVE) const

Hostname / MAC of the serving interface — the highest-priority connected adapter, or the first adapter if none is connected. ACTIVE returns the effective value (user override if set, else the generated default); FACTORY the generated default. Return false if no adapter has been added.

JSON status

size_t statusToJson(char* out, size_t len, bool includeNtp = false) const

Serialises the active status as a compact JSON object into out (no heap, no String). Size the buffer with NetworkManager.STATUS_JSON_SIZE. Returns the number of characters written (excluding NUL), or 0 on error/overflow (in which case out is set empty).

Always contains interface, connected, ip, mask, gw, and a dns array (populated slots only). With includeNtp, an ntp object is appended holding the overall synced flag and a servers array of the live SNTP servers (name + resolved/numeric ip). A DHCP-provided server shows an empty name.

{"interface":"eth","connected":true,"ip":"172.20.11.78","mask":"255.255.0.0",
 "gw":"172.20.0.1","dns":["172.20.0.121","172.20.0.122"],
 "ntp":{"synced":true,"servers":[{"name":"pool.ntp.org","ip":"162.159.200.1"}]}}

Constants: STATUS_JSON_LEN (max length excl. NUL) and STATUS_JSON_SIZE (= LEN + 1).

Time & NTP

The library exposes NTP information at two layers: configured (what you set, from the profile) and active (what the SNTP client is actually using, read live). See README for the model.

uint32_t getEpoch() const

Current Unix time in seconds, or 0 if not yet synced.

bool isTimeValid() const

true once at least one successful SNTP sync has occurred.

void setNtpProfile(NtpProfile& profile)

The servers every interface uses, unless it has one of its own. A device has one clock, so this is the usual place to say where it comes from: set it once, before begin().

void NetworkAdapter::setNtpProfile(NtpProfile& profile)

Binds servers to a single interface, overriding the device-level profile for that one. Unbound adapters keep using the device-level profile, so the two mix freely.

Needed when interfaces genuinely differ — typically when one network offers a time server over DHCP (option 42) and the other does not. It has to be said on the adapter rather than by re-aiming the device-level profile as the serving interface changes: the manager must decide whether to keep option 42 before it starts an interface, because lwIP settles that during the DHCP handshake, and at that moment the application has not yet been told which interface is taking over. Only the adapter carries the answer that early.

ethAdapter.setNtpProfile(ethNtp);     // this network's own time server
wifiAdapter.setNtpProfile(wifiNtp);   // leave empty to take DHCP's

clearNtpProfile() on either object undoes the corresponding binding.

void clearNtpProfile()

Stops reading servers from the device-level profile. Adapters with their own are unaffected. For the rest nothing is then configured, and on the next connect the manager lets DHCP supply a server instead.

bool getNtp(uint8_t i, char* out, size_t len) const

Configured server i, copied from whichever NtpProfile is currently set. Needs no connection — the servers are persisted settings, so they stay readable while every interface is down, as getHostname() and getMac() do. Returns false if no profile is set or slot i is unused; a name longer than out is truncated to fit and still returns true, so size the buffer at Host::MAX_FQDN_SIZE.

DHCP-provided servers never appear here — they are not configuration. Use the two active getters below for what the SNTP client is really using.

bool getActiveNtpName(uint8_t i, char* out, size_t len) const

Active SNTP server name for slot i, read live from the SNTP client. Non-empty for host-name servers (e.g. pool.ntp.org); empty for numeric / DHCP-provided servers. Returns false when empty.

IPAddress getActiveNtpIP(uint8_t i) const

Active SNTP server address for slot i: the resolved address of a host-name server (which can change as a pool rotates) or a DHCP/numeric server. Returns 0.0.0.0 if the slot is unset or a host name has not been resolved yet — the resolved IP typically appears from the first successful sync onward.

void setNtpSyncInterval(uint32_t ms)

Sets the SNTP poll interval at runtime (clamped to a 15 s floor). ESP8266 requires the NtpSyncHook for this to reach the lwIP SNTP stack; ESP32 and AVR apply it natively.

uint32_t getNtpSyncInterval() const

The configured poll interval, ms.

Applying a profile at runtime

bool applyProfile(NetworkAdapter& adapter)

Stops and restarts adapter with its (possibly updated) profile. Must be called from the update() task. From another task (e.g. a web-server handler), call adapter.requestApply() instead — update() will apply it on the next iteration.

// From any task:
wifiProfile.setConfig(newCfg);   // profiles are thread-safe
wifiAdapter.requestApply();      // applied by update() on the manager task

Event enum

NetworkManagerClass::Event:

Value Meaning
CONNECTED An adapter obtained an IP with nothing serving before it — the first connection at boot, or the first after a DISCONNECTED.
FALLBACK The active adapter failed; a lower-priority adapter took over.
RESTORED A higher-priority adapter recovered and took over from the fallback.
DISCONNECTED Nothing connected for RECONNECT_TIMEOUT, including a boot where no interface ever came up. Emitted at most once per outage. A working fallback does not trigger this.
RECONNECTING Reserved; not currently emitted.

A link outage shorter than RECONNECT_TIMEOUT produces no event: if a higher-priority adapter briefly drops and recovers before the timeout, the decision core still considers it the serving adapter (no DISCONNECTED, hence no subsequent CONNECTED). RESTORED requires a prior fallback (a second adapter), and a fresh CONNECTED means nothing was serving beforehand — either at boot, or after a DISCONNECTED. Application events are therefore deliberately coarse; components needing finer link-state tracking (e.g. the AVR SNTP client) watch the live link independently.


NetworkStatus struct

Returned by NetworkManager::getStatus() and NetworkAdapter::getStatus(). Plain value type (copyable); holds only live netif properties — NTP is not included (read it via the NTP accessors instead).

struct NetworkStatus {
    NetworkProfile::InterfaceType interfaceType;   // ETH / WIFI
    bool      connected;                           // true while an IP is held
    IPAddress localIP;                             // 0.0.0.0 if not connected
    IPAddress subnetMask;
    IPAddress gateway;
    IPAddress dns[NETWORK_PROFILE_DNS_SERVER_COUNT];
};

NetworkAdapter (base class)

Adapters are normally driven by the manager; application code interacts with the adapter object mainly for the accessors below (and, for WiFi, TX power / RSSI).

State enum

enum class State { IDLE, CONNECTING, CONNECTED, FAILED }

Getters

State getState() const

Current lifecycle state.

NetworkStatus getStatus() const

IPAddress getSubnetMask() const

IPAddress getGatewayIP() const

IPAddress getDns(uint8_t i = 0) const

IPAddress getLocalIP() const

Same snapshot semantics as the manager accessors, but for this adapter specifically (whether or not it is the active one).

void setNtpFromDhcp(bool fromDhcp) / bool ntpFromDhcp() const

Whether this interface should accept DHCP option 42. Set by the manager before start(), and acted on where the interface begins its DHCP query — which is the only moment it can be. lwIP decides from this flag whether to keep the offer at all, so setting it once the lease has arrived is too late.

Defaults to true, matching "nothing configured, so take what the network offers". Sketches do not call this; the manager derives it from the NTP profile.

void setNtpProfile(NtpProfile& profile) / void clearNtpProfile() / NtpProfile* ntpProfile() const

Binds this interface's own NTP servers, overriding the manager's device-level profile for this interface alone. Optional: an unbound adapter uses the device-level one, so a device whose interfaces share a clock source needs nothing here. See Time & NTP for when the two differ and why the binding lives on the adapter.

const NetworkProfile& getProfile() const

The associated profile.

bool getHostname(char* buf, size_t len, NetworkProfile::ConfigSource source = ConfigSource::ACTIVE) const

bool getMac(NetworkProfile::MACAddress mac, NetworkProfile::ConfigSource source = ConfigSource::ACTIVE) const

Hostname / MAC of this interface (delegates to its profile). ACTIVE returns the effective value (override if set, else the generated default); FACTORY the generated default. Available before the interface connects — configuration, not runtime state.

Re-apply profiles

void requestApply()

Thread-safe request to re-apply this adapter's profile; honoured by the manager's update(). See applyProfile.

start(), stop(), update() and canProbe() are the manager's orchestration interface — do not call them from application code.

Failed start()

A start() that returns false is a fault of the hardware or of the configuration rather than of the network: an Ethernet chip that does not answer (missing module, loose connector, wrong CS or RST pin), a WiFi profile with no SSID. The manager records it; the adapter is not probed again until RETRY_INTERVAL has passed, doubled for every further failure up to START_RETRY_MAX (15 s, 30 s, 60 s ... 5 min by default), and a successful start() resets it. Meanwhile update() probes the next adapter, so a working fallback takes over at once and is not disturbed by the retries.

This is separate from a connection that fails after a successful start (DHCP timeout, cable pulled, AP gone): that keeps the fixed RETRY_INTERVAL.

A custom adapter that overrides canProbe() must keep the _startRetryDue() check, as the built-in ones do.


Adapters

Each platform provides concrete adapters. Construct them with a matching profile and register with addAdapter():

WiFiProfile      wifiProfile;
ESP32WiFiAdapter wifiAdapter(wifiProfile);
Adapter Header Interface
ESP32WiFiAdapter ESP32WiFiAdapter.h ESP32 WiFi STA
ESP32EthAdapter ESP32EthAdapter.h ESP32 Ethernet (SPI PHY)
ESP8266WiFiAdapter ESP8266WiFiAdapter.h ESP8266 WiFi STA
ESP8266EthAdapter ESP8266EthAdapter.h ESP8266 wired lwIP (W5500 / W5100 / ENC28J60)
AVREthernetAdapter AVREthernetAdapter.h AVR Ethernet (W5100 / W5500)

WiFi adapters — TX power & RSSI

Available on ESP32WiFiAdapter and ESP8266WiFiAdapter (identical signatures):

bool setTxPower(float dBm)

Sets the transmit power at runtime. Writes through to the profile (validated and lock-protected) and applies it immediately, so it also survives reconnects (start() re-applies the profile value). Persist it across reboots by saving the profile (saveCfg()). Returns false if dBm is out of range (nothing changes). Call from the loop() task, or — for a preemptive async web handler on ESP32 — defer the call to the loop, or rely on the profile lock.

float getTxPower() const

The configured TX power in dBm (from the profile), or NaN if the hardware default is in effect. (ESP32's quantised hardware value is available via WiFi.getTxPower().)

int getRssi() const

Current RSSI in dBm from WiFi.RSSI(). Only meaningful while connected.

wifiAdapter.setTxPower(8.5f);
int rssi = wifiAdapter.getRssi();   // e.g. -63

Ethernet adapters

ESP8266 (ESP8266EthAdapter) — select the PHY at compile time; the lwIP wired drivers are distinct types, so this cannot be a runtime value:

#define ETH_PHY_TYPE ETH_PHY_W5500
#define ETH_PHY_CS   16
#include "ESP8266EthAdapter.h"

AVR (AVREthernetAdapter) — set the CS pin:

#define ETH_PHY_CS 10
#include "AVREthernetAdapter.h"

See Platform notes for the W5100 link-detection limitation.

Adapter alias headers

EthAdapter.h and WiFiAdapter.h resolve to the correct platform adapter, so a sketch can be written once and compiled for any target:

#include <EthAdapter.h>    // -> ESP32EthAdapter / ESP8266EthAdapter / AVREthernetAdapter
#include <WiFiAdapter.h>   // -> ESP32WiFiAdapter / ESP8266WiFiAdapter (ESP only)

EthAdapter  ethAdapter(ethProfile);
WiFiAdapter wifiAdapter(wifiProfile);

NetworkManagerNtpSyncHook.h

An opt-in header that makes setNtpSyncInterval() effective on ESP8266. The lwIP2 SNTP stack has no runtime API for the poll interval; it calls a weak function that must be overridden by a strong definition. Because a strong free function cannot live in a header-only, multi-TU library without ODR clashes, the override is provided here for you to include in exactly one translation unit (normally your .ino):

#include "NetworkManager.h"
#include "NetworkManagerNtpSyncHook.h"   // ONE .cpp/.ino only

Including it in more than one TU is a deliberate multiple-definition link error. On ESP32 and AVR the header is a no-op (they set the interval natively), and after inclusion NETWORK_MANAGER_NTP_SYNC_INTERVAL_SETTER is defined so code can detect it at compile time.


Platform notes

Execution model

update() and all other NetworkManager calls must originate from a single task (the Arduino loop(), or a dedicated network task). On ESP32/ESP8266, SDK network events arrive on a separate context; the adapters translate those into state changes and queue the resulting user events, which update() then delivers on the loop task. This means your onEvent / onNtpSync callbacks — and anything they call, such as getStatus() or Serial.print — run on the loop task with a full stack, never on the small SDK event-task stack.

Thread-safety summary:

  • Adapter state and cross-task signals are std::atomic on ESP32/ESP8266, plain on AVR.
  • The fallback/restore decision core is serialised by one mutex (ESP32); ESP8266/AVR are single-context and need no lock.
  • No lock is ever held across start(), stop(), or a user callback.
  • Profiles are independently thread-safe and may be read/written from any task.

Holding DNS and the default route

lwIP keeps one resolver table for the whole stack, not one per netif, so whichever interface last touched it owns it — including one that is merely trying to connect. Measured on both platforms: on ESP32 WiFi.begin() clears the table outright, before association, and leaves it empty for the better part of a second; on both, an arriving lease overwrites whatever the serving interface had.

The consequence is unkind. A primary that keeps retrying and never connects strips DNS from the interface actually carrying traffic: routing stays perfect, and nothing resolves by name.

The manager therefore remembers the serving interface's servers while they are still its own (from the profile under static addressing, from the table at connect under DHCP) and puts them back whenever the table has drifted, checked once a second. A restore also re-arms SNTP, so a name that could not be resolved while the table was empty is looked up again. Reading the table back instead cannot work: it is the very thing that was overwritten.

This repairs damage rather than preventing it. A name looked up inside the window still fails; the check bounds how long the window lasts, it cannot close it.

The gateway needs none of this: it lives per netif, and no other interface's lease moves it — measured, not assumed.

The default route is re-pointed at the serving adapter after a change of serving interface (NetworkAdapter::setDefaultRoute()). esp_netif re-elects the default netif on IP events only, so with a static address there is no DHCP client and no LOST_IP, and the route could otherwise stay on the interface just taken out of service.

Sketches need no action for any of this.

ESP32: IPC-task stack overflow when registering the W5500 interrupt

With ETH_PHY_IRQ set, the core's SPI-Ethernet driver installs the GPIO ISR service during the first ETH.begin(). The IDF runs that registration on the IPC task, whose stack is small; the allocation inside esp_intr_alloc() leaves little headroom, and an interrupt arriving at that moment saves its register frame on top — occasionally tripping the guard, which appears as an intermittent "Stack canary watchpoint triggered (ipc1)" panic. It happens inside the SDK, before any library code runs. Only the FIRST ETH.begin() takes this path (the ISR service is global and installed once), so it is a boot-time phenomenon — in a WiFi-primary setup, only on boots where WiFi is unavailable and Ethernet is therefore started. It has been seen only rarely, and not reproducibly, in the FallbackTestRunner; lowering the Core Debug Level gives enough headroom in practice. Polling mode removes the cause: define ETH_PHY_IRQ as -1. Do not leave it undefined — with the macro missing, the core's argument-less ETH.begin() (which ESP32EthAdapter calls) has no pin set to start from and returns false.

ESP32: "invalid static ip" logged on Ethernet re-arm (harmless)

stop() stops the DHCP client so that a background lease renewal cannot overwrite the serving interface's DNS and gateway. When the link later returns, the core's own ETH_CONNECTED handler runs first, sees a stopped DHCP client, assumes a static address, finds none, and logs E esp_netif_handlers: invalid static ip. The adapter's start() then restarts the DHCP client and the address is obtained normally. The message is cosmetic and appears once per Ethernet re-arm on DHCP profiles.

ESP8266 static configuration: route and link by hand

On ESP8266 the wired driver is LwipIntfDev, which installs the default route only on the DHCP bind path and, on stop(), leaves the netif in place but takes its link down. For a static address the adapter therefore does two things LwipIntfDev does not: after setDefault() it installs the gateway and default route by hand (netif_set_gw + netif_set_default), and on re-arm it brings the netif link back up. Without these a static ESP8266 interface resolves on-link names but cannot reach any off-link host (e.g. a public NTP server), and loses connectivity entirely after a link flap. DHCP configurations are unaffected — LwipIntfDev handles their route and link. ESP32 and AVR install static routes natively and need none of this.

NTP resynchronisation on reconnect

ESP8266 resynchronises NTP on its own SNTP poll cycle after a re-arm, not immediately; ESP32 and AVR trigger an immediate resync on reconnect.

AVR: begin() blocks during DHCP without a link

The Arduino Ethernet library's Ethernet.begin(mac) is synchronous and waits out its internal DHCP timeout when no lease can be obtained — so a cold boot with no link stalls in begin() (~60-80 s by default) before the manager ever sees the adapter fail, delaying the first DISCONNECTED accordingly. The AVR adapter caps this with the timeout overload (AVR_ETH_ADAPTER_DHCP_BEGIN_TIMEOUT). A static address is unaffected — begin() returns immediately. ESP32/ESP8266 do not block in begin() at all.

W5100 has no link detection

Cable-driven fallback requires a controller with hardware link detection — W5500 (or ENC28J60 on ESP8266). On W5100 the driver reports the link as always up (isLinkDetectable() is false; isLinked() is hardcoded true), so:

  • connected() depends only on holding an IP; a physical cable unplug is invisible.
  • With a static IP, the interface never reports failure → fallback does not trigger.
  • With DHCP, failure is seen only when the lease is eventually lost (slow), not on unplug.

W5100 is fine for single-interface use, but not for cable-driven ETH↔WiFi fallback.

AVR SNTP is link-gated

The AVR SNTP client polls only while an adapter is connected. On a controller with link detection (W5500) it pauses within one update() tick of a link loss — faster than the RECONNECT_TIMEOUT event — and resumes with a fresh sync (re-resolve, backoff reset) when the link returns. On W5100 (no link detection) it keeps trying: an accepted compromise for that chip.

DHCP-provided NTP: one server

DHCP option 42 capture is enabled, but the platform lwIP build caps the number of NTP servers taken from DHCP at one (ESP8266: LWIP_DHCP_MAX_NTP_SERVERS, wired into the precompiled lwIP2; ESP32: CONFIG_LWIP_DHCP_MAX_NTP_SERVERS, IDF default 1). Manually configured NTP servers are not limited this way — all NtpProfile::SERVER_COUNT slots are used.


License

MIT — see LICENSE.