Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion docs/button-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
11 changes: 10 additions & 1 deletion docs/en/button-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
93 changes: 93 additions & 0 deletions firmware/data/portal_html.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -88,6 +91,13 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col

<!-- Step 1: WiFi + Server -->
<div id="s1">
<div id="savedWrap" class="hidden" style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<span class="lbl" id="savedTitle" style="margin-bottom:0">已保存网络</span>
<span id="savedCount" style="font-size:.72rem;color:var(--gy)">0/5</span>
</div>
<ul class="wl" id="savedList" style="margin-bottom:0"></ul>
</div>
<div class="wtabs">
<div class="wtab" id="wtScan" onclick="switchWTab('scan')">选择网络</div>
<div class="wtab act" id="wtMan" onclick="switchWTab('manual')">手动输入</div>
Expand Down Expand Up @@ -135,6 +145,8 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col
</div>
</div>
<button class="btn btn-ghost" id="cBtn" onclick="doConnect()"><span class="bt" id="btnConnText">连接并保存</span><div class="sp"></div></button>
<button class="btn" id="addBtn" onclick="doAddOnly()" style="margin-top:8px;background:var(--bg);color:var(--bk);border:1px solid var(--bd);font-size:.82rem;padding:10px"><span class="bt" id="btnAddText">仅保存到列表</span><div class="sp"></div></button>
<div id="addHint" style="font-size:.68rem;color:var(--gy);margin-top:5px;text-align:center">连不上的网络(如办公室/手机热点)可仅保存,开机会按顺序尝试</div>
</div>

<!-- Step 2: Success -->
Expand Down Expand Up @@ -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:"设备将自动重启并联网,请使用配对码继续完成认领与配置。",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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='&times;';
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');
Expand Down
7 changes: 7 additions & 0 deletions firmware/src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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};

Expand Down
47 changes: 42 additions & 5 deletions firmware/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -979,7 +993,7 @@ void setup() {
return;
}
ledFeedback("fail");
handleFailure("WiFi failed");
handleWiFiFailure();
return;
}

Expand Down Expand Up @@ -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() {
Expand Down
46 changes: 42 additions & 4 deletions firmware/src/network.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
Loading
Loading