Skip to content

About

An Arduino library for priority-based network connection management on ESP32, ESP8266 and AVR. It handles connection, fallback, reconnection, live status, and NTP synchronisation across multiple interfaces with a small, event-driven, heap-free API.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

NetworkManager

An Arduino library for priority-based network connection management on ESP32, ESP8266, and AVR. It handles connection, fallback, reconnection, live status, and NTP synchronisation across multiple interfaces with a small, event-driven, heap-free API.

Full API details are in API.md.

Architecture

NetworkManager          (singleton — connection orchestration)
├── NetworkManagerCore  (pure decision logic — host-testable)
├── EthAdapter          (platform adapter — hardware lifecycle)
│   └── EthProfile      (configuration — IP, DNS, priority, MAC)
└── WiFiAdapter
    └── WiFiProfile

Profile (NetworkProfile) is a pure, thread-safe data class holding one interface's configuration (IP, DNS, hostname, MAC, priority, TX power). It makes no hardware calls and can be read or written at any time, including while the interface is active.

Adapter wraps a platform interface (WiFi, Ethernet) and owns its lifecycle (start(), stop(), update()), translating profile settings into platform API calls. Five adapters are provided; custom ones subclass NetworkAdapter.

NetworkManagerCore is the fallback / restore / reconnect decision logic as a pure C++ class with no hardware or threading dependencies — it can be compiled and tested on a host PC.

NetworkManager orchestrates the adapters: it sorts them by priority, starts the highest-priority one, monitors state, and drives fallback, reconnect and restore. Application code interacts only with NetworkManager after setup.

Features

  • Priority-based fallback — the highest-priority connected adapter is always preferred
  • Automatic reconnect — failed connections are retried; DISCONNECTED is emitted only on a genuine total outage (including a boot where nothing ever connects), once per outage
  • Higher-priority restore — a recovered primary interface is restored automatically
  • Event callbacks — CONNECTED, FALLBACK, RESTORED, DISCONNECTED, delivered on the loop task
  • Live status snapshot — getStatus() / individual getters / statusToJson() for a web or MQTT payload
  • NTP — sync callback, configured vs. live-server accessors, runtime poll interval, and automatic DHCP option 42
  • Runtime WiFi control — setTxPower() / getTxPower() / getRssi()
  • No heap, no String — all state is fixed-size
  • Compile-time tuning — adapter count, timeouts, and buffer sizes via macros

Supported platforms and adapters

Platform Adapter Interface
ESP32 ESP32WiFiAdapter WiFi STA
ESP32 ESP32EthAdapter Ethernet (SPI/RMII PHY)
ESP8266 ESP8266WiFiAdapter WiFi STA
ESP8266 ESP8266EthAdapter wired lwIP (W5500 / W5100 / ENC28J60)
AVR AVREthernetAdapter Ethernet (W5100 / W5200 / W5500)

The alias headers EthAdapter.h and WiFiAdapter.h resolve to the correct adapter for the target, so a fallback sketch can be written once and built for ESP32 or ESP8266 unchanged.

Dependencies

Quick Start

#include <WiFiAdapter.h>
#include <NtpProfile.h>
#include <NetworkManager.h>

WiFiProfile wifiProfile;
WiFiAdapter wifiAdapter(wifiProfile);
NtpProfile  ntpProfile;

void onNetworkEvent(NetworkManagerClass::Event event, NetworkAdapter&) {
    if (event == NetworkManagerClass::Event::CONNECTED) {
        NetworkStatus s = NetworkManager.getStatus();
        Serial.print("Connected — IP: ");
        Serial.println(s.localIP);
    } else if (event == NetworkManagerClass::Event::DISCONNECTED) {
        Serial.println("Disconnected");
    }
}

void onNtpSync() {
    Serial.print("NTP synced — epoch ");
    Serial.println(NetworkManager.getEpoch());
}

