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);