diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py
index bf421ac..dba2343 100644
--- a/backend/api/routes/config.py
+++ b/backend/api/routes/config.py
@@ -44,11 +44,12 @@ async def post_config(
)
modes = data.get("modes", [])
logger.info(
- "[CONFIG SAVE REQUEST] source=%s mac=%s modes=%s refresh_strategy=%s",
+ "[CONFIG SAVE REQUEST] source=%s mac=%s modes=%s refresh_strategy=%s always_active=%s",
x_inksight_client or "unknown",
mac,
len(modes) if isinstance(modes, list) else 0,
data.get("refresh_strategy"),
+ data.get("always_active"),
)
config_id = await save_config(mac, data)
await set_pending_refresh(mac, True)
@@ -63,9 +64,11 @@ async def post_config(
saved_config = await get_active_config(mac)
if saved_config:
logger.info(
- "[CONFIG VERIFY] Saved config id=%s refresh_strategy=%s",
+ "[CONFIG VERIFY] Saved config id=%s refresh_strategy=%s always_active=%s is_always_active=%s",
saved_config.get("id"),
saved_config.get("refresh_strategy"),
+ saved_config.get("always_active"),
+ saved_config.get("is_always_active"),
)
return ConfigSaveResponse(ok=True, config_id=config_id)
diff --git a/backend/api/routes/device.py b/backend/api/routes/device.py
index b1c8dfb..96402aa 100644
--- a/backend/api/routes/device.py
+++ b/backend/api/routes/device.py
@@ -124,20 +124,25 @@ async def device_state(
if ota_url and "/firmware/download" in ota_url and "/api/firmware/download" not in ota_url:
state["ota_url"] = ota_url.replace("/firmware/download", "/api/firmware/download")
- explicit_mode = str(state.get("runtime_mode") or "").lower()
- if explicit_mode in ("active", "interval"):
- state["runtime_mode"] = explicit_mode
- return state
-
runtime_mode = "interval"
last_poll = state.get("last_state_poll_at", "")
+ recent_poll = False
if isinstance(last_poll, str) and last_poll:
try:
delta = (datetime.now() - datetime.fromisoformat(last_poll)).total_seconds()
- runtime_mode = "active" if delta <= 8 else "interval"
+ recent_poll = delta <= 8
except ValueError:
logger.warning("[DEVICE] Invalid last_state_poll_at for %s: %s", mac, last_poll, exc_info=True)
- runtime_mode = "interval"
+ recent_poll = False
+
+ explicit_mode = str(state.get("runtime_mode") or "").lower()
+ if explicit_mode == "active":
+ runtime_mode = "active" if recent_poll else "interval"
+ elif explicit_mode == "interval":
+ runtime_mode = "interval"
+ elif recent_poll:
+ runtime_mode = "active"
+
state["runtime_mode"] = runtime_mode
return state
diff --git a/backend/core/config_store.py b/backend/core/config_store.py
index 6c3deba..0c771e0 100644
--- a/backend/core/config_store.py
+++ b/backend/core/config_store.py
@@ -1473,8 +1473,13 @@ async def revoke_device_member(owner_user_id: int, mac: str, target_user_id: int
async def save_config(mac: str, data: dict) -> int:
now = datetime.now().isoformat()
refresh_strategy = data.get("refreshStrategy", "random")
+ always_active = 1 if bool(data.get("always_active", False)) else 0
logger.info(
- f"[CONFIG SAVE] mac={mac}, refreshStrategy={refresh_strategy}, modes={data.get('modes')}"
+ "[CONFIG SAVE] mac=%s, refreshStrategy=%s, always_active=%s, modes=%s",
+ mac,
+ refresh_strategy,
+ always_active,
+ data.get("modes"),
)
db = await get_main_db()
@@ -1525,7 +1530,7 @@ async def save_config(mac: str, data: dict) -> int:
memo_text,
mode_overrides_json,
1 if bool(data.get("is_focus_listening", False)) else 0,
- 1 if bool(data.get("always_active", False)) else 0,
+ always_active,
now,
),
)
@@ -1544,7 +1549,7 @@ async def save_config(mac: str, data: dict) -> int:
)
await db.commit()
- logger.info(f"[CONFIG SAVE] ✓ Saved as id={config_id}, is_active=1")
+ logger.info("[CONFIG SAVE] Saved as id=%s, is_active=1, always_active=%s", config_id, always_active)
return config_id
diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py
index 0289be5..d72a93c 100644
--- a/backend/tests/test_integration.py
+++ b/backend/tests/test_integration.py
@@ -395,6 +395,39 @@ async def test_config_save_preserves_active_runtime_mode(client):
assert state["pending_refresh"] == 1
+@pytest.mark.asyncio
+async def test_stale_active_runtime_mode_reports_interval_for_web_state(client):
+ mac = "AA:BB:CC:DD:EE:13"
+ headers = await provision_device_headers(client, mac)
+ from core.config_store import update_device_state
+
+ claim_resp = await client.post(f"/api/device/{mac}/claim-token", headers=headers)
+ assert claim_resp.status_code == 200
+
+ await register_user(client, "stale_runtime")
+ consume_resp = await client.post("/api/claim/consume", json={"token": claim_resp.json()["token"]})
+ assert consume_resp.status_code == 200
+
+ heartbeat_resp = await client.post(
+ f"/api/v1/device/{mac}/heartbeat",
+ json={"battery_voltage": 3.91, "wifi_rssi": -42},
+ headers=headers,
+ )
+ assert heartbeat_resp.status_code == 200
+
+ await update_device_state(
+ mac,
+ runtime_mode="active",
+ last_state_poll_at="2000-01-01T00:00:00",
+ )
+
+ state_resp = await client.get(f"/api/device/{mac}/state")
+ assert state_resp.status_code == 200
+ state = state_resp.json()
+ assert state["is_online"] is True
+ assert state["runtime_mode"] == "interval"
+
+
@pytest.mark.asyncio
async def test_config_save_persists_always_active_flag(client):
mac = "AA:BB:CC:DD:EE:12"
diff --git a/firmware/data/portal_html.h b/firmware/data/portal_html.h
index 0f697b6..cd63a6b 100644
--- a/firmware/data/portal_html.h
+++ b/firmware/data/portal_html.h
@@ -46,6 +46,10 @@ 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}
+.wa{display:flex;align-items:center;gap:6px;flex-shrink:0}
+.wc{background:var(--bk);border:none;cursor:pointer;color:#fff;padding:5px 8px;border-radius:6px;font-size:.72rem;font-weight:600;line-height:1;flex-shrink:0}
+.wc:hover{background:#333}
+.wc:disabled,.wx:disabled{opacity:.6;cursor:not-allowed}
.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}
@@ -205,11 +209,14 @@ srvUrlPh:"例如: http://192.168.1.100:8080",
srvPortPh:"例如: 3000",
btnConn:"连接并保存",
btnAdd:"仅保存到列表",
+btnSavedConn:"连接",
savedTitle:"已保存网络",
addHint:"连不上的网络(如办公室/手机热点)可仅保存,开机会按顺序尝试",
msgAddOk:"已保存到列表",
msgFull:"最多保存 5 个网络",
msgDelOk:"已删除",
+msgSavedConnFail:"连接失败,密码可能已变更,请重新输入密码后连接并保存",
+msgSavedNotFound:"未找到该已保存网络",
s2Title:"配网完成",
s2Next:"下一步:",
s2Auto:"设备将自动重启并联网,请使用配对码继续完成认领与配置。",
@@ -263,11 +270,14 @@ srvUrlPh:"e.g. http://192.168.1.100:8080",
srvPortPh:"e.g. 3000",
btnConn:"Connect & Save",
btnAdd:"Save to List",
+btnSavedConn:"Connect",
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",
+msgSavedConnFail:"Connection failed. The password may have changed; enter it again and use Connect & Save.",
+msgSavedNotFound:"Saved network not found",
s2Title:"Setup Complete",
s2Next:"Next Step: ",
s2Auto:"Device will restart and connect. Use the pairing code to bind.",
@@ -440,21 +450,26 @@ if(i.type==='password'){i.type='text';b.innerHTML='';}
}
+function setServerForConnect(st){
+var sv=srvMode==='official'?OFFICIAL_SERVER:document.getElementById('srvIn').value.trim();
+var fp=srvMode==='official'?LOCAL_DEFAULT_FRONTEND_PORT:document.getElementById('frontendPortIn').value.trim();
+if(sv&&!sv.match(/^https?:\/\//)){st.className='st e';st.textContent=t('errUrl');return false;}
+if(srvMode==='custom'&&!/^\d{2,5}$/.test(fp)){st.className='st e';st.textContent=t('errPort');return false;}
+srvUrl=normalizeServerUrl(sv);
+frontendPort=fp;
+return true;
+}
+
function doConnect(){
var s=ssid||document.getElementById('ssidIn').value.trim();
var p=document.getElementById('pwIn').value;
-var sv=srvMode==='official'?OFFICIAL_SERVER:document.getElementById('srvIn').value.trim();
-var fp=srvMode==='official'?LOCAL_DEFAULT_FRONTEND_PORT:document.getElementById('frontendPortIn').value.trim();
var st=document.getElementById('pSt'),btn=document.getElementById('cBtn');
if(!s){st.className='st e';st.textContent=t('errSsid');return;}
if(!p){st.className='st e';st.textContent=t('errPw');return;}
if(p.length<8){st.className='st e';st.textContent=t('errPwLen');return;}
-if(sv&&!sv.match(/^https?:\/\//)){st.className='st e';st.textContent=t('errUrl');return;}
-if(srvMode==='custom'&&!/^\d{2,5}$/.test(fp)){st.className='st e';st.textContent=t('errPort');return;}
+if(!setServerForConnect(st))return;
btn.classList.add('ld');btn.disabled=true;
st.className='st c';st.textContent=t('msgConn')+s+' ...';
-srvUrl=normalizeServerUrl(sv);
-frontendPort=fp;
var fd=new FormData();fd.append('ssid',s);fd.append('pass',p);if(srvUrl)fd.append('server',srvUrl);
fetch('/save_wifi',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){
@@ -545,9 +560,13 @@ 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 actions=document.createElement('span');actions.className='wa';
+var conn=document.createElement('button');conn.className='wc';conn.type='button';conn.textContent=t('btnSavedConn');
+conn.onclick=function(){connectSaved(name,conn)};
var del=document.createElement('button');del.className='wx';del.type='button';del.innerHTML='×';
del.onclick=function(){delNet(name)};
-li.appendChild(left);li.appendChild(del);
+actions.appendChild(conn);actions.appendChild(del);
+li.appendChild(left);li.appendChild(actions);
ul.appendChild(li);
});
cnt.textContent=names.length+'/'+savedMax;
@@ -569,6 +588,33 @@ st.className='st w';st.textContent=t('msgDelOk');
}).catch(function(){st.className='st e';st.textContent=t('msgReqFail');});
}
+function connectSaved(name,btn){
+var st=document.getElementById('pSt');
+if(!setServerForConnect(st))return;
+btn.disabled=true;
+st.className='st c';st.textContent=t('msgConn')+name+' ...';
+var fd=new FormData();fd.append('ssid',name);if(srvUrl)fd.append('server',srvUrl);
+fetch('/connect_saved',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){
+btn.disabled=false;
+if(d&&d.list){renderSaved(d.list);}
+if(d.ok){
+st.className='st s';st.textContent=t('msgConnOk');
+document.getElementById('cSSID').textContent=name;
+pairCode=(d.pair_code||'').toUpperCase();
+showSuccess();
+}else if(d.msg==='SAVED_CONNECT_FAILED'){
+st.className='st e';st.textContent=t('msgSavedConnFail');
+}else if(d.msg==='NOT_FOUND'){
+st.className='st e';st.textContent=t('msgSavedNotFound');
+}else{
+st.className='st e';st.textContent=d.msg||t('msgConnFail');
+}
+}).catch(function(){
+btn.disabled=false;
+st.className='st e';st.textContent=t('msgReqFail');
+});
+}
+
function doAddOnly(){
var s=ssid||document.getElementById('ssidIn').value.trim();
var p=document.getElementById('pwIn').value;
diff --git a/firmware/src/audio_codec.cpp b/firmware/src/audio_codec.cpp
index 5c4cf79..6e77dfe 100644
--- a/firmware/src/audio_codec.cpp
+++ b/firmware/src/audio_codec.cpp
@@ -9,6 +9,16 @@
#define I2S_MIC_PORT I2S_NUM_1
#define I2S_SPK_PORT I2S_NUM_0
+#if defined(BOARD_PROFILE_SMT_WROOM32E)
+#define I2S_MIC_CHANNEL_FORMAT I2S_CHANNEL_FMT_ONLY_LEFT
+#else
+#define I2S_MIC_CHANNEL_FORMAT I2S_CHANNEL_FMT_ONLY_RIGHT
+#endif
+
+#ifndef I2S_MIC_SAMPLE_SHIFT
+#define I2S_MIC_SAMPLE_SHIFT 16
+#endif
+
// ── Inmp441Max98357Codec ─────────────────────────────────────
Inmp441Max98357Codec::Inmp441Max98357Codec(bool duplex) {
@@ -26,7 +36,7 @@ static bool installHalfDuplexInputDriver() {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = CODEC_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
- .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
+ .channel_format = I2S_MIC_CHANNEL_FORMAT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 4,
@@ -102,7 +112,7 @@ static bool installDuplexMicDriver() {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = CODEC_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
- .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
+ .channel_format = I2S_MIC_CHANNEL_FORMAT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 6,
@@ -242,8 +252,9 @@ int Inmp441Max98357Codec::Read(int16_t* dest, int maxSamples) {
int samplesRead = bytesRead / sizeof(int32_t);
if (samplesRead > maxSamples) samplesRead = maxSamples;
+
for (int i = 0; i < samplesRead; i++) {
- dest[i] = (int16_t)(read_buf_[i] >> 16);
+ dest[i] = (int16_t)(read_buf_[i] >> I2S_MIC_SAMPLE_SHIFT);
}
return samplesRead;
}
diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp
index e555be5..a5480cf 100644
--- a/firmware/src/main.cpp
+++ b/firmware/src/main.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
@@ -48,13 +49,9 @@ static const char *VOCAB_REVIEW_MODE_ID = "VOCAB_REVIEW";
static const int VOICE_SILENCE_COMMIT_MS = 600;
static const float VOICE_STREAM_VAD_THRESHOLD = 150.0f;
static const unsigned long VOICE_MAX_CAPTURE_MS = 8000;
+static const unsigned long VOICE_PLAYBACK_TAIL_CLEAR_MS = 300;
static const int VOICE_DEEP_CLEAR_INTERVAL = 5; // deep-clear every N turns to remove ghosting
-#if VOICE_ONLY_BUILD
-static const float BARGE_IN_THRESHOLD = 2000.0f;
-static const int BARGE_IN_CONFIRM_FRAMES = 3;
-#endif
-
struct VoiceTurnPerf {
int turnIndex = 0;
unsigned long speechStartAt = 0;
@@ -86,8 +83,16 @@ enum class DeviceState : uint8_t {
ERROR,
};
+enum class WakeupReason : uint8_t {
+ POWER_ON,
+ TIMER,
+ BUTTON,
+ UNKNOWN,
+};
+
struct DeviceContext {
DeviceState state = DeviceState::BOOT;
+ WakeupReason wakeupReason = WakeupReason::POWER_ON;
// Button state
unsigned long btnPressStart = 0;
@@ -106,7 +111,6 @@ struct DeviceContext {
bool wantRefresh = false;
bool wantEnterLiveMode = false;
bool wantEnterAiChatMode = false;
- bool wantSingleVoiceTurn = false;
bool wantEnterVocabReview = false;
bool wantVocabFlip = false;
bool wantVocabNextRating = false;
@@ -119,6 +123,52 @@ struct DeviceContext {
static DeviceContext ctx;
static bool focusListening = false;
+static bool alwaysActive = false;
+
+static WakeupReason detectWakeupReason() {
+ esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
+ switch (cause) {
+ case ESP_SLEEP_WAKEUP_TIMER:
+ Serial.println("[WAKE] Wakeup from timer");
+ return WakeupReason::TIMER;
+ case ESP_SLEEP_WAKEUP_EXT0:
+ case ESP_SLEEP_WAKEUP_EXT1:
+ case ESP_SLEEP_WAKEUP_GPIO:
+ Serial.println("[WAKE] Wakeup from button/GPIO");
+ return WakeupReason::BUTTON;
+ case ESP_SLEEP_WAKEUP_UNDEFINED:
+ Serial.println("[WAKE] Power on or reset");
+ return WakeupReason::POWER_ON;
+ default:
+ Serial.printf("[WAKE] Unknown wakeup cause: %d\n", (int)cause);
+ return WakeupReason::UNKNOWN;
+ }
+}
+
+static int effectiveSleepMinutes() {
+#if DEBUG_MODE
+ return DEBUG_REFRESH_MIN;
+#else
+ return cfgSleepMin;
+#endif
+}
+
+static bool refreshActivityFlags() {
+ bool focusFlag = false;
+ bool alwaysActiveFlag = false;
+ if (!fetchFocusListeningFlag(&focusFlag, &alwaysActiveFlag)) {
+ return false;
+ }
+ bool changed = (focusListening != focusFlag) || (alwaysActive != alwaysActiveFlag);
+ focusListening = focusFlag;
+ alwaysActive = alwaysActiveFlag;
+ if (changed) {
+ Serial.printf("[CONFIG] activity flags updated focus=%s always_active=%s\n",
+ focusListening ? "true" : "false",
+ alwaysActive ? "true" : "false");
+ }
+ return true;
+}
// Content dedup — skip display refresh when content unchanged
static uint32_t lastContentChecksum = 0;
@@ -636,34 +686,7 @@ static void runVoiceLoop(AudioService &audioService) {
}
} else if (state == VoiceState::SPEAKING && cap->sampleCount > 0) {
- float rms = audioCalculateRMS(cap->samples, cap->sampleCount);
- if (rms >= BARGE_IN_THRESHOLD) {
- bargeInFrames++;
- if (bargeInFrames >= BARGE_IN_CONFIRM_FRAMES) {
- Serial.printf("[VOICE] Barge-in detected (RMS=%.0f, frames=%d)\n", rms, bargeInFrames);
- voiceWsInterrupt();
- audioService.ResetPlayback();
- audioService.SetGenerationId(audioService.GetGenerationId() + 1);
- bargeInFrames = 0;
-
- speechDetected = true;
- turnCounter++;
- resetVoiceTurnPerf(turnPerf);
- turnPerf.turnIndex = turnCounter;
- turnPerf.speechStartAt = millis();
- lastVoiceAt = millis();
-
- audioService.PushForEncoding(cap->samples, cap->sampleCount);
- turnPerf.sentAudioChunks++;
- turnPerf.sentAudioBytes += cap->sampleCount * sizeof(int16_t);
- turnPerf.firstChunkSentAt = millis();
-
- state = VoiceState::LISTENING;
- voiceSetLed(state);
- }
- } else {
- bargeInFrames = 0;
- }
+ bargeInFrames = 0;
} else {
bargeInFrames = 0;
}
@@ -676,6 +699,7 @@ static void runVoiceLoop(AudioService &audioService) {
Serial.println("[VOICE] Button interrupt");
voiceWsInterrupt();
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
audioService.SetGenerationId(audioService.GetGenerationId() + 1);
speechDetected = false;
lastVoiceAt = 0;
@@ -738,6 +762,7 @@ static void runVoiceLoop(AudioService &audioService) {
} else if (event.type == VoiceWsEventType::TurnInterrupted) {
Serial.printf("[VOICE] Turn %d: interrupted by server\n", turnPerf.turnIndex);
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
} else if (event.type == VoiceWsEventType::TurnDone) {
turnPerf.turnDoneAt = millis();
@@ -754,7 +779,7 @@ static void runVoiceLoop(AudioService &audioService) {
voiceWsLoop();
delay(20);
}
- delay(300);
+ delay(VOICE_PLAYBACK_TAIL_CLEAR_MS);
audioService.ResetPlayback();
if (exitConversation) {
@@ -805,7 +830,7 @@ static void runVoiceLoop(AudioService &audioService) {
void setup() {
Serial.begin(115200);
delay(3000);
-#if defined(BOARD_PROFILE_ESP32_C3_WROOM02)
+#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E)
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
#endif
@@ -920,7 +945,6 @@ static void handleFailure(const char *reason);
static void handleWiFiFailure();
static void enterDeepSleep(int minutes);
static bool runAiChatConversation();
-static void runSingleVoiceTurn();
static bool decodeVoiceBmpToFrameBuffer(const uint8_t *bmpBytes, size_t bmpLen);
// ═════════════════════════════════════════════════════════════
@@ -930,7 +954,7 @@ static bool decodeVoiceBmpToFrameBuffer(const uint8_t *bmpBytes, size_t bmpLen);
void setup() {
Serial.begin(115200);
delay(3000);
-#if defined(BOARD_PROFILE_ESP32_C3_WROOM02)
+#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E)
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
#endif
@@ -938,6 +962,7 @@ void setup() {
gpioInit();
ledInit();
+ ctx.wakeupReason = detectWakeupReason();
epdInit();
cacheInit();
@@ -997,11 +1022,9 @@ void setup() {
return;
}
- bool focusFlag = false;
- if (fetchFocusListeningFlag(&focusFlag)) {
- focusListening = focusFlag;
- } else {
+ if (!refreshActivityFlags()) {
focusListening = false;
+ alwaysActive = false;
}
if (g_userAborted) {
Serial.println("User aborted during focus fetch -> portal");
@@ -1082,13 +1105,22 @@ void setup() {
}
bool firstInstallLivePending = isFirstInstallLiveModePending();
- if (firstInstallLivePending) {
+ bool buttonWakeActive = (ctx.wakeupReason == WakeupReason::BUTTON);
+ if (firstInstallLivePending || alwaysActive || buttonWakeActive) {
ctx.liveMode = true;
ctx.lastLivePollAt = 0;
ctx.lastLiveWiFiRetryAt = 0;
- markFirstInstallLiveModeDone();
+ if (firstInstallLivePending) {
+ markFirstInstallLiveModeDone();
+ }
postRuntimeMode("active");
- Serial.println("[LIVE] First install: default to active mode");
+ if (buttonWakeActive) {
+ Serial.println("[LIVE] Button wakeup: entering active mode");
+ } else {
+ Serial.println(firstInstallLivePending
+ ? "[LIVE] First install: default to active mode"
+ : "[LIVE] Always active config enabled");
+ }
} else {
postRuntimeMode("interval");
if (focusListening) {
@@ -1101,6 +1133,14 @@ void setup() {
ctx.state = DeviceState::DISPLAYING;
ctx.setupDoneAt = millis();
+ if (!ctx.liveMode) {
+#if DEBUG_MODE
+ Serial.printf("[DEBUG] Boot complete, entering deep sleep for %d min\n", DEBUG_REFRESH_MIN);
+#else
+ Serial.printf("Boot complete, entering deep sleep for %d min\n", cfgSleepMin);
+#endif
+ enterDeepSleep(effectiveSleepMinutes());
+ }
#if DEBUG_MODE
Serial.printf("[DEBUG] Staying awake, refresh every %d min (user config: %d min)\n",
DEBUG_REFRESH_MIN, cfgSleepMin);
@@ -1134,6 +1174,8 @@ void loop() {
postRuntimeMode("interval");
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
+ delay(500);
+ enterDeepSleep(effectiveSleepMinutes());
} else {
ctx.liveMode = true;
ctx.lastLivePollAt = 0;
@@ -1240,31 +1282,10 @@ void loop() {
ledFeedback("fail");
}
}
- } else if (ctx.wantSingleVoiceTurn) {
+ } else if (ctx.wantEnterAiChatMode) {
#else
- } else if (ctx.wantSingleVoiceTurn) {
-#endif
- ctx.wantSingleVoiceTurn = false;
- ctx.switchToModeId = "";
- Serial.println("[VOICE] Short press -> single voice turn");
- if (WiFi.status() != WL_CONNECTED && !connectWiFi()) {
- Serial.println("[VOICE] WiFi reconnect failed, skip");
- } else {
- runSingleVoiceTurn();
- ctx.btnPressStart = 0;
- ctx.ignoreConfigButtonUntilRelease = (digitalRead(PIN_CFG_BTN) == LOW);
- if (ctx.switchToModeId.length() > 0) {
- Serial.printf("[VOICE] Mode switch to %s, refreshing immediately\n",
- ctx.switchToModeId.c_str());
- g_suppressAbortCheck = true;
- triggerImmediateRefresh(false, true);
- g_suppressAbortCheck = false;
- WiFi.disconnect(true);
- WiFi.mode(WIFI_OFF);
- ctx.setupDoneAt = millis();
- }
- }
} else if (ctx.wantEnterAiChatMode) {
+#endif
ctx.wantEnterAiChatMode = false;
ctx.switchToModeId = "";
Serial.println("[AI CHAT] Dedicated switch long press -> enter conversation mode");
@@ -1319,6 +1340,9 @@ void loop() {
triggerImmediateRefresh();
ctx.wantRefresh = false;
ctx.setupDoneAt = millis();
+ if (!ctx.liveMode) {
+ enterDeepSleep(effectiveSleepMinutes());
+ }
}
handleLiveMode();
@@ -1353,6 +1377,7 @@ void loop() {
#endif
triggerImmediateRefresh();
ctx.setupDoneAt = millis();
+ enterDeepSleep(effectiveSleepMinutes());
}
}
@@ -1414,14 +1439,28 @@ void loop() {
// ── Deep sleep ──────────────────────────────────────────────
static void enterDeepSleep(int minutes) {
- if (focusListening) {
- Serial.println("[FOCUS] Focus listening enabled, skipping deep sleep");
+ if (focusListening || alwaysActive) {
+ Serial.println(focusListening
+ ? "[FOCUS] Focus listening enabled, skipping deep sleep"
+ : "[LIVE] Always active enabled, skipping deep sleep");
return;
}
+ ctx.state = DeviceState::SLEEPING;
+ WiFi.disconnect(true);
+ WiFi.mode(WIFI_OFF);
epdSleep();
Serial.printf("Deep sleep for %d min (~%duA)\n", minutes, 5);
Serial.flush();
esp_sleep_enable_timer_wakeup((uint64_t)minutes * 60ULL * 1000000ULL);
+#if PIN_CFG_BTN >= 0
+ const uint64_t wakeMask = 1ULL << PIN_CFG_BTN;
+#if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32S2 || CONFIG_IDF_TARGET_ESP32H2 || CONFIG_IDF_TARGET_ESP32C6
+ esp_deep_sleep_enable_gpio_wakeup(wakeMask, ESP_GPIO_WAKEUP_GPIO_LOW);
+#else
+ esp_sleep_enable_ext1_wakeup(wakeMask, ESP_EXT1_WAKEUP_ALL_LOW);
+#endif
+ Serial.printf("[WAKE] Timer + GPIO%d button wake enabled\n", PIN_CFG_BTN);
+#endif
esp_deep_sleep_start();
}
@@ -1454,11 +1493,10 @@ static void handleFailure(const char *reason) {
updateTimeDisplay();
lastRenderedPeriod = currentPeriodIndex();
ctx.lastClockTick = millis();
- WiFi.disconnect(true);
- WiFi.mode(WIFI_OFF);
ctx.state = DeviceState::DISPLAYING;
ctx.setupDoneAt = millis();
resetRetryCount();
+ enterDeepSleep(effectiveSleepMinutes());
return;
}
@@ -1473,14 +1511,7 @@ static void handleFailure(const char *reason) {
} else {
Serial.println("Max retries reached, entering deep sleep");
resetRetryCount();
- if (focusListening) {
- Serial.println("[FOCUS] Focus listening enabled, not entering deep sleep");
- ctx.state = DeviceState::DISPLAYING;
- ctx.setupDoneAt = millis();
- return;
- }
- esp_sleep_enable_timer_wakeup((uint64_t)cfgSleepMin * 60ULL * 1000000ULL);
- esp_deep_sleep_start();
+ enterDeepSleep(effectiveSleepMinutes());
}
}
@@ -1540,8 +1571,18 @@ static void handleLiveMode() {
bool shouldExitLive = false;
if (hasPendingRemoteAction(&shouldExitLive)) {
Serial.println("[LIVE] Pending action detected, refreshing now");
+ bool wasAlwaysActive = alwaysActive;
+ refreshActivityFlags();
triggerImmediateRefresh(false, true);
ctx.setupDoneAt = millis();
+ if ((shouldExitLive || (wasAlwaysActive && !alwaysActive)) && !focusListening) {
+ ctx.liveMode = false;
+ postRuntimeMode("interval");
+ Serial.println(shouldExitLive
+ ? "[LIVE] Backend requested interval mode after refresh"
+ : "[LIVE] Always active disabled, entering interval deep sleep");
+ enterDeepSleep(effectiveSleepMinutes());
+ }
return;
}
if (shouldExitLive) {
@@ -1550,6 +1591,7 @@ static void handleLiveMode() {
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
Serial.println("[LIVE] Backend requested interval mode");
+ enterDeepSleep(effectiveSleepMinutes());
return;
}
@@ -1583,162 +1625,6 @@ static bool decodeVoiceBmpToFrameBuffer(const uint8_t *bmpBytes, size_t bmpLen)
return true;
}
-// ── Single voice turn (short-press, one Q&A) ────────────────
-
-static void runSingleVoiceTurn() {
-#if !defined(BOARD_HAS_AUDIO)
- return;
-#else
- showVoiceIndicator(true);
- ledFeedback("ack");
-
- static Inmp441Max98357Codec codec(true);
- if (!codec.Start()) {
- Serial.println("[VOICE] Single turn: codec start failed");
- hideVoiceIndicator();
- return;
- }
-
- static AudioService audioService;
- if (!audioService.Initialize(&codec)) {
- Serial.println("[VOICE] Single turn: AudioService init failed");
- codec.Stop();
- hideVoiceIndicator();
- return;
- }
-
- if (!voiceWsOpen(SAMPLE_RATE, W, H, false)) {
- Serial.println("[VOICE] Single turn: WS open failed");
- audioService.Stop();
- codec.Stop();
- hideVoiceIndicator();
- return;
- }
-
- unsigned long readyStartAt = millis();
- bool sessionReady = false;
- while (millis() - readyStartAt < 6000) {
- voiceWsLoop();
- VoiceWsEvent event;
- while (voiceWsPollEvent(event)) {
- if (event.type == VoiceWsEventType::SessionReady) {
- sessionReady = true;
- }
- voiceWsReleaseEvent(event);
- }
- if (sessionReady) break;
- delay(10);
- }
-
- if (!sessionReady) {
- Serial.println("[VOICE] Single turn: session ready timeout");
- voiceWsClose();
- audioService.Stop();
- codec.Stop();
- hideVoiceIndicator();
- return;
- }
-
- audioService.Start();
- audioService.FlushCaptureQueue();
- Serial.println("[VOICE] Single turn: listening...");
-
- bool speechDetected = false;
- bool committed = false;
- unsigned long lastVoiceAt = 0;
-
- while (voiceWsConnected() && !committed) {
- voiceWsLoop();
- drainSendQueue(audioService);
-
- AsCaptureChunk *cap = nullptr;
- while (audioService.PollCaptureChunk(cap)) {
- if (cap->sampleCount > 0) {
- audioNoiseGateApply(cap->samples, cap->sampleCount, 80.0f);
- float rms = audioCalculateRMS(cap->samples, cap->sampleCount);
- if (!speechDetected && rms < VOICE_STREAM_VAD_THRESHOLD) {
- audioAdaptiveNoiseFloor(rms);
- }
- float noiseFloor = audioAdaptiveNoiseFloor(-1.0f);
- float effectiveThreshold = max(VOICE_STREAM_VAD_THRESHOLD, noiseFloor * 3.0f);
-
- if (rms >= effectiveThreshold) {
- speechDetected = true;
- lastVoiceAt = millis();
- }
- if (speechDetected) {
- audioService.PushForEncoding(cap->samples, cap->sampleCount);
- }
- bool silenceTimeout = speechDetected && lastVoiceAt > 0 &&
- (millis() - lastVoiceAt >= (unsigned long)VOICE_SILENCE_COMMIT_MS);
- bool maxDuration = speechDetected &&
- (millis() - lastVoiceAt >= VOICE_MAX_CAPTURE_MS + VOICE_SILENCE_COMMIT_MS);
- if (silenceTimeout || maxDuration) {
- drainSendQueue(audioService);
- voiceWsCommitTurn();
- committed = true;
- }
- }
- audioService.ReleaseCaptureChunk(cap);
- }
- delay(5);
- }
-
- if (!committed) {
- Serial.println("[VOICE] Single turn: no speech or WS dropped");
- audioService.Stop();
- voiceWsClose();
- codec.Stop();
- hideVoiceIndicator();
- return;
- }
-
- Serial.println("[VOICE] Single turn: waiting for response...");
- unsigned long waitStart = millis();
- bool gotDone = false;
- while (voiceWsConnected() && millis() - waitStart < 15000) {
- voiceWsLoop();
- drainSendQueue(audioService);
-
- VoiceWsEvent event;
- while (voiceWsPollEvent(event)) {
- if (event.type == VoiceWsEventType::TtsAudioChunk) {
- if (event.needsDecode) {
- audioService.PushForDecoding(event.data, event.dataLen, event.generationId);
- } else {
- audioService.PushPcmForPlayback(event.data, event.dataLen, event.generationId);
- }
- } else if (event.type == VoiceWsEventType::TurnDone) {
- gotDone = true;
- if (event.switchToMode.length() > 0) {
- ctx.switchToModeId = event.switchToMode;
- Serial.printf("[VOICE] Single turn: mode switch -> %s\n", event.switchToMode.c_str());
- }
- voiceWsReleaseEvent(event);
- break;
- }
- voiceWsReleaseEvent(event);
- }
- if (gotDone) break;
- delay(10);
- }
-
- unsigned long drainStart = millis();
- while (!audioService.IsPlaybackEmpty() && millis() - drainStart < 8000) {
- voiceWsLoop();
- delay(20);
- }
- delay(100);
-
- audioService.Stop();
- audioService.ResetPlayback();
- voiceWsClose();
- codec.Stop();
- hideVoiceIndicator();
- Serial.println("[VOICE] Single turn: done");
-#endif
-}
-
// ── AI Chat conversation (display build, full-duplex) ───────
static bool runAiChatConversation() {
@@ -1772,6 +1658,10 @@ static bool runAiChatConversation() {
VoiceTurnPerf turnPerf;
unsigned long lastRmsLogAt = 0;
float peakRms = 0;
+#if PIN_AI_CHAT_SW >= 0
+ bool exitButtonIgnoreUntilRelease = (digitalRead(PIN_AI_CHAT_SW) == LOW);
+ unsigned long exitButtonPressStart = 0;
+#endif
showVoiceChatScreen();
@@ -1812,6 +1702,32 @@ static bool runAiChatConversation() {
voiceWsLoop();
drainSendQueue(audioService);
+#if PIN_AI_CHAT_SW >= 0
+ bool exitButtonPressed = (digitalRead(PIN_AI_CHAT_SW) == LOW);
+ if (exitButtonIgnoreUntilRelease) {
+ if (!exitButtonPressed) {
+ exitButtonIgnoreUntilRelease = false;
+ }
+ exitButtonPressStart = 0;
+ } else if (exitButtonPressed) {
+ if (exitButtonPressStart == 0) {
+ exitButtonPressStart = millis();
+ } else if (millis() - exitButtonPressStart >= (unsigned long)AI_CHAT_BTN_HOLD_MS) {
+ Serial.printf("[AI CHAT] Switch held for %dms, exit conversation\n", AI_CHAT_BTN_HOLD_MS);
+ audioService.ResetPlayback();
+ voiceWsInterrupt();
+ audioService.Stop();
+ voiceWsClose();
+ codec.Stop();
+ ctx.aiBtnPressStart = 0;
+ ctx.ignoreAiButtonUntilRelease = true;
+ return true;
+ }
+ } else {
+ exitButtonPressStart = 0;
+ }
+#endif
+
AsCaptureChunk *captureChunk = nullptr;
while (audioService.PollCaptureChunk(captureChunk)) {
if (!waitingForTurnDone && captureChunk->sampleCount > 0) {
@@ -1895,6 +1811,7 @@ static bool runAiChatConversation() {
Serial.printf("[VOICE_PERF][DEVICE] turn=%d interrupted_after_commit_ms=%lu\n", turnPerf.turnIndex, voicePerfSince(turnPerf.commitAt));
}
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
audioService.SetGenerationId(audioService.GetGenerationId() + 1);
resetVoiceTurnPerf(turnPerf);
}
@@ -1935,6 +1852,9 @@ static bool runAiChatConversation() {
);
}
} else if (event.type == VoiceWsEventType::TtsAudioChunk) {
+ waitingForTurnDone = true;
+ speechDetected = false;
+ lastVoiceAt = 0;
turnPerf.recvAudioChunks++;
turnPerf.recvAudioBytes += event.dataLen;
if (turnPerf.firstTtsChunkAt == 0) {
@@ -1962,8 +1882,8 @@ static bool runAiChatConversation() {
Serial.printf("[VOICE_PERF][DEVICE] turn=%d turn_interrupted_after_commit_ms=%lu\n", turnPerf.turnIndex, voicePerfSince(turnPerf.commitAt));
}
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
} else if (event.type == VoiceWsEventType::TurnDone) {
- waitingForTurnDone = false;
speechDetected = false;
lastVoiceAt = 0;
exitConversation = event.exitConversation;
@@ -1978,8 +1898,10 @@ static bool runAiChatConversation() {
voiceWsLoop();
delay(20);
}
- delay(120);
+ delay(VOICE_PLAYBACK_TAIL_CLEAR_MS);
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
+ waitingForTurnDone = false;
Serial.printf(
"[VOICE_PERF][DEVICE] turn=%d done total_ms=%lu commit_to_done_ms=%lu recv_audio_chunks=%u recv_audio_bytes=%u exit=%s\n",
turnPerf.turnIndex,
@@ -2005,6 +1927,7 @@ static bool runAiChatConversation() {
speechDetected = false;
lastVoiceAt = 0;
audioService.ResetPlayback();
+ audioService.FlushCaptureQueue();
resetVoiceTurnPerf(turnPerf);
}
voiceWsReleaseEvent(event);
@@ -2325,8 +2248,7 @@ static void checkAiChatButton() {
}
}
#else
- Serial.printf("[VOICE] Short press %lums, queue single voice turn\n", duration);
- ctx.wantSingleVoiceTurn = true;
+ // Short press is intentionally unused; long press enters AI chat.
#endif
}
}
diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp
index 437de06..d624fcf 100644
--- a/firmware/src/network.cpp
+++ b/firmware/src/network.cpp
@@ -11,7 +11,7 @@
#include
#include
#include
-#if defined(BOARD_PROFILE_ESP32_C3_WROOM02)
+#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E)
#include
#endif
@@ -196,7 +196,7 @@ float readBatteryVoltage() {
sum += readings[i];
float avgRaw = (float)sum / (SAMPLES - 2 * DISCARD);
-#if defined(BOARD_PROFILE_ESP32_C3_WROOM02)
+#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E)
static esp_adc_cal_characteristics_t adcChars;
static bool calibrated = false;
if (!calibrated) {
@@ -206,22 +206,12 @@ float readBatteryVoltage() {
uint32_t mv = esp_adc_cal_raw_to_voltage((uint32_t)avgRaw, &adcChars);
float realBatteryVoltage = (mv / 1000.0f) * 2.0f; // R1=10k, R2=10k
-
- const float measuredLow = 2.95f;
- const float measuredHigh = 4.17f;
- const float targetLow = 0.0f;
- const float targetHigh = 3.3f;
-
- if (realBatteryVoltage <= measuredLow) return targetLow;
- if (realBatteryVoltage >= measuredHigh) return targetHigh;
-
- float mappedVoltage = targetLow + (realBatteryVoltage - measuredLow) *
- (targetHigh - targetLow) / (measuredHigh - measuredLow);
- if (mappedVoltage > targetHigh) mappedVoltage = targetHigh;
- if (mappedVoltage < targetLow) mappedVoltage = targetLow;
- return mappedVoltage;
+ Serial.printf("[BAT] raw=%.1f adc=%umV vbat=%.2fV\n", avgRaw, (unsigned int)mv, realBatteryVoltage);
+ return realBatteryVoltage;
#else
- return avgRaw * (3.3f / 4095.0f) * 2.0f;
+ float realBatteryVoltage = avgRaw * (3.3f / 4095.0f) * 2.0f;
+ Serial.printf("[BAT] raw=%.1f vbat=%.2fV\n", avgRaw, realBatteryVoltage);
+ return realBatteryVoltage;
#endif
}
@@ -489,9 +479,10 @@ bool ensureDeviceToken() {
return false;
}
-bool fetchFocusListeningFlag(bool *outEnabled) {
+bool fetchFocusListeningFlag(bool *outEnabled, bool *outAlwaysActive) {
if (!outEnabled) return false;
*outEnabled = false;
+ if (outAlwaysActive) *outAlwaysActive = false;
if (WiFi.status() != WL_CONNECTED) return false;
if (!ensureDeviceToken()) return false;
@@ -528,8 +519,18 @@ bool fetchFocusListeningFlag(bool *outEnabled) {
body.indexOf("\"is_focus_listening\": true") >= 0 ||
body.indexOf("\"focus_listening\":1") >= 0 ||
body.indexOf("\"focus_listening\": 1") >= 0;
+ bool alwaysActive =
+ body.indexOf("\"is_always_active\":true") >= 0 ||
+ body.indexOf("\"is_always_active\": true") >= 0 ||
+ body.indexOf("\"always_active\":1") >= 0 ||
+ body.indexOf("\"always_active\": 1") >= 0 ||
+ body.indexOf("\"always_active\":true") >= 0 ||
+ body.indexOf("\"always_active\": true") >= 0;
*outEnabled = enabled;
- Serial.printf("[FOCUS] is_focus_listening=%s\n", enabled ? "true" : "false");
+ if (outAlwaysActive) *outAlwaysActive = alwaysActive;
+ Serial.printf("[CONFIG] is_focus_listening=%s always_active=%s\n",
+ enabled ? "true" : "false",
+ alwaysActive ? "true" : "false");
return true;
}
return false;
diff --git a/firmware/src/network.h b/firmware/src/network.h
index 4adc20b..11e2a7c 100644
--- a/firmware/src/network.h
+++ b/firmware/src/network.h
@@ -90,7 +90,7 @@ bool ensureDeviceToken();
bool postHeartbeat(bool force = false);
// ── Focus listening helpers ─────────────────────────────────
-bool fetchFocusListeningFlag(bool *outEnabled);
+bool fetchFocusListeningFlag(bool *outEnabled, bool *outAlwaysActive = nullptr);
bool fetchFocusAlertBMP();
// ── Battery ─────────────────────────────────────────────────
diff --git a/firmware/src/portal.cpp b/firmware/src/portal.cpp
index 6c30918..e2e0ba5 100644
--- a/firmware/src/portal.cpp
+++ b/firmware/src/portal.cpp
@@ -29,6 +29,8 @@ static const int PORTAL_MAX_PASS = 64;
static const int PORTAL_MAX_URL = 200;
static const int PORTAL_MAX_CONFIG = 2048;
+static String generatePairCode();
+
static String sanitizeInput(const String &input, int maxLen) {
String result = input.substring(0, maxLen);
result.trim();
@@ -97,6 +99,71 @@ static String buildWiFiListJson() {
return json;
}
+static bool findSavedWiFiPassword(const String &targetSsid, String &passOut) {
+ int count = getWiFiCount();
+ for (int i = 0; i < count; i++) {
+ String savedSsid, savedPass;
+ if (!getWiFiAt(i, savedSsid, savedPass)) continue;
+ if (savedSsid == targetSsid) {
+ passOut = savedPass;
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool connectPortalWiFi(const String &ssid, const String &pass) {
+ Serial.printf("Portal: connecting to %s\n", ssid.c_str());
+ wifiConnecting = true;
+ lastWifiError = "";
+
+ WiFi.mode(WIFI_AP_STA);
+ WiFi.begin(ssid.c_str(), pass.c_str());
+
+ unsigned long t0 = millis();
+ while (WiFi.status() != WL_CONNECTED && millis() - t0 < (unsigned long)WIFI_TIMEOUT) {
+ delay(300);
+ Serial.print(".");
+ }
+ Serial.println();
+
+ wifiConnecting = false;
+
+ if (WiFi.status() == WL_CONNECTED) {
+ wifiConnected = true;
+ lastWifiError = "";
+ Serial.printf("WiFi OK IP=%s\n", WiFi.localIP().toString().c_str());
+ return true;
+ }
+
+ uint8_t reason = WiFi.status();
+ Serial.printf("WiFi connection failed, status code: %d\n", reason);
+ if (reason == WL_NO_SSID_AVAIL) {
+ lastWifiError = "NO_SSID";
+ } else if (reason == WL_CONNECT_FAILED) {
+ lastWifiError = "AUTH_FAIL";
+ } else {
+ lastWifiError = "TIMEOUT";
+ }
+ WiFi.disconnect();
+ WiFi.mode(WIFI_AP_STA);
+ return false;
+}
+
+static void sendPortalConnectSuccess() {
+ String pairCode = generatePairCode();
+ savePendingPairCode(pairCode);
+ Serial.printf("[PAIR] local pair code: %s\n", pairCode.c_str());
+ String response = String("{\"ok\":true,\"pair_code\":\"") + pairCode +
+ "\",\"list\":" + buildWiFiListJson() + "}";
+ Serial.printf("Sending response: %s\n", response.c_str());
+ webServer.send(200, "application/json", response);
+
+ pendingRestart = true;
+ restartAtMillis = millis() + 15000;
+ Serial.println("Restart scheduled in 15s (or earlier via /restart)");
+}
+
static String generatePairCode() {
char buf[7];
snprintf(buf, sizeof(buf), "%06u", (unsigned)(esp_random() % 1000000));
@@ -241,49 +308,10 @@ void startCaptivePortal() {
Serial.printf("Server URL saved: %s\n", serverUrl.c_str());
}
- Serial.printf("Portal: connecting to %s\n", ssid.c_str());
- wifiConnecting = true;
- lastWifiError = "";
-
- WiFi.mode(WIFI_AP_STA);
- WiFi.begin(ssid.c_str(), pass.c_str());
-
- unsigned long t0 = millis();
- while (WiFi.status() != WL_CONNECTED && millis() - t0 < (unsigned long)WIFI_TIMEOUT) {
- delay(300);
- Serial.print(".");
- }
- Serial.println();
-
- wifiConnecting = false;
-
- if (WiFi.status() == WL_CONNECTED) {
+ if (connectPortalWiFi(ssid, pass)) {
saveWiFiConfig(ssid, pass);
- wifiConnected = true;
- lastWifiError = "";
- Serial.printf("WiFi OK IP=%s\n", WiFi.localIP().toString().c_str());
- String pairCode = generatePairCode();
- savePendingPairCode(pairCode);
- Serial.printf("[PAIR] local pair code: %s\n", pairCode.c_str());
- String response = String("{\"ok\":true,\"pair_code\":\"") + pairCode + "\"}";
- Serial.printf("Sending response: %s\n", response.c_str());
- webServer.send(200, "application/json", response);
-
- pendingRestart = true;
- restartAtMillis = millis() + 15000;
- Serial.println("Restart scheduled in 15s (or earlier via /restart)");
+ sendPortalConnectSuccess();
} else {
- uint8_t reason = WiFi.status();
- Serial.printf("WiFi connection failed, status code: %d\n", reason);
- if (reason == WL_NO_SSID_AVAIL) {
- lastWifiError = "NO_SSID";
- } else if (reason == WL_CONNECT_FAILED) {
- lastWifiError = "AUTH_FAIL";
- } else {
- lastWifiError = "TIMEOUT";
- }
- WiFi.disconnect();
- WiFi.mode(WIFI_AP_STA);
String msg;
if (lastWifiError == "NO_SSID") msg = "找不到该网络";
else if (lastWifiError == "AUTH_FAIL") msg = "密码错误";
@@ -295,6 +323,52 @@ void startCaptivePortal() {
}
});
+ // ── Route: Connect using a saved network password ───────
+ webServer.on("/connect_saved", HTTP_POST, []() {
+ String ssid = sanitizeSSID(webServer.arg("ssid"));
+ String serverUrl = sanitizeInput(webServer.arg("server"), PORTAL_MAX_URL);
+ webServer.sendHeader("Access-Control-Allow-Origin", "*");
+
+ Serial.printf("\n--- /connect_saved Request ---\n");
+ Serial.printf("SSID: %s\n", ssid.c_str());
+ Serial.printf("Server: %s\n", serverUrl.c_str());
+
+ if (ssid.length() == 0) {
+ webServer.send(200, "application/json", "{\"ok\":false,\"msg\":\"SSID empty\"}");
+ return;
+ }
+
+ String pass;
+ if (!findSavedWiFiPassword(ssid, pass)) {
+ webServer.send(200, "application/json",
+ "{\"ok\":false,\"msg\":\"NOT_FOUND\",\"list\":" + buildWiFiListJson() + "}");
+ return;
+ }
+
+ if (serverUrl.length() > 0) {
+ if (!isValidUrl(serverUrl)) {
+ webServer.send(200, "application/json",
+ "{\"ok\":false,\"msg\":\"服务器地址必须以 http:// 或 https:// 开头\"}");
+ return;
+ }
+ while (serverUrl.endsWith("/")) {
+ serverUrl = serverUrl.substring(0, serverUrl.length() - 1);
+ }
+ saveServerUrl(serverUrl);
+ Serial.printf("Server URL saved: %s\n", serverUrl.c_str());
+ }
+
+ if (connectPortalWiFi(ssid, pass)) {
+ // Re-saving an existing SSID promotes it to slot 0.
+ addWiFiConfig(ssid, pass);
+ sendPortalConnectSuccess();
+ } else {
+ Serial.println("[PORTAL] Saved network connect failed");
+ webServer.send(200, "application/json",
+ "{\"ok\":false,\"msg\":\"SAVED_CONNECT_FAILED\",\"list\":" + buildWiFiListJson() + "}");
+ }
+ });
+
// ── Route: Save user config ─────────────────────────────
webServer.on("/save_config", HTTP_POST, []() {
String config = sanitizeInput(webServer.arg("config"), PORTAL_MAX_CONFIG);
diff --git a/webapp/app/config/page.test.ts b/webapp/app/config/page.test.ts
index d920aaf..fce2e08 100644
--- a/webapp/app/config/page.test.ts
+++ b/webapp/app/config/page.test.ts
@@ -1,7 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { queueImmediateRefreshIfOnline } from "@/lib/device-utils";
+import { calculateBatteryPct, queueImmediateRefreshIfOnline } from "@/lib/device-utils";
+
+test("calculateBatteryPct uses the lithium battery voltage curve", () => {
+ assert.equal(calculateBatteryPct(null), null);
+ assert.equal(calculateBatteryPct(Number.NaN), null);
+ assert.equal(calculateBatteryPct(2.9), 0);
+ assert.equal(calculateBatteryPct(3.0), 0);
+ assert.equal(calculateBatteryPct(3.35), 25);
+ assert.equal(calculateBatteryPct(3.7), 50);
+ assert.equal(calculateBatteryPct(3.85), 65);
+ assert.equal(calculateBatteryPct(4.0), 80);
+ assert.equal(calculateBatteryPct(4.2), 100);
+ assert.equal(calculateBatteryPct(4.3), 100);
+});
test("queueImmediateRefreshIfOnline triggers refresh for online device", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
@@ -69,6 +82,36 @@ test("queueImmediateRefreshIfOnline skips refresh for offline device", async ()
);
});
+test("queueImmediateRefreshIfOnline skips refresh for interval runtime mode", async () => {
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
+ const fetchImpl = async (url: string, init?: RequestInit) => {
+ calls.push({ url, init });
+ if (url === "/api/device/AA%3ABB%3ACC%3ADD%3AEE%3AFF/state") {
+ return {
+ ok: true,
+ json: async () => ({
+ is_online: true,
+ last_seen: "2026-03-16T10:00:00",
+ runtime_mode: "interval",
+ }),
+ } as Response;
+ }
+ throw new Error(`unexpected url: ${url}`);
+ };
+
+ const result = await queueImmediateRefreshIfOnline(fetchImpl, "AA:BB:CC:DD:EE:FF", {
+ Authorization: "Bearer test",
+ });
+
+ assert.equal(result.onlineNow, false);
+ assert.equal(result.refreshQueued, false);
+ assert.equal(result.lastSeen, "2026-03-16T10:00:00");
+ assert.deepEqual(
+ calls.map((call) => call.url),
+ ["/api/device/AA%3ABB%3ACC%3ADD%3AEE%3AFF/state"],
+ );
+});
+
test("queueImmediateRefreshIfOnline reports refresh failure for online device", async () => {
const fetchImpl = async (url: string) => {
if (url === "/api/device/AA%3ABB%3ACC%3ADD%3AEE%3AFF/state") {
diff --git a/webapp/app/config/page.tsx b/webapp/app/config/page.tsx
index bb3b400..6149088 100644
--- a/webapp/app/config/page.tsx
+++ b/webapp/app/config/page.tsx
@@ -100,7 +100,7 @@ const TONE_OPTIONS = [
] as const;
const PERSONA_PRESETS = ["鲁迅", "王小波", "JARVIS", "苏格拉底", "村上春树"] as const;
-import { queueImmediateRefreshIfOnline } from "@/lib/device-utils";
+import { calculateBatteryPct, queueImmediateRefreshIfOnline } from "@/lib/device-utils";
function normalizeTone(v: unknown): string {
if (typeof v !== "string") return "neutral";
@@ -1382,9 +1382,9 @@ function ConfigPageInner() {
? tr("配置已保存,暂时无法确认设备状态", "Settings saved, but device status is currently unavailable")
: onlineNow
? (refreshQueued
- ? tr("配置已保存,已通知设备立即刷新", "Settings saved, device notified to refresh now")
- : tr("配置已保存,设备在线,但立即刷新通知失败", "Settings saved, device is online, but immediate refresh notification failed"))
- : tr("配置已保存,设备当前离线,将在设备上线后生效", "Settings saved. Device is offline and will update when it comes online"),
+ ? tr("配置已保存,已通知在线设备尽快同步", "Settings saved, online device notified to sync soon")
+ : tr("配置已保存,设备在线,但同步通知失败", "Settings saved, device is online, but sync notification failed"))
+ : tr("配置已保存,设备当前不在活跃同步状态,将在设备下次上线后生效", "Settings saved. Device is not actively syncing and will update when it comes online"),
syncResult.onlineNow === null || !refreshQueued ? "info" : "success",
);
setPreviewNoCacheOnce(true);
@@ -2402,9 +2402,7 @@ function ConfigPageInner() {
);
const activeModeSchema = settingsMode ? (modeSchemaMap[settingsMode] || []) : [];
- const batteryPct = stats?.last_battery_voltage
- ? Math.min(100, Math.max(0, Math.round((stats.last_battery_voltage / 3.3) * 100)))
- : null;
+ const batteryPct = calculateBatteryPct(stats?.last_battery_voltage);
const currentDeviceMembership = userDevices.find((d) => d.mac.toUpperCase() === mac.toUpperCase()) || null;
const denyByMembership = Boolean(mac && currentUser && !devicesLoading && !currentDeviceMembership);
const currentUserRole = currentDeviceMembership?.role || "";
diff --git a/webapp/lib/device-utils.ts b/webapp/lib/device-utils.ts
index 7251597..e09a038 100644
--- a/webapp/lib/device-utils.ts
+++ b/webapp/lib/device-utils.ts
@@ -1,5 +1,18 @@
export type FetchLike = (input: string, init?: RequestInit) => Promise;
+export function calculateBatteryPct(voltage: number | null | undefined): number | null {
+ if (typeof voltage !== "number" || !Number.isFinite(voltage)) return null;
+
+ const fullVoltage = 4.2;
+ const highVoltage = 3.7;
+ const lowVoltage = 3.0;
+ const pct = voltage >= highVoltage
+ ? ((voltage - highVoltage) / (fullVoltage - highVoltage)) * 50 + 50
+ : ((voltage - lowVoltage) / (highVoltage - lowVoltage)) * 50;
+
+ return Math.min(100, Math.max(0, Math.round(pct)));
+}
+
export async function queueImmediateRefreshIfOnline(
fetchImpl: FetchLike,
mac: string,
@@ -16,18 +29,19 @@ export async function queueImmediateRefreshIfOnline(
const stateData = await stateRes.json();
const onlineNow = Boolean(stateData?.is_online);
const lastSeen = typeof stateData?.last_seen === "string" && stateData.last_seen ? stateData.last_seen : null;
+ const runtimeMode = typeof stateData?.runtime_mode === "string" ? stateData.runtime_mode.toLowerCase() : "";
- if (!onlineNow) {
+ if (!onlineNow || runtimeMode === "interval") {
return { onlineNow: false, lastSeen, refreshQueued: false };
}
try {
- await fetchImpl(`/api/device/${encodeURIComponent(mac)}/refresh`, {
+ const refreshRes = await fetchImpl(`/api/device/${encodeURIComponent(mac)}/refresh`, {
cache: "no-store",
method: "POST",
headers,
});
- return { onlineNow: true, lastSeen, refreshQueued: true };
+ return { onlineNow: true, lastSeen, refreshQueued: refreshRes.ok };
} catch {
return { onlineNow: true, lastSeen, refreshQueued: false };
}