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=' 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 }; }