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.
- Compile-time macros
- NetworkManagerClass
- Event enum
- NetworkStatus struct
- NetworkAdapter (base class)
- Adapters
- NetworkManagerNtpSyncHook.h
- Platform notes
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.
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().
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; // fineIf 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().
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().
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.
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).
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.
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()).
Stops all adapters and resets internal state.
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.
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.
true if any adapter currently holds an IP. A point-in-time reading — it may
stop being true the moment it returns.
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.
Active adapter's IP address.
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.
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.
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).
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.
Current Unix time in seconds, or 0 if not yet synced.
true once at least one successful SNTP sync has occurred.
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().
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'sclearNtpProfile() on either object undoes the corresponding binding.
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.
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.
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.
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.
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.
The configured poll interval, ms.
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 taskNetworkManagerClass::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.
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];
};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).
Current lifecycle state.
Same snapshot semantics as the manager accessors, but for this adapter specifically (whether or not it is the active one).
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.
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.
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.
Thread-safe request to re-apply this adapter's profile; honoured by the
manager's update(). See applyProfile.
start(),stop(),update()andcanProbe()are the manager's orchestration interface — do not call them from application code.
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.
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) |
Available on ESP32WiFiAdapter and ESP8266WiFiAdapter (identical signatures):
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.
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().)
Current RSSI in dBm from WiFi.RSSI(). Only meaningful while connected.
wifiAdapter.setTxPower(8.5f);
int rssi = wifiAdapter.getRssi(); // e.g. -63ESP8266 (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.
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);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 onlyIncluding 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.
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::atomicon 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.
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.
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.
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.
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.
ESP8266 resynchronises NTP on its own SNTP poll cycle after a re-arm, not immediately; ESP32 and AVR trigger an immediate resync on reconnect.
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.
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.
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 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.
MIT — see LICENSE.