void setup() {
    Serial.begin(115200);

    WiFiProfile::WiFiConfig cfg;
    cfg.dhcp     = true;
    cfg.priority = 0;
    // MAX_SSID_LEN is a macro (collides with the ESP32 SDK's) — use it
    // unqualified, or copy with sizeof(dest)-1.
    strncpy(cfg.ssid,     "MyNetwork",    MAX_SSID_LEN);
    strncpy(cfg.password, "MyPassword",   WiFiProfile::MAX_PASSWORD_LEN);
    wifiProfile.setConfig(cfg);

    NetworkManager.onEvent(onNetworkEvent);
    // The NTP servers, device-wide: one NtpProfile, handed to the
    // manager, rather than a copy inside every interface's configuration.
    ntpProfile.setServer("pool.ntp.org", 0);
    NetworkManager.setNtpProfile(ntpProfile);
    NetworkManager.onNtpSync(onNtpSync);
    NetworkManager.addAdapter(wifiAdapter);
    NetworkManager.begin();
}

void loop() {
    NetworkManager.update();
    delay(10);
}

See the examples/ directory for ETH-only, WiFi-only and ETH+WiFi fallback sketches.

ETH → WiFi fallback

Lower priority number = higher priority. When the primary fails, the manager starts the next adapter and emits FALLBACK; when the primary recovers it is restored and the fallback stopped (RESTORED).

ethCfg.priority  = 0;   // primary
wifiCfg.priority = 1;   // fallback

NetworkManager.addAdapter(ethAdapter);
NetworkManager.addAdapter(wifiAdapter);
NetworkManager.begin();

If two adapters share a priority value, the tie is broken by registration order. The restore probe calls start() on the primary while the fallback is active, so this feature is intended for independent hardware interfaces (ETH + WiFi), not two adapters that share one radio.

Cable-driven fallback needs hardware link detection (W5500 or ENC28J60). On W5100 the driver reports the link as unknown, so a physical unplug is invisible: with a static IP the interface never reports failure (no fallback), and with DHCP failure is only seen when the lease is eventually lost. W5100 is fine for single-interface use.

Status

getStatus() returns a consistent snapshot of the active interface; individual getters and a JSON serialiser are also provided. All read a cached snapshot — no hardware calls, safe from a callback.

NetworkStatus s = NetworkManager.getStatus();     // interfaceType, connected, localIP, subnetMask, gateway, dns[]
IPAddress gw    = NetworkManager.getGatewayIP();

char js[NetworkManager.STATUS_JSON_SIZE];
if (NetworkManager.statusToJson(js, sizeof(js), /*includeNtp=*/true))
    server.send(200, "application/json", js);      // or mqtt.publish(topic, js)

statusToJson() always emits interface, connected, ip, mask, gw, and a dns array; with includeNtp it appends an ntp object (synced + a servers array). See API.md.

NTP servers

The servers come from an NtpProfile handed over with NetworkManager.setNtpProfile() — a device-level setting, since a device has one clock.

Where interfaces genuinely reach different time servers, bind a profile to the adapter instead, with NetworkAdapter::setNtpProfile(). An adapter's own profile wins; unbound adapters keep using the device-level one, so a device can mix the two. This matters most when one network offers a time server over DHCP (option 42) and the other does not: the manager has to know which it is dealing with before it starts an interface, since lwIP settles the option-42 question during the DHCP handshake, and at that moment the application has not yet been told which interface is taking over. EthWiFiFallback and FallbackTestRunner both bind per interface.

With no profile applying to an interface, nothing is configured and that interface's DHCP-provided servers are used, if the lease carries any.

NTP (two views)

NTP information is available at two layers:

  • Configured — what you set: NtpProfile::getServer(i, buf, len).
  • Active — what the SNTP client is actually using, read live: getActiveNtpName(i, buf, len) and getActiveNtpIP(i).

The active view also reflects DHCP-provided servers and the address a pool name currently resolves to. onNtpSync() fires on the first genuine sync; isTimeValid() / getEpoch() report the time state.

setNtpSyncInterval(ms) changes the poll cadence at runtime. On ESP32/AVR this is native; on ESP8266 you must include NetworkManagerNtpSyncHook.h in exactly one translation unit (normally your .ino) for it to take effect — see API.md.

