diff --git a/CLAUDE.md b/CLAUDE.md index 179dcfd7..8a18d233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,8 +147,10 @@ PlatformIO / Arduino project for ESP32-C3. Main files: - `src/network.cpp` — WiFi connection, HTTP fetch from backend, NTP time sync, RSSI reporting - `src/display.cpp` — GxEPD2 e-ink display driver - `src/epd_driver.cpp` — raw e-ink display control (low-level SPI commands) -- `src/portal.cpp` — Captive Portal for WiFi provisioning -- `src/storage.cpp` — NVS (flash) storage for persisting device state across deep sleep +- `src/portal.cpp` — Captive Portal for WiFi provisioning + saved-network management (`/wifi_list`, `/add_wifi`, `/delete_wifi`) +- `src/storage.cpp` — NVS (flash) storage for persisting device state across deep sleep; holds the saved WiFi list (up to `MAX_WIFI_NETWORKS`, default 5) + +**Multi-WiFi & fallback** — the device stores up to 5 WiFi networks and tries them in order on boot (`connectWiFi` in `network.cpp`), failing over to the next on each failure. If all saved networks fail, `handleWiFiFailure` (`main.cpp`) does one quick retry sweep and then enters the captive portal (`enterPortalMode`). The portal lets users view/add/delete saved networks. Legacy single-SSID configs are auto-migrated into the list by `loadConfig` (no `CONFIG_VERSION` bump, so existing credentials survive an upgrade). ## Testing diff --git a/docs/button-controls.md b/docs/button-controls.md index 825e7c8b..4534bfc3 100644 --- a/docs/button-controls.md +++ b/docs/button-controls.md @@ -36,9 +36,18 @@ InkSight 固件利用这两个按键实现了设备重启、模式切换和重 - 设备无法正常联网,需要救援。 - **提示**:如果设备是全新刷机、尚未配置过 Wi-Fi,开机时会自动进入配网模式,无需按住按键。 -## 3. 对应源码位置 +## 3. 多 Wi-Fi 与自动回退配网 + +- **最多保存 5 个 Wi-Fi**:在配网页(默认地址 `192.168.4.1`)可查看、删除、新增已保存的网络。除了「连接并保存」当前所在的网络外,还可用「仅保存到列表」预先录入暂时连不上的网络(如办公室 / 手机热点)。 +- **开机按顺序尝试**:设备会按保存顺序逐个尝试连接,第一个失败就自动尝试下一个,连上任意一个即继续正常流程。 +- **全部失败自动开热点**:如果所有已保存网络都连不上,设备会快速重试一轮,仍失败则**自动进入配置门户**并发射 `InkSight-xxxxx` 热点(约 1 分钟内),无需按键即可重新配网或增删网络。 + +## 4. 对应源码位置 - 启动时检测是否强制进入配置门户:`firmware/src/main.cpp` +- 多 Wi-Fi 顺序连接与失败回退:`firmware/src/network.cpp`(`connectWiFi`)、`firmware/src/main.cpp`(`handleWiFiFailure`) +- 保存的 Wi-Fi 列表存储(最多 5 个):`firmware/src/storage.cpp` +- 配网页与 `/wifi_list`、`/add_wifi`、`/delete_wifi` 路由:`firmware/src/portal.cpp`、`firmware/data/portal_html.h` - 按键短按 / 长按逻辑:`firmware/src/main.cpp` - 长按阈值与短按最小时长:`firmware/src/config.h` - 推荐开发板的按键引脚定义:`firmware/src/config.h` diff --git a/docs/en/button-controls.md b/docs/en/button-controls.md index da51c979..5446bd2a 100644 --- a/docs/en/button-controls.md +++ b/docs/en/button-controls.md @@ -36,9 +36,18 @@ In the InkSight firmware, the BOOT button is defined as the primary interaction - Rescuing a device that cannot connect to its saved network. - **Note**: If the device is freshly flashed and has no saved Wi-Fi credentials, it will enter the captive portal automatically on boot without needing to hold the button. -## 3. Source of truth +## 3. Multi-Wi-Fi & Automatic Fallback + +- **Up to 5 saved networks**: In the captive portal (default `192.168.4.1`) you can view, delete, and add saved networks. Besides "Connect & Save" for the network you're currently near, use "Save to List" to pre-register networks you can't reach right now (e.g. office / phone hotspot). +- **Tried in order on boot**: The device tries saved networks one by one in order; if the first fails it automatically moves on to the next, and proceeds as soon as any one connects. +- **Auto captive portal when all fail**: If none of the saved networks can be reached, the device does one quick retry sweep and then **automatically enters the captive portal**, broadcasting an `InkSight-xxxxx` hotspot (within about a minute) so you can reconfigure or edit networks without pressing any button. + +## 4. Source of truth - Force-portal check during boot: `firmware/src/main.cpp` +- Multi-Wi-Fi ordered connect & fallback: `firmware/src/network.cpp` (`connectWiFi`), `firmware/src/main.cpp` (`handleWiFiFailure`) +- Saved Wi-Fi list storage (up to 5): `firmware/src/storage.cpp` +- Captive portal and `/wifi_list`, `/add_wifi`, `/delete_wifi` routes: `firmware/src/portal.cpp`, `firmware/data/portal_html.h` - Short-press / long-press handler: `firmware/src/main.cpp` - Long-press and debounce thresholds: `firmware/src/config.h` - Recommended board button pin definitions: `firmware/src/config.h` diff --git a/firmware/data/portal_html.h b/firmware/data/portal_html.h index a9ef6115..0f697b6a 100644 --- a/firmware/data/portal_html.h +++ b/firmware/data/portal_html.h @@ -46,6 +46,9 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col .wi:hover{border-color:var(--bk);background:var(--bg)} .wi.sel{border-color:var(--bk);background:var(--bg)} .wn{font-size:.85rem;font-weight:500;display:flex;align-items:center;gap:6px} +.wx{background:none;border:none;cursor:pointer;color:var(--gy);padding:4px 6px;border-radius:6px;font-size:1.1rem;line-height:1;flex-shrink:0} +.wx:hover{color:#dc2626;background:#fef2f2} +.si-ord{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:var(--bg);border:1px solid var(--bd);font-size:.66rem;color:var(--gy);flex-shrink:0} .ws{display:flex;align-items:flex-end;gap:1.5px;height:14px} .ws .b{width:3px;background:#e0e0dc;border-radius:1px} .ws .b.a{background:var(--bk)} @@ -88,6 +91,13 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col
+
选择网络
手动输入
@@ -135,6 +145,8 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col
+ +
连不上的网络(如办公室/手机热点)可仅保存,开机会按顺序尝试
@@ -192,6 +204,12 @@ srvCusTip2:"前端配置页端口,用于重启后跳转到 localhost:端口/co srvUrlPh:"例如: http://192.168.1.100:8080", srvPortPh:"例如: 3000", btnConn:"连接并保存", +btnAdd:"仅保存到列表", +savedTitle:"已保存网络", +addHint:"连不上的网络(如办公室/手机热点)可仅保存,开机会按顺序尝试", +msgAddOk:"已保存到列表", +msgFull:"最多保存 5 个网络", +msgDelOk:"已删除", s2Title:"配网完成", s2Next:"下一步:", s2Auto:"设备将自动重启并联网,请使用配对码继续完成认领与配置。", @@ -244,6 +262,12 @@ srvCusTip2:"Frontend port, used for redirecting to localhost:port/config", srvUrlPh:"e.g. http://192.168.1.100:8080", srvPortPh:"e.g. 3000", btnConn:"Connect & Save", +btnAdd:"Save to List", +savedTitle:"Saved Networks", +addHint:"Networks not reachable here (office / phone hotspot) can be saved only; tried in order on boot", +msgAddOk:"Saved to list", +msgFull:"Up to 5 networks allowed", +msgDelOk:"Removed", s2Title:"Setup Complete", s2Next:"Next Step: ", s2Auto:"Device will restart and connect. Use the pairing code to bind.", @@ -302,6 +326,9 @@ document.getElementById('srvCusTip2').textContent=t('srvCusTip2'); document.getElementById('srvIn').placeholder=t('srvUrlPh'); document.getElementById('frontendPortIn').placeholder=t('srvPortPh'); document.getElementById('btnConnText').textContent=t('btnConn'); +document.getElementById('btnAddText').textContent=t('btnAdd'); +document.getElementById('savedTitle').textContent=t('savedTitle'); +document.getElementById('addHint').textContent=t('addHint'); document.getElementById('s2Title').textContent=t('s2Title'); document.getElementById('s2Next').textContent=t('s2Next'); document.getElementById('s2Auto').textContent=t('s2Auto'); @@ -330,6 +357,7 @@ var LOCAL_DEFAULT_SERVER='http://本地服务IP:8080'; var LOCAL_DEFAULT_FRONTEND_PORT='3000'; var ssid='',ctm=null,devMac='',srvUrl='',srvMode='official',pairCode='',frontendPort=LOCAL_DEFAULT_FRONTEND_PORT; var hiddenSsids=[]; +var savedMax=5; function setStep(n){ var d1=document.getElementById('d1'),d2=document.getElementById('d2'); @@ -499,11 +527,76 @@ pairCode=''; document.getElementById('pairWrap').classList.add('hidden'); document.getElementById('pairCode').textContent='--------'; document.getElementById('cdN').textContent='10'; +loadSaved(); +} + +// ── Saved networks (multi-WiFi) ───────────────────────────── +function renderSaved(d){ +if(d&&typeof d.max==='number')savedMax=d.max; +var names=(d&&d.networks)||[]; +var wrap=document.getElementById('savedWrap'); +var ul=document.getElementById('savedList'); +var cnt=document.getElementById('savedCount'); +var addBtn=document.getElementById('addBtn'); +ul.innerHTML=''; +names.forEach(function(name,idx){ +var li=document.createElement('li');li.className='wi';li.style.cursor='default'; +var left=document.createElement('span');left.className='wn'; +var ord=document.createElement('span');ord.className='si-ord';ord.textContent=(idx+1); +var nm=document.createElement('span');nm.textContent=name; +left.appendChild(ord);left.appendChild(nm); +var del=document.createElement('button');del.className='wx';del.type='button';del.innerHTML='×'; +del.onclick=function(){delNet(name)}; +li.appendChild(left);li.appendChild(del); +ul.appendChild(li); +}); +cnt.textContent=names.length+'/'+savedMax; +if(names.length>0)wrap.classList.remove('hidden');else wrap.classList.add('hidden'); +var full=names.length>=savedMax; +addBtn.disabled=full; +} + +function loadSaved(){ +fetch('/wifi_list').then(function(r){return r.json()}).then(renderSaved).catch(function(){}); +} + +function delNet(name){ +var st=document.getElementById('pSt'); +var fd=new FormData();fd.append('ssid',name); +fetch('/delete_wifi',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){ +if(d&&d.list){renderSaved(d.list);}else{loadSaved();} +st.className='st w';st.textContent=t('msgDelOk'); +}).catch(function(){st.className='st e';st.textContent=t('msgReqFail');}); +} + +function doAddOnly(){ +var s=ssid||document.getElementById('ssidIn').value.trim(); +var p=document.getElementById('pwIn').value; +var st=document.getElementById('pSt'),btn=document.getElementById('addBtn'); +if(!s){st.className='st e';st.textContent=t('errSsid');return;} +if(p.length>0&&p.length<8){st.className='st e';st.textContent=t('errPwLen');return;} +btn.classList.add('ld');btn.disabled=true; +var fd=new FormData();fd.append('ssid',s);fd.append('pass',p); +fetch('/add_wifi',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){ +btn.classList.remove('ld');btn.disabled=false; +if(d.ok){ +st.className='st s';st.textContent=t('msgAddOk'); +document.getElementById('ssidIn').value='';document.getElementById('pwIn').value=''; +ssid='';reShowList(); +renderSaved(d.list); +}else{ +st.className='st e';st.textContent=d.msg==='FULL'?t('msgFull'):(d.msg||t('msgReqFail')); +} +}).catch(function(){ +btn.classList.remove('ld');btn.disabled=false; +st.className='st e';st.textContent=t('msgReqFail'); +}); } (function(){ applyLang(); switchWTab('manual'); +loadSaved(); fetch('/scan').then(function(r){return r.json()}).then(function(d){ document.getElementById('wScanLoading').style.display='none'; var ul=document.getElementById('wifiList'); diff --git a/firmware/src/config.h b/firmware/src/config.h index 472bc498..9285a2c4 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -108,6 +108,7 @@ static const int FULL_REFRESH_INTERVAL = 10; // Full refresh every N updates to // ── Config defaults ───────────────────────────────────────── static const char *DEFAULT_SERVER = ""; // Must be set via captive portal static const int WIFI_TIMEOUT = 15000; // ms +static const int MAX_WIFI_NETWORKS = 5; // Max saved WiFi credentials (tried in order on boot) static const int HTTP_TIMEOUT = 30000; // ms static const int CFG_BTN_HOLD_MS = 2000; // Long press duration to trigger config mode static const int AI_CHAT_BTN_HOLD_MS = 3000; // Long press duration to enter AI chat mode @@ -119,6 +120,12 @@ static const int LIVE_POLL_MS = 5000; // Poll interval for pending remot static const int LIVE_WIFI_RETRY_MS = 5000; // Retry interval when WiFi is disconnected static const unsigned long HEARTBEAT_INTERVAL_MS = 10UL * 60UL * 1000UL; static const int MAX_RETRY_COUNT = 5; // Max retries before deep sleep +// WiFi -> captive portal fallback: when ALL saved networks fail to connect, +// do this many quick in-place retry sweeps (no reboot) before opening the AP. +// Keeps the portal fast to appear (user is likely waiting to reconfigure) +// while still riding out a brief blip such as a router rebooting. +static const int WIFI_PORTAL_RETRY_SWEEPS = 1; +static const unsigned long WIFI_PORTAL_RETRY_DELAY_MS = 3000; // Progressive retry delays in seconds: 5s, 15s, 30s, 60s, 120s static const int RETRY_DELAYS[] = {5, 15, 30, 60, 120}; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 1c03e9e3..e555be59 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -835,11 +835,24 @@ void setup() { ledFeedback("connecting"); if (!connectWiFi()) { - Serial.println("[VOICE] WiFi failed, restarting in 5s"); - ledFeedback("fail"); - delay(5000); - ESP.restart(); + // Quick in-place retry sweeps, then fall back to the captive portal. + for (int i = 0; i < WIFI_PORTAL_RETRY_SWEEPS; i++) { + Serial.printf("[VOICE] WiFi unreachable, quick retry sweep %d/%d in %lus\n", + i + 1, WIFI_PORTAL_RETRY_SWEEPS, WIFI_PORTAL_RETRY_DELAY_MS / 1000); + ledFeedback("fail"); + delay(WIFI_PORTAL_RETRY_DELAY_MS); + if (connectWiFi()) break; + } + if (WiFi.status() != WL_CONNECTED) { + Serial.println("[VOICE] WiFi still unreachable -> captive portal"); + resetRetryCount(); + ledFeedback("portal"); + startCaptivePortal(); + gPortalMode = true; + return; + } } + resetRetryCount(); Serial.println("[VOICE] WiFi connected"); static Inmp441Max98357Codec codec(true); @@ -904,6 +917,7 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVo static void handleLiveMode(); static bool waitForContentReady(); static void handleFailure(const char *reason); +static void handleWiFiFailure(); static void enterDeepSleep(int minutes); static bool runAiChatConversation(); static void runSingleVoiceTurn(); @@ -979,7 +993,7 @@ void setup() { return; } ledFeedback("fail"); - handleFailure("WiFi failed"); + handleWiFiFailure(); return; } @@ -1470,6 +1484,29 @@ static void handleFailure(const char *reason) { } } +// ── WiFi failure handler ──────────────────────────────────── +// All saved WiFi networks failed to associate (connectWiFi already swept the +// full list once). Do a few quick in-place retry sweeps to ride out a brief +// blip (e.g. router rebooting), then fall back to the captive portal so the +// user can fix or add credentials. No multi-minute deep-sleep delays here — +// the user is typically standing by waiting to reconfigure. +static void handleWiFiFailure() { + for (int i = 0; i < WIFI_PORTAL_RETRY_SWEEPS; i++) { + Serial.printf("[DIAG] WiFi unreachable, quick retry sweep %d/%d in %lus\n", + i + 1, WIFI_PORTAL_RETRY_SWEEPS, WIFI_PORTAL_RETRY_DELAY_MS / 1000); + delay(WIFI_PORTAL_RETRY_DELAY_MS); + if (connectWiFi()) { + Serial.println("[DIAG] WiFi recovered -> reboot to resume normal flow"); + resetRetryCount(); + ESP.restart(); + } + if (g_userAborted) break; + } + Serial.println("[DIAG] WiFi still unreachable -> captive portal"); + resetRetryCount(); + enterPortalMode(); +} + // ── Live mode ─────────────────────────────────────────────── static void handleLiveMode() { diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index 1bc3e8a0..437de065 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -57,11 +57,13 @@ static QueueHandle_t gVoiceWsEventQueue = nullptr; // ── WiFi connection ───────────────────────────────────────── -bool connectWiFi() { - g_userAborted = false; - Serial.printf("WiFi: %s ", cfgSSID.c_str()); +// Associate with a single AP and wait up to WIFI_TIMEOUT. Returns true on +// success. Returns false on timeout/abort (g_userAborted set on abort). +static bool tryAssociate(const String &ssid, const String &pass) { + Serial.printf("WiFi: %s ", ssid.c_str()); WiFi.mode(WIFI_STA); - WiFi.begin(cfgSSID.c_str(), cfgPass.c_str()); + WiFi.disconnect(); + WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long t0 = millis(); while (WiFi.status() != WL_CONNECTED) { @@ -74,6 +76,42 @@ bool connectWiFi() { Serial.print("."); } Serial.printf(" OK IP=%s\n", WiFi.localIP().toString().c_str()); + return true; +} + +bool connectWiFi() { + g_userAborted = false; + + // Try each saved network in order; first success wins. + int count = getWiFiCount(); + if (count <= 0) { + // No saved list (shouldn't normally happen post-loadConfig); fall back + // to the legacy primary credentials. + if (cfgSSID.length() == 0) return false; + if (!tryAssociate(cfgSSID, cfgPass)) return false; + } else { + bool associated = false; + for (int i = 0; i < count; i++) { + String ssid, pass; + if (!getWiFiAt(i, ssid, pass)) continue; + if (g_userAborted) return false; + Serial.printf("[WIFI] Trying network %d/%d\n", i + 1, count); + if (tryAssociate(ssid, pass)) { + // Promote the connected network to primary so cfgSSID/cfgPass + // (used for display, pairing, heartbeat) reflect it. + cfgSSID = ssid; + cfgPass = pass; + associated = true; + break; + } + if (g_userAborted) return false; + } + if (!associated) { + Serial.println("[WIFI] All saved networks failed"); + return false; + } + } + if (!ensureDeviceToken()) return false; if (cfgPendingPairCode.length() > 0) { String mac = WiFi.macAddress(); diff --git a/firmware/src/portal.cpp b/firmware/src/portal.cpp index f4fc0180..6c309187 100644 --- a/firmware/src/portal.cpp +++ b/firmware/src/portal.cpp @@ -69,6 +69,34 @@ static bool isValidUrl(const String &url) { return url.startsWith("http://") || url.startsWith("https://"); } +// Escape a string for safe embedding in a JSON string literal. +static String jsonEscape(const String &s) { + String out; + for (unsigned int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { out += '\\'; out += c; } + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if (c == '\t') out += "\\t"; + else out += c; + } + return out; +} + +// Build {"networks":["a","b"],"max":N} from the saved WiFi list. +static String buildWiFiListJson() { + String names[MAX_WIFI_NETWORKS]; + int count = 0; + getWiFiSSIDList(names, count); + String json = "{\"networks\":["; + for (int i = 0; i < count; i++) { + if (i > 0) json += ","; + json += "\"" + jsonEscape(names[i]) + "\""; + } + json += "],\"max\":" + String(MAX_WIFI_NETWORKS) + "}"; + return json; +} + static String generatePairCode() { char buf[7]; snprintf(buf, sizeof(buf), "%06u", (unsigned)(esp_random() % 1000000)); @@ -294,6 +322,44 @@ void startCaptivePortal() { Serial.println("Restart scheduled in 30 seconds (or earlier via /restart)"); }); + // ── Route: List saved WiFi networks (names only) ──────── + webServer.on("/wifi_list", HTTP_GET, []() { + webServer.sendHeader("Access-Control-Allow-Origin", "*"); + webServer.send(200, "application/json", buildWiFiListJson()); + }); + + // ── Route: Add a network to the saved list (no connect) ── + // Used to pre-register secondary networks (office / phone hotspot) that + // are not reachable from the current location. No restart is triggered. + webServer.on("/add_wifi", HTTP_POST, []() { + String ssid = sanitizeSSID(webServer.arg("ssid")); + String pass = sanitizeTextInput(webServer.arg("pass"), PORTAL_MAX_PASS); + webServer.sendHeader("Access-Control-Allow-Origin", "*"); + if (ssid.length() == 0) { + webServer.send(200, "application/json", "{\"ok\":false,\"msg\":\"SSID empty\"}"); + return; + } + if (addWiFiConfig(ssid, pass)) { + Serial.printf("[PORTAL] Added saved network: %s\n", ssid.c_str()); + webServer.send(200, "application/json", + "{\"ok\":true,\"list\":" + buildWiFiListJson() + "}"); + } else { + webServer.send(200, "application/json", + "{\"ok\":false,\"msg\":\"FULL\"}"); + } + }); + + // ── Route: Delete a saved network by SSID ─────────────── + webServer.on("/delete_wifi", HTTP_POST, []() { + String ssid = sanitizeSSID(webServer.arg("ssid")); + bool ok = deleteWiFiBySSID(ssid); + Serial.printf("[PORTAL] Delete network '%s' -> %s\n", ssid.c_str(), ok ? "ok" : "not found"); + webServer.sendHeader("Access-Control-Allow-Origin", "*"); + webServer.send(200, "application/json", + String("{\"ok\":") + (ok ? "true" : "false") + + ",\"list\":" + buildWiFiListJson() + "}"); + }); + // ── Route: Manual restart ─────────────────────────────── webServer.on("/restart", HTTP_POST, []() { Serial.println("\n--- /restart Request Received ---"); diff --git a/firmware/src/storage.cpp b/firmware/src/storage.cpp index e2922645..c0cbef22 100644 --- a/firmware/src/storage.cpp +++ b/firmware/src/storage.cpp @@ -19,6 +19,52 @@ String cfgConfigJson; String cfgDeviceToken; String cfgPendingPairCode; +// ── Multi-WiFi credential list (in-memory, mirrors NVS) ───── +static String g_wifiSsids[MAX_WIFI_NETWORKS]; +static String g_wifiPass[MAX_WIFI_NETWORKS]; +static int g_wifiCount = 0; + +// Build NVS key "wifi_sN" / "wifi_pN" (kind = 's' or 'p'). +static String wifiKey(char kind, int idx) { + char buf[12]; + snprintf(buf, sizeof(buf), "wifi_%c%d", kind, idx); + return String(buf); +} + +// Persist the in-memory list to NVS (assumes prefs already open read-write). +static void persistWiFiList(Preferences &p) { + p.putInt("wifi_n", g_wifiCount); + for (int i = 0; i < MAX_WIFI_NETWORKS; i++) { + if (i < g_wifiCount) { + p.putString(wifiKey('s', i).c_str(), g_wifiSsids[i]); + p.putString(wifiKey('p', i).c_str(), g_wifiPass[i]); + } else { + p.remove(wifiKey('s', i).c_str()); + p.remove(wifiKey('p', i).c_str()); + } + } + // Keep legacy single-SSID keys in sync with slot 0 (back-compat). + if (g_wifiCount > 0) { + p.putString("ssid", g_wifiSsids[0]); + p.putString("pass", g_wifiPass[0]); + } else { + p.remove("ssid"); + p.remove("pass"); + } +} + +// Mirror slot 0 into the legacy cfgSSID/cfgPass runtime vars used downstream. +static void syncPrimaryRuntime() { + if (g_wifiCount > 0) { + cfgSSID = g_wifiSsids[0]; + cfgPass = g_wifiPass[0]; + } else { + cfgSSID = ""; + cfgPass = ""; + } +} + + // ── Load config from NVS ──────────────────────────────────── void loadConfig() { @@ -36,6 +82,7 @@ void loadConfig() { cfgConfigJson = ""; cfgDeviceToken = ""; cfgPendingPairCode = ""; + g_wifiCount = 0; return; } @@ -46,8 +93,39 @@ void loadConfig() { cfgConfigJson = prefs.getString("config_json", ""); cfgDeviceToken = prefs.getString("device_token", ""); cfgPendingPairCode = prefs.getString("pair_code", ""); + + // ── Load multi-WiFi list ──────────────────────────────── + int wifiN = prefs.getInt("wifi_n", -1); + bool needMigration = false; + g_wifiCount = 0; + if (wifiN >= 0) { + if (wifiN > MAX_WIFI_NETWORKS) wifiN = MAX_WIFI_NETWORKS; + for (int i = 0; i < wifiN; i++) { + String s = prefs.getString(wifiKey('s', i).c_str(), ""); + if (s.length() == 0) continue; // skip corrupt/empty slot + g_wifiSsids[g_wifiCount] = s; + g_wifiPass[g_wifiCount] = prefs.getString(wifiKey('p', i).c_str(), ""); + g_wifiCount++; + } + } else if (cfgSSID.length() > 0) { + // Legacy single-SSID config -> migrate into slot 0. + g_wifiSsids[0] = cfgSSID; + g_wifiPass[0] = cfgPass; + g_wifiCount = 1; + needMigration = true; + } prefs.end(); + syncPrimaryRuntime(); + + if (needMigration) { + prefs.begin("inksight", false); // read-write + prefs.putInt("cfg_version", CONFIG_VERSION); + persistWiFiList(prefs); + prefs.end(); + Serial.printf("Migrated legacy WiFi config into multi-WiFi list (%d entry)\n", g_wifiCount); + } + // Sanity checks if (cfgSleepMin < 10 || cfgSleepMin > 1440) { cfgSleepMin = 60; @@ -94,14 +172,82 @@ void markFirstInstallLiveModeDone() { // ── Save WiFi credentials ─────────────────────────────────── +// Set as the primary network (slot 0). Back-compat wrapper over addWiFiConfig. void saveWiFiConfig(const String &ssid, const String &pass) { - prefs.begin("inksight", false); // read-write + addWiFiConfig(ssid, pass); +} + +// ── Multi-WiFi credential list ────────────────────────────── + +int getWiFiCount() { + return g_wifiCount; +} + +bool getWiFiAt(int idx, String &ssid, String &pass) { + if (idx < 0 || idx >= g_wifiCount) return false; + ssid = g_wifiSsids[idx]; + pass = g_wifiPass[idx]; + return true; +} + +void getWiFiSSIDList(String out[], int &count) { + count = g_wifiCount; + for (int i = 0; i < g_wifiCount; i++) { + out[i] = g_wifiSsids[i]; + } +} + +bool addWiFiConfig(const String &ssid, const String &pass) { + if (ssid.length() == 0) return false; + + // Existing SSID: update password and move to front (slot 0). + int existing = -1; + for (int i = 0; i < g_wifiCount; i++) { + if (g_wifiSsids[i] == ssid) { existing = i; break; } + } + if (existing >= 0) { + for (int i = existing; i > 0; i--) { + g_wifiSsids[i] = g_wifiSsids[i - 1]; + g_wifiPass[i] = g_wifiPass[i - 1]; + } + g_wifiSsids[0] = ssid; + g_wifiPass[0] = pass; + } else { + if (g_wifiCount >= MAX_WIFI_NETWORKS) return false; // list full + // Append new network to the end; tried after earlier-saved ones. + g_wifiSsids[g_wifiCount] = ssid; + g_wifiPass[g_wifiCount] = pass; + g_wifiCount++; + } + + prefs.begin("inksight", false); + prefs.putInt("cfg_version", CONFIG_VERSION); + persistWiFiList(prefs); + prefs.end(); + syncPrimaryRuntime(); + return true; +} + +bool deleteWiFiBySSID(const String &ssid) { + int idx = -1; + for (int i = 0; i < g_wifiCount; i++) { + if (g_wifiSsids[i] == ssid) { idx = i; break; } + } + if (idx < 0) return false; + for (int i = idx; i < g_wifiCount - 1; i++) { + g_wifiSsids[i] = g_wifiSsids[i + 1]; + g_wifiPass[i] = g_wifiPass[i + 1]; + } + g_wifiCount--; + g_wifiSsids[g_wifiCount] = ""; + g_wifiPass[g_wifiCount] = ""; + + prefs.begin("inksight", false); prefs.putInt("cfg_version", CONFIG_VERSION); - prefs.putString("ssid", ssid); - prefs.putString("pass", pass); + persistWiFiList(prefs); prefs.end(); - cfgSSID = ssid; - cfgPass = pass; + syncPrimaryRuntime(); + return true; } // ── Save server URL ───────────────────────────────────────── diff --git a/firmware/src/storage.h b/firmware/src/storage.h index 99d546e9..88db20d2 100644 --- a/firmware/src/storage.h +++ b/firmware/src/storage.h @@ -20,6 +20,27 @@ void loadConfig(); // Save WiFi credentials to NVS void saveWiFiConfig(const String &ssid, const String &pass); +// ── Multi-WiFi credential list (up to MAX_WIFI_NETWORKS) ──── +// Networks are tried in index order (0 first) on boot. + +// Number of saved WiFi networks. +int getWiFiCount(); + +// Read credentials at index. Returns false if idx out of range. +bool getWiFiAt(int idx, String &ssid, String &pass); + +// Add or update a network. If the SSID already exists, its password is +// updated and it is moved to the front (slot 0). Otherwise it is appended. +// Returns false if the list is full and the SSID is new. +bool addWiFiConfig(const String &ssid, const String &pass); + +// Delete a network by SSID and compact the list. Returns true if removed. +bool deleteWiFiBySSID(const String &ssid); + +// Fill out[] (length >= MAX_WIFI_NETWORKS) with saved SSID names (no passwords). +// count is set to the number written. +void getWiFiSSIDList(String out[], int &count); + // Save server URL to NVS void saveServerUrl(const String &url);