Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// Patternflow - shared Wi-Fi bring-up (non-blocking + auto-reconnect)
//
// Everything network-side rides on one STA connection.
// This module owns it so there is exactly one WiFi.begin() in flight and
// one place that handles dropouts.
//
// begin() — kicks off the join and returns immediately. Boot never
// blocks; patterns render right away.
// tick() — called every loop. Detects the 0->1 connect edge (so the
// caller can start network services once connected) and,
// while disconnected, retries on a fixed interval. esp_wifi's
// own auto-reconnect is also enabled, so most transient drops
// recover without us; the explicit retry covers the cases it
// gives up on (initial join failed, AP rebooted, ...).
//
// Replaces the old blocking connect that froze setup() for up to 8 s and
// never recovered once a join failed.
//
// License: MIT
#pragma once
#include <Arduino.h>
#include "config.h"
// One core switch, not a disjunction of feature flags. This used to read
// `#if PF_OSC_ENABLED || PF_OTA_ENABLED || PF_AUDIO_ENABLED || ...`, which
// was the core deciding whether the radio exists by enumerating features —
// and a stale enumeration at that: the console, MQTT and weather all need
// Wi-Fi and were never in the list. Whether this panel has a radio is the
// core's own question; PF_WIFI_ENABLED answers it in net_config.h.
#if PF_WIFI_ENABLED
#define PF_WIFI_NEEDED 1
#include <WiFi.h>
#include <Preferences.h>
#endif
namespace PatternflowWifi {
// ── The last network someone asked for, and what became of it ──────────
//
// A person who has just typed a password on a phone - on the panel's own
// hotspot, most likely - needs to hear back: joined, and where, or why not.
// A join is opened by applyCredentials() (the console and Improv both land
// there) and settled by tick(). The driver's reason for a failed attempt
// comes in through the hotspot's event hook (core_hotspot.h), which already
// listens, so this file stays free of event plumbing. A join at boot opens
// nothing: only a network someone asked for is reported.
enum JoinState : uint8_t { JOIN_NONE = 0, JOIN_TRYING, JOIN_OK, JOIN_FAILED };
enum JoinWhy : uint8_t { WHY_NONE = 0, WHY_NOT_FOUND, WHY_PASSWORD, WHY_REFUSED, WHY_NO_ANSWER };
inline JoinState joinState = JOIN_NONE;
inline JoinWhy joinWhy = WHY_NONE;
inline String joinSsid;
inline String joinIp;
inline uint32_t joinAtMs = 0; // when it was opened, then when it settled
inline uint32_t joinSeq = 0; // bumped on every outcome, so a watcher reports each once
inline uint8_t joinReason = 0; // the driver's reason behind a failure; 0 if none was heard
// The reason of every station drop, from the event hook. Our own
// disconnect() before each attempt (ASSOC_LEAVE) is filtered out there.
inline volatile uint8_t lastStaReason = 0;
constexpr uint32_t JOIN_GIVE_UP_MS = 30000;
// The driver's reasons (wifi_err_reason_t) folded into what a person can
// act on. A wrong WPA2 password shows up as a handshake timeout far more
// often than as AUTH_FAIL. The status flags are the fallback when no reason
// was heard at all.
inline JoinWhy whyFromReason(uint8_t reason, bool noSsid, bool authFailed) {
switch (reason) {
case 201: return WHY_NOT_FOUND; // NO_AP_FOUND
case 15: // 4WAY_HANDSHAKE_TIMEOUT
case 23: // 802_1X_AUTH_FAILED
case 202: // AUTH_FAIL
case 204: return WHY_PASSWORD; // HANDSHAKE_TIMEOUT
case 5: // ASSOC_TOOMANY
case 203: return WHY_REFUSED; // ASSOC_FAIL
default: break;
}
if (noSsid) return WHY_NOT_FOUND;
if (authFailed) return WHY_PASSWORD;
return WHY_NO_ANSWER;
}
inline const char* joinStateName() {
switch (joinState) {
case JOIN_TRYING: return "trying";
case JOIN_OK: return "joined";
case JOIN_FAILED: return "failed";
default: return "none";
}
}
inline const char* joinWhyText() {
switch (joinWhy) {
case WHY_NOT_FOUND: return "network not found";
case WHY_PASSWORD: return "wrong password";
case WHY_REFUSED: return "the network refused the panel";
case WHY_NO_ANSWER: return "no answer from the network";
default: return "";
}
}
#ifdef PF_WIFI_NEEDED
// How often to re-issue WiFi.begin() while disconnected (from net_config.h).
// Long enough to let a join attempt finish before we kick it again.
constexpr uint32_t RETRY_INTERVAL_MS = PF_WIFI_RETRY_INTERVAL_MS;
inline bool started = false;
inline bool connectedNow = false;
inline bool justConnectedEdge = false;
// The hotspot (core_hotspot.h) keeps these current from the network task:
// whether the panel's own AP is up, and how many clients are on it.
inline bool hotspotUp = false;
inline int hotspotClients = 0;
// While the hotspot is up and the station has no link, the radio runs
// AP-only: a station interface that is merely present - scanning,
// retrying, idle - drags the AP's throughput to a crawl on this driver
// (2026-09-22: 13 KB pages cut at 5 KB after 20 s). The station comes
// back for one attempt at a time: a rare retry with nobody on the
// hotspot, or credentials from the console; if it fails, AP-only again.
#ifndef PF_HOTSPOT_STA_RETRY_MS
#define PF_HOTSPOT_STA_RETRY_MS 300000
#endif
inline bool staProbing = false;
inline uint32_t staProbeEndsMs = 0;
constexpr uint32_t STA_PROBE_MS = 8000;
inline void stationOn() {
if ((WiFi.getMode() & WIFI_MODE_STA) == 0) WiFi.mode(WIFI_AP_STA);
}
inline void stationOff() {
if (hotspotUp && (WiFi.getMode() & WIFI_MODE_STA)) {
WiFi.disconnect();
WiFi.mode(WIFI_AP);
}
staProbing = false;
}
inline uint32_t lastBeginMs = 0;
inline bool retryCurrentNetwork = false;
inline uint32_t connectedAddress = 0;
inline uint32_t disconnects = 0;
inline uint32_t retryAttempts = 0;
inline uint32_t lastReconnectMs = 0;
inline uint32_t disconnectedAtMs = 0;
inline bool reconnectRequested = false;
inline uint32_t reconnectRequestedAtMs = 0;
// Last definitive failure (NO_SSID / AUTH_FAIL), latched for statusText().
// The retry loop bounces the raw WiFi.status() between a failure code and
// "idle/disconnected" every cycle, which reads as flickering text on the
// panel — latching keeps the label steady until the state truly changes.
inline wl_status_t latchedFailure = WL_IDLE_STATUS;
inline void failJoin(uint32_t now) {
const wl_status_t st = WiFi.status();
joinReason = lastStaReason;
joinWhy = whyFromReason(joinReason, st == WL_NO_SSID_AVAIL, st == WL_CONNECT_FAILED);
joinState = JOIN_FAILED;
joinAtMs = now;
joinSeq++;
Serial.printf("[WiFi] could not join \"%s\": %s (reason %u)\n", joinSsid.c_str(),
joinWhyText(), (unsigned)joinReason);
}
// Active credentials in use. Loaded from NVS (where Improv-Serial provisioning
// writes them, see core_improv.h) when present, otherwise the compile-time
// placeholders from net_config.h. Held in String so they outlive begin().
inline String activeSsid;
inline String activePass;
inline bool credsFromNvs = false;
// NVS namespace dedicated to provisioned Wi-Fi, kept separate from the app's
// "patternflow" prefs so the two handles never collide. Opened only for the
// duration of each read/write below.
constexpr const char* WIFI_NVS_NS = "pf_wifi";
// Several networks can be remembered — home, studio, a friend's place, the
// venue — so moving the device does not mean re-provisioning it over USB.
//
// Stored as ssid0..ssidN / pass0..passN plus "count". Slot order is priority
// order: whatever connected (or was provisioned) most recently sits at 0.
constexpr int MAX_NETWORKS = 5;
inline String savedSsids[MAX_NETWORKS];
inline String savedPasses[MAX_NETWORKS];
inline int savedCountValue = 0;
// Which slot the next join attempt uses. tick() walks this forward so a boot
// in any remembered location eventually lands, without scanning.
inline int attemptIdx = 0;
// Which saved slot to try FIRST on power-up (0..count-1), set from /wifi and
// persisted. Slot order is recency, which is the right default and the wrong
// answer when a panel lives at a venue and was last provisioned at home: it
// then spends every boot failing on a network that is not there before it
// wraps around. This pins the starting point; the walk still covers the rest.
// Takes effect on the next boot — switching the live join would drop the
// session that asked for it.
inline int bootIdx = 0;
inline int savedCount() { return savedCountValue; }
inline const String& savedSsid(int i) { return savedSsids[i]; }
inline void clampBootIdx() {
if (savedCountValue <= 0) {
bootIdx = 0;
return;
}
if (bootIdx < 0 || bootIdx >= savedCountValue) bootIdx = 0;
}
// The list reorders under the preference (addNetwork moves an entry to the
// front), so the slot the user picked has to be followed through the shuffle
// rather than left pointing at whatever lands in its index.
inline void adjustBootIdxOnInsertAtFront(int from) {
if (from <= 0) return;
if (bootIdx == from) bootIdx = 0;
else if (bootIdx < from) bootIdx++;
clampBootIdx();
}
inline void writeNetworks() {
Preferences p;
if (!p.begin(WIFI_NVS_NS, /*readOnly=*/false)) return;
p.putInt("count", savedCountValue);
// 16, not 8: NVS caps a key at 15 characters, and this is the width at
// which the compiler can prove "ssid%d" cannot truncate for any int.
// At 8 it cannot, and CI builds the host tests with -Werror.
char key[16];
for (int i = 0; i < savedCountValue; i++) {
snprintf(key, sizeof(key), "ssid%d", i);
p.putString(key, savedSsids[i]);
snprintf(key, sizeof(key), "pass%d", i);
p.putString(key, savedPasses[i]);
}
p.putInt("bootIdx", bootIdx);
// Keep the legacy single-slot keys pointing at the top network, so a
// downgrade to an older firmware still finds a usable network.
if (savedCountValue > 0) {
p.putString("ssid", savedSsids[0]);
p.putString("pass", savedPasses[0]);
}
p.end();
}
// Pull stored networks out of NVS, falling back to the compile-time
// placeholder when nothing has been provisioned yet.
inline void loadCredentials() {
savedCountValue = 0;
Preferences p;
if (p.begin(WIFI_NVS_NS, /*readOnly=*/true)) {
int count = p.getInt("count", -1);
if (count >= 0) {
char key[16]; // see writeNetworks(): 15 is the NVS key limit
for (int i = 0; i < count && i < MAX_NETWORKS; i++) {
snprintf(key, sizeof(key), "ssid%d", i);
String ssid = p.getString(key, "");
if (ssid.length() == 0) continue;
// Heal duplicates the pre-fix aliasing bug wrote into NVS: the same
// SSID twice reads as one network shown twice on /wifi, and makes
// the retry walk burn slots re-trying a network that just failed.
bool duplicate = false;
for (int j = 0; j < savedCountValue; j++) {
if (savedSsids[j] == ssid) { duplicate = true; break; }
}
if (duplicate) continue;
snprintf(key, sizeof(key), "pass%d", i);
savedSsids[savedCountValue] = ssid;
savedPasses[savedCountValue] = p.getString(key, "");
savedCountValue++;
}
bootIdx = p.getInt("bootIdx", 0);
} else {
// Migration: firmware before multi-network stored one ssid/pass pair.
String ssid = p.getString("ssid", "");
if (ssid.length() > 0) {
savedSsids[0] = ssid;
savedPasses[0] = p.getString("pass", "");
savedCountValue = 1;
}
}
p.end();
}
if (savedCountValue > 0) {
if (p.begin(WIFI_NVS_NS, /*readOnly=*/true)) {
int stored = p.getInt("count", -1);
p.end();
// Rewrite once when the layout changed on the way in: legacy
// single-slot migration, or duplicate slots healed above.
if (stored != savedCountValue) writeNetworks();
}
clampBootIdx();
attemptIdx = bootIdx;
activeSsid = savedSsids[bootIdx];
activePass = savedPasses[bootIdx];
credsFromNvs = true;
} else {
activeSsid = PF_WIFI_SSID;
activePass = PF_WIFI_PASS;
credsFromNvs = false;
}
}
// Add a network, or move an already-known one to the front. Returns false only
// when the SSID is empty or the list is full of other networks.
inline bool addNetwork(const String& ssidRef, const String& passRef) {
// Copy FIRST. The reconnect path calls this with savedSsids[i] /
// savedPasses[i] themselves, and the shift loop below overwrites what
// those references point at mid-move. That exact aliasing turned
// [PatternflowLocal, wifiiii] into [PatternflowLocal, PatternflowLocal]
// the moment the second network connected — deleting the network that
// had just WORKED and duplicating the one that hadn't.
const String ssid = ssidRef;
const String pass = passRef;
if (ssid.length() == 0) return false;
int existing = -1;
for (int i = 0; i < savedCountValue; i++) {
if (savedSsids[i] == ssid) { existing = i; break; }
}
if (existing < 0 && savedCountValue >= MAX_NETWORKS) {
// Full: drop the least recently used (last) entry to make room.
existing = MAX_NETWORKS - 1;
savedCountValue = MAX_NETWORKS - 1;
}
if (existing < 0 && savedCountValue > 0) {
// Brand-new network goes in at the front, so everything shifts down one.
bootIdx = min(bootIdx + 1, MAX_NETWORKS - 1);
}
int from = existing >= 0 ? existing : savedCountValue;
if (from >= savedCountValue) savedCountValue = min(savedCountValue + 1, MAX_NETWORKS);
for (int i = min(from, MAX_NETWORKS - 1); i > 0; i--) {
savedSsids[i] = savedSsids[i - 1];
savedPasses[i] = savedPasses[i - 1];
}
savedSsids[0] = ssid;
savedPasses[0] = pass;
if (existing >= 0) adjustBootIdxOnInsertAtFront(from);
clampBootIdx();
writeNetworks();
return true;
}
// Forget one network by SSID. The device may be connected to it right now; the
// join is left alone so nobody loses their session mid-request, and the entry
// simply is not tried again after the next drop.
inline bool removeNetwork(const String& ssid) {
for (int i = 0; i < savedCountValue; i++) {
if (savedSsids[i] != ssid) continue;
for (int j = i; j < savedCountValue - 1; j++) {
savedSsids[j] = savedSsids[j + 1];
savedPasses[j] = savedPasses[j + 1];
}
savedCountValue--;
savedSsids[savedCountValue] = "";
savedPasses[savedCountValue] = "";
// Follow the preference through the gap the removal left.
if (i < bootIdx) bootIdx--;
else if (i == bootIdx) bootIdx = min(bootIdx, max(savedCountValue - 1, 0));
clampBootIdx();
if (attemptIdx >= savedCountValue) attemptIdx = bootIdx;
writeNetworks();
return true;
}
return false;
}
// Pick which saved slot the NEXT boot tries first. Deliberately does not
// reconnect: the request arrives over the very connection it would drop.
inline bool setBootIndex(int idx) {
if (savedCountValue <= 0) return false;
if (idx < 0 || idx >= savedCountValue) return false;
bootIdx = idx;
writeNetworks();
return true;
}
inline int getBootIndex() {
clampBootIdx();
return bootIdx;
}
// Kick off the connection and return immediately.
inline void begin() {
if (started) return;
started = true;
loadCredentials();
WiFi.persistent(false); // don't thrash NVS with creds every boot
WiFi.mode(WIFI_STA);
WiFi.setHostname(PF_OTA_HOSTNAME);
WiFi.setSleep(false); // lower latency for OSC/OTA/WS
WiFi.setAutoReconnect(true); // let the IDF re-join on transient drops
WiFi.begin(activeSsid.c_str(), activePass.c_str());
lastBeginMs = millis();
// Cap transmit power (PF_WIFI_TX_POWER in net_config.h). Must come after
// begin() — setTxPower() refuses while neither STA nor AP is started. The
// cap lives in the driver, so the retry path's disconnect()/begin() cycle
// keeps it; only a full driver stop would clear it. Logged as a readback
// rather than the requested value because the IDF clamps to what the radio
// can actually do, and an RF test wants the achieved number.
if (WiFi.setTxPower(PF_WIFI_TX_POWER)) {
Serial.printf("[WiFi] max TX power set — radio reports %.1f dBm\n",
WiFi.getTxPower() / 4.0f);
} else {
Serial.println("[WiFi] WARNING: TX power cap rejected; radio is at default");
}
Serial.printf("[WiFi] connecting to \"%s\" (%s creds, non-blocking)...\n",
activeSsid.c_str(), credsFromNvs ? "provisioned" : "built-in");
}
// Store new credentials (handed over by Improv-Serial) and restart the join
// immediately so the next tick() reflects the new network. Persisted to NVS
// so they survive reboot and win over the built-in placeholders.
inline void applyCredentials(const String& ssid, const String& pass) {
// Adds rather than replaces: provisioning at a second location keeps the
// first one working, and this network becomes the one tried first.
addNetwork(ssid, pass);
attemptIdx = 0;
activeSsid = ssid;
activePass = pass;
credsFromNvs = true;
started = true;
// Force a clean reconnect with the new creds (mirrors the retry path).
connectedNow = false;
__atomic_store_n(&justConnectedEdge, false, __ATOMIC_RELEASE);
connectedAddress = 0;
retryCurrentNetwork = false;
latchedFailure = WL_IDLE_STATUS; // stale failure was for the old creds
joinState = JOIN_TRYING;
joinWhy = WHY_NONE;
joinSsid = ssid;
joinIp = "";
joinReason = 0;
lastStaReason = 0;
joinAtMs = millis();
if (hotspotUp) {
stationOn();
staProbing = true;
staProbeEndsMs = millis() + 3 * STA_PROBE_MS; // a real network deserves a longer look
}
WiFi.disconnect();
WiFi.begin(activeSsid.c_str(), activePass.c_str());
lastBeginMs = millis();
Serial.printf("[WiFi] applying provisioned creds for \"%s\"\n", ssid.c_str());
}
inline bool hasStoredCredentials() { return credsFromNvs; }
// Anything worth an attempt: saved networks, or built-in credentials that
// are not the placeholder a secret-less checkout compiles with.
inline bool hasCredentials() {
return credsFromNvs || strcmp(PF_WIFI_SSID, "YOUR_WIFI_SSID") != 0;
}
inline const String& currentSsid() { return activeSsid; }
// The HTTP reply gets time to leave before the network owner drops its link.
// Reuses credentials in RAM; no NVS write or provisioning round trip.
inline void requestReconnect() {
reconnectRequestedAtMs = millis();
reconnectRequested = true;
}
// Call once per loop. Maintains the connection and exposes a one-shot
// "just connected" edge via consumeJustConnected().
inline void tick() {
const uint32_t now = millis();
if (reconnectRequested && (uint32_t)(now - reconnectRequestedAtMs) >= 500) {
reconnectRequested = false;
if (hotspotUp) {
// AP-only while alone: the station has to exist before it can reconnect.
stationOn();
staProbing = true;
staProbeEndsMs = now + 3 * STA_PROBE_MS;
}
WiFi.reconnect();
}
bool connected = (WiFi.status() == WL_CONNECTED);
if (connected) {
staProbing = false;
const uint32_t address = (uint32_t)WiFi.localIP();
if (!connectedNow) {
connectedNow = true;
if (joinState == JOIN_TRYING) {
if (activeSsid == joinSsid) {
joinState = JOIN_OK;
joinIp = WiFi.localIP().toString();
joinAtMs = now;
joinSeq++;
} else {
failJoin(now); // another remembered network answered first
}
}
if (disconnects) lastReconnectMs = now - disconnectedAtMs;
retryCurrentNetwork = false;
__atomic_store_n(&justConnectedEdge, true, __ATOMIC_RELEASE);
Serial.printf("[WiFi] connected — IP %s\n",
WiFi.localIP().toString().c_str());
// Whatever just worked becomes the first thing tried next boot, so a
// device that lives in one place stops cycling after the first join.
if (attemptIdx > 0 && attemptIdx < savedCountValue) {
addNetwork(savedSsids[attemptIdx], savedPasses[attemptIdx]);
attemptIdx = 0;
}
} else if (address != connectedAddress) {
// DHCP can renew to a different address without a sampled down edge.
__atomic_store_n(&justConnectedEdge, true, __ATOMIC_RELEASE);
}
connectedAddress = address;
return;
}
// Disconnected (or never joined).
if (connectedNow) {
connectedNow = false;
connectedAddress = 0;
disconnectedAtMs = now;
disconnects++;
retryCurrentNetwork = true;
Serial.println("[WiFi] connection lost; retrying...");
// Let auto-reconnect recover a brief drop. The old zero timestamp
// immediately disconnected it again and jumped to a DIFFERENT saved
// network, making one lost beacon turn into a tour of absent SSIDs.
lastBeginMs = now;
}
if (joinState == JOIN_TRYING && (uint32_t)(now - joinAtMs) >= JOIN_GIVE_UP_MS) failJoin(now);
// A retry is a scan of every channel, one or two seconds with the hotspot
// off the air. Someone on the hotspot: no retries at all (credentials
// arriving through the console still connect at once, applyCredentials()
// does not pass here). Nobody on it: rarely. No hotspot: as before.
uint32_t interval = RETRY_INTERVAL_MS;
if (hotspotUp) {
if (staProbing) {
// One attempt is in flight; give it its window, then stand down.
if ((int32_t)(now - staProbeEndsMs) >= 0) {
if (joinState == JOIN_TRYING) failJoin(now); // before stationOff() resets the status
stationOff();
Serial.println("[WiFi] station probe found nothing - hotspot alone");
}
return;
}
if (hotspotClients > 0) return;
interval = PF_HOTSPOT_STA_RETRY_MS;
}
if (now - lastBeginMs >= interval) {
lastBeginMs = now;
retryAttempts++;
if (hotspotUp) {
stationOn();
staProbing = true;
staProbeEndsMs = now + STA_PROBE_MS;
}
// With more than one network remembered, each retry tries the next one.
// A present network normally authenticates well inside one 5 s window, and
// an absent one reports NO_SSID_AVAIL quickly, so cycling finds whichever
// is in range. Deliberately no WiFi.scanNetworks() here: a scan allocates
// internal RAM, and on this board that is the resource the web console is
// already short of (see /status).
if (retryCurrentNetwork) {
retryCurrentNetwork = false; // one explicit retry of the working SSID
} else if (savedCountValue > 1) {
attemptIdx = (attemptIdx + 1) % savedCountValue;
activeSsid = savedSsids[attemptIdx];
activePass = savedPasses[attemptIdx];
}
WiFi.disconnect(); // clear any half-finished attempt (avoids the
// "sta is connecting, cannot set config" error)
WiFi.begin(activeSsid.c_str(), activePass.c_str());
if (savedCountValue > 1) {
Serial.printf("[WiFi] retry connect (%d/%d: \"%s\")...\n", attemptIdx + 1,
savedCountValue, activeSsid.c_str());
} else {
Serial.println("[WiFi] retry connect...");
}
}
}
inline bool isConnected() {
return WiFi.status() == WL_CONNECTED;
}
// The link the console can be reached over: the station, or the panel's
// own hotspot. Every service that starts "once the network is up" asks
// this, not WL_CONNECTED - a phone on the hotspot with a lease and no
// port 80 was the 2026-09-22 bench in one line.
inline bool linkUp() {
return hotspotUp || WiFi.status() == WL_CONNECTED;
}
// The hotspot coming up is a link too: the same edge loop() starts the
// services on. Every begin() behind it is idempotent, so the station's
// own edge later costs nothing.
inline void raiseLinkEdge() {
__atomic_store_n(&justConnectedEdge, true, __ATOMIC_RELEASE);
}
// True exactly once after each successful (re)connection. The caller uses
// this to (re)start network services.
inline bool consumeJustConnected() {
// Set by tick() on the network core, consumed by loop() on the other:
// one atomic exchange, so an edge cannot land between a read and a clear.
return __atomic_exchange_n(&justConnectedEdge, false, __ATOMIC_ACQ_REL);
}
// Short status word for the on-device info screen. Failure labels stay
// latched across retry cycles (see latchedFailure above).
inline const char* statusText() {
wl_status_t s = WiFi.status();
if (s == WL_CONNECTED) {
latchedFailure = WL_IDLE_STATUS;
return "CONNECTED";
}
if (s == WL_NO_SSID_AVAIL || s == WL_CONNECT_FAILED) latchedFailure = s;
switch (latchedFailure) {
case WL_NO_SSID_AVAIL: return "NO SSID";
case WL_CONNECT_FAILED: return "AUTH FAIL";
default: return "CONNECTING";
}
}
// ASCII only: the GFX default font renders each byte of a multi-byte
// UTF-8 char (like an em dash) as its own garbage glyph.
inline String ipString() {
if (WiFi.status() == WL_CONNECTED) return WiFi.localIP().toString();
return String("-");
}
#else // !PF_WIFI_NEEDED — all network features compiled out
inline void begin() {}
inline void tick() {}
inline void applyCredentials(const String&, const String&) {}
inline void requestReconnect() {}
inline bool hasStoredCredentials() { return false; }
inline bool hasCredentials() { return false; }
inline const String& currentSsid() { static String s; return s; }
inline int savedCount() { return 0; }
inline const String& savedSsid(int) { static String s; return s; }
inline bool addNetwork(const String&, const String&) { return false; }
inline bool removeNetwork(const String&) { return false; }
inline bool setBootIndex(int) { return false; }
inline int getBootIndex() { return 0; }
inline bool isConnected() { return false; }
inline bool linkUp() { return false; }
inline void raiseLinkEdge() {}
inline bool hotspotUp = false;
inline int hotspotClients = 0;
inline void stationOn() {}
inline void stationOff() {}
inline bool consumeJustConnected() { return false; }
inline const char* statusText() { return "OFF"; }
inline String ipString() { return String("-"); }
#endif
} // namespace PatternflowWifi