DHCP provides at most one NTP server on both platforms (an lwIP build limit). Manually configured servers use all NtpProfile::SERVER_COUNT slots.

The serving interface's DNS is held against the others. lwIP keeps one resolver table for the whole stack, so any interface that starts or acquires a lease takes it over — on ESP32 WiFi.begin() clears it outright, before association. A primary that keeps retrying and never connects would otherwise strip DNS from the interface actually carrying traffic, which looks like working routing with nothing resolvable by name. The manager remembers the serving interface's servers and restores them, checked once a second; a restore also re-arms SNTP, so a name it could not resolve is looked up again. This repairs damage rather than preventing it: a name looked up inside that window still fails. The gateway needs no such care — it lives per netif and no other interface's lease moves it.

AVR NTP resolves synchronously. With a host-name NTP server, the first sync after each connect performs a blocking DNS lookup inside update(), briefly stalling loop() (bounded by the Ethernet DNS timeout; the result is cached until the adapter changes). A literal IP NTP server skips DNS entirely and never blocks — the recommended choice on AVR.

AVR NTP retries with backoff and follows the link. Once the server is resolved, a failed sync is retried after NETWORK_MANAGER_DEFAULT_NTP_RETRY_INTERVAL, doubling on each failure up to _RETRY_MAX and resetting on success; a resolution failure instead waits the full sync interval. The client also tracks the live link — on a chip with link detection (W5500) it pauses within one update() tick of a link loss and resumes with a fresh sync when the link returns (W5100 has no detection, so it keeps trying). On ESP, retry and backoff are handled by lwIP.

Runtime WiFi control

On the WiFi adapters (ESP32WiFiAdapter / ESP8266WiFiAdapter):

wifiAdapter.setTxPower(8.5f);        // dBm; validated, persists across reconnects
float p = wifiAdapter.getTxPower();  // configured value
int   r = wifiAdapter.getRssi();     // live RSSI, e.g. -63

setTxPower() writes through to the profile (validated, lock-protected), so it survives reconnects; save the profile to persist it across reboots. See API.md.

Configuration

Define macros before including any library header (or in platformio.ini build_flags). Common ones:

Macro Default Description
NETWORK_MANAGER_MAX_ADAPTERS 2 Maximum adapters (1–8).
NETWORK_MANAGER_RECONNECT_TIMEOUT 60000 ms before DISCONNECTED; 0 disables.
NETWORK_MANAGER_MUTEX_TIMEOUT 1000 Mutex timeout, ms (ESP32 only).
NETWORK_MANAGER_DEFAULT_NTP_SYNC_INTERVAL 3600000 Default SNTP interval, ms.
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 Probe retry interval, ms.
NETWORK_ADAPTER_START_RETRY_MAX 300000 Cap for the growing wait after failed start() calls, ms.

The full list, including adapter-specific and PHY-selection macros, is in API.md.

Where to define these

These macros set fixed buffer sizes (e.g. NetworkStatus, the profile's NTP buffers), so every translation unit that includes a library header must see the same values. In a single-file sketch, define them before the first #include. In a multi-file project they must be defined globally — otherwise the differing struct layouts across .cpp files are an ODR violation (undefined behaviour).

  • PlatformIO (all platforms) — in platformio.ini:

    build_flags =
        -D HOST_FQDN_LEN=32
        -D NTP_PROFILE_SERVER_COUNT=1
  • Arduino IDE, ESP8266 — add a file named <SketchName>.ino.globals.h next to your .ino; the ESP8266 core force-includes it into every source file:

    // MySketch.ino.globals.h
    #define HOST_FQDN_LEN                    32
    #define NTP_PROFILE_SERVER_COUNT 1
  • Arduino IDE, ESP32 — add a file named build_opt.h in the sketch folder. The core passes it to the compiler as a response file for every translation unit, so it holds compiler flags, not #defines (despite the .h name):

    -DHOST_FQDN_LEN=32
    -DNTP_PROFILE_SERVER_COUNT=1
  • Arduino IDE, AVR — there is no per-sketch global mechanism. Either keep the sketch to a single .ino (define before the first include), or pass the flags via arduino-cli --build-property "compiler.cpp.extra_flags=-DHOST_FQDN_LEN=32 …". For a multi-file AVR project, PlatformIO is the simplest route.

Important notes:

  1. The ESP8266 .ino.globals.h and the ESP32 build_opt.h are different mechanisms — ESP32 does not read .ino.globals.h, and the ESP8266 /*@create-file:build.opt@ … @end*/ block has no ESP32 equivalent (put the raw flags straight into build_opt.h instead).
  2. Defining a macro before the include in only your .ino affects that file only — other .cpp files use the defaults. That mismatch is the multi-TU pitfall the global methods above avoid.

Memory & fine-tuning

The library never allocates on the heap; every buffer is a fixed size fixed at compile time. The footprint is driven by a handful of macros inherited from NetworkProfile / Host — define them before including any library header (or via platformio.ini build_flags), identically in every translation unit.

Macro Default Controls Impact
HOST_FQDN_LEN 253 Max NTP-server FQDN length Largest lever — every FQDN buffer is HOST_FQDN_LEN + 1, and they appear per profile, in the SNTP setup, and in statusToJson().
NTP_PROFILE_ENABLED 0 (AVR) / 1 (ESP) Whether NTP exists in the build Off removes the SNTP support too — on AVR a UDP client, not just buffers.
NTP_PROFILE_SERVER_COUNT 3 (ESP32) / 1 elsewhere NTP server slots, 1 to 3 One FQDN buffer per slot, per profile, plus one per slot in the SNTP setup and JSON.
NETWORK_PROFILE_DNS_SERVER_COUNT 1 (AVR) / 2 (ESP) DNS slots per profile 4 bytes/slot (minor).
NETWORK_PROFILE_HOSTNAME_LEN 63 Max host name length One buffer of this size per profile.

NetworkManager.STATUS_JSON_SIZE (the statusToJson() buffer size) is derived: 200 + NTP_PROFILE_SERVER_COUNT * (HOST_FQDN_LEN + 1 + 48).

Where the FQDN buffers go

With the ESP defaults (HOST_FQDN_LEN = 253 → 254-byte buffers, 3 NTP slots, two profiles):

  • Persistent — NTP servers stored in each profile (254 × 3 × 2 ≈ 1.5 KB) plus the static scratch array used when SNTP is (re)configured (254 × 3 ≈ 0.75 KB): ~2.3 KB spent purely on NTP server names.
  • Transient (stack) — statusToJson() (one FQDN buffer), the NetworkConfig value read at adapter start, and the profile's toJson().
  • Your buffers — STATUS_JSON_SIZE (≈ 1.1 KB with three NTP slots) and NetworkProfile::JSON_SIZE are sized for the worst case and shrink with the macros above.

The DHCP-provided NTP server count is capped at one by the platform lwIP build, so the multi-slot storage only pays off for manually configured servers.

Tuning guidance

  • Shorten HOST_FQDN_LEN first. pool.ntp.org is 12 chars; a literal IP is 15. HOST_FQDN_LEN = 32 (or 64) cuts every FQDN buffer to roughly an eighth of the default with no functional loss.
  • Set NTP_PROFILE_SERVER_COUNT to what you use. 1 is plenty for most deployments and removes two thirds of the NTP storage versus the ESP32 default of 3. To leave NTP out altogether, set NTP_PROFILE_ENABLED to 0 — that is a different question from how many servers to store, and the AVR default.
  • AVR (ATmega2560, 8 KB SRAM): keep NTP off unless needed; if you enable it, pair it with a small HOST_FQDN_LEN — a 253-byte FQDN alone would size a ~1 KB statusToJson() buffer.
  • JSON costs nothing until you declare a buffer for statusToJson() / toJson().
// Lean configuration — short NTP names or literal IPs, a single server
#define HOST_FQDN_LEN                    32
#define NTP_PROFILE_SERVER_COUNT         1
#define NETWORK_PROFILE_DNS_SERVER_COUNT 1

#include <EthAdapter.h>
#include <WiFiAdapter.h>
#include <NetworkManager.h>
// No NTP at all — no FQDN storage, and no SNTP client either (the AVR default)
#define NTP_PROFILE_ENABLED 0

The NetworkProfile README's Memory Optimisation section has the full per-field breakdown and the exact savings table.

Design notes

Execution model

update() and all NetworkManager calls must come from a single task (the Arduino loop(), or a dedicated network task). SDK network events arrive on a separate context; the adapters queue the resulting user events, which update() delivers on the loop task. Your onEvent / onNtpSync callbacks therefore run on the loop task with a full stack — it is safe to call getStatus(), Serial.print, etc. from inside them.

Deferred start/stop and events

Adapter start() / stop() invoke platform APIs that can themselves generate events or block, so they are never called from an event context: the internal _handleStateChange() records deferred flags and queues the user event, and update() executes them on the next iteration. This keeps teardown and arbitrary app callback code off the small SDK event-task stack.

Decision core

All fallback / restore / reconnect policy lives in NetworkManagerCore, a platform-independent class with no Arduino, hardware, or threading primitives. It receives a snapshot of adapter states and returns an intent (which adapter to start/stop, which event to emit); NetworkManager executes those intents. The test/ directory runs the core against a host compiler.

Thread safety

  • Adapter state (_state) and cross-task signals (_lastFailedMs, deferred bits, pending-apply) are std::atomic on ESP32; plain on AVR.
  • The decision core is protected by the single NetworkManager mutex (ESP32); ESP8266 (cooperative) and AVR (single-threaded) need no lock.
  • Callbacks run outside any mutex, on the update() task.
  • Profiles are independently thread-safe and may be read/written from any task.

Platform notes

Most platform-specific behaviour is handled internally; the API reference covers it in full under Platform notes. A few points are worth knowing as a user:

  • ESP32 with a W5500 and an ETH_PHY_IRQ pin has, rarely and not reproducibly (so far only in the FallbackTestRunner), hit an SDK stack-canary panic (Stack canary watchpoint triggered (ipc1)) while the core installs the Ethernet interrupt. Polling mode avoids it: ETH_PHY_IRQ defined as -1 (not left undefined: the argument-less ETH.begin() then fails). Lowering the Core Debug Level also helps in practice.
  • An adapter whose start() fails (an Ethernet chip that does not answer) is retried after 15 s, 30 s, 60 s ... up to 5 min, while the next adapter takes over at once.
  • ESP32, DHCP Ethernet: a harmless esp_netif_handlers: invalid static ip line may be logged once when the Ethernet link returns. It is cosmetic — the interface re-acquires its lease normally.
  • AVR, DHCP without a link at boot: Ethernet.begin() blocks for its internal DHCP timeout, delaying the first DISCONNECTED. The adapter caps this with AVR_ETH_ADAPTER_DHCP_BEGIN_TIMEOUT (default 8000 ms). A static address is unaffected.
  • ESP8266 NTP after a handoff: resynchronisation follows the SNTP poll cycle rather than happening immediately, so a fresh sync can lag a reconnect by one interval. ESP32 and AVR resync immediately.

Fallback and restore keep the default route and DNS resolver pointed at the serving interface on both ESP platforms, including with static addressing — see the API reference for the mechanism.

Testing

The fallback/restore behaviour is validated on real hardware with a semi-automatic 32-case test runner (examples/FallbackTestRunner), and the decision core has a host-side regression suite (test/nm_harness.cpp). See the test/README.md for details. Results across ESP32 (W5500 and LAN8720A), ESP8266 and the test method are documented in Testing.md.

License

MIT — see LICENSE for details.

About

An Arduino library for priority-based network connection management on ESP32, ESP8266 and AVR. It handles connection, fallback, reconnection, live status, and NTP synchronisation across multiple interfaces with a small, event-driven, heap-free API.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages