diff --git a/backend/api/shared.py b/backend/api/shared.py index 9f4c2a8f..f735fed9 100644 --- a/backend/api/shared.py +++ b/backend/api/shared.py @@ -1096,6 +1096,8 @@ def build_firmware_manifest(version: str, download_url: str, chip_family: str = def chip_family_from_asset_name(asset_name: str) -> str: name = (asset_name or "").lower() + if "_s3" in name or "esp32s3" in name or "esp32-s3" in name: + return "ESP32-S3" if "wroom32e" in name or "_esp32" in name: return "ESP32" if "_c3" in name or "esp32c3" in name: diff --git a/backend/tests/test_firmware_api.py b/backend/tests/test_firmware_api.py index 00575a6b..d0f8adbd 100644 --- a/backend/tests/test_firmware_api.py +++ b/backend/tests/test_firmware_api.py @@ -161,16 +161,23 @@ def test_expand_release_assets_returns_multiple_bin_entries(): "size": 1140784, "browser_download_url": "https://example.com/epd_42_wroom32e.bin", }, + { + "name": "epd_42_yd_s3_n16r8.bin", + "size": 1150000, + "browser_download_url": "https://example.com/epd_42_yd_s3_n16r8.bin", + }, ], } items = shared_api.expand_firmware_release_assets(release) - assert len(items) == 2 + assert len(items) == 3 assert items[0]["asset_name"] == "epd_42_c3.bin" assert items[0]["chip_family"] == "ESP32-C3" assert items[1]["asset_name"] == "epd_42_wroom32e.bin" assert items[1]["chip_family"] == "ESP32" + assert items[2]["asset_name"] == "epd_42_yd_s3_n16r8.bin" + assert items[2]["chip_family"] == "ESP32-S3" def test_render_api_key_invalid_image_uses_project_font_loader(monkeypatch): diff --git a/firmware/merge_firmware.py b/firmware/merge_firmware.py index a34184df..9dee166b 100644 --- a/firmware/merge_firmware.py +++ b/firmware/merge_firmware.py @@ -5,7 +5,7 @@ def merge_bin(source, target, env): firmware = str(target[0]) board = env.BoardConfig() mcu = board.get("build.mcu", "esp32") - flash_mode = "dio" + flash_mode = env.GetProjectOption("board_build.flash_mode", "dio") flash_freq = board.get("build.f_flash", "40000000L").replace("L", "") flash_freq_m = str(int(int(flash_freq) / 1000000)) + "m" flash_size = board.get("upload.flash_size", "4MB") diff --git a/firmware/partitions/inksight_16mb_ota.csv b/firmware/partitions/inksight_16mb_ota.csv new file mode 100644 index 00000000..1fa51dbd --- /dev/null +++ b/firmware/partitions/inksight_16mb_ota.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +app0, app, ota_0, 0x10000, 0x500000, +app1, app, ota_1, 0x510000, 0x500000, +spiffs, data, spiffs, 0xa10000, 0x5f0000, diff --git a/firmware/platformio.ini b/firmware/platformio.ini index f932a946..371e2f79 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -53,6 +53,27 @@ build_flags = -DEPD_PANEL_42_SSD1683_BW -DALLOW_INSECURE_FALLBACK=0 +[env:epd_42_wsv2_ssd1683_yd_s3_n16r8] +extends = common +board = esp32-s3-devkitc-1 +upload_speed = 460800 +board_build.flash_mode = qio +board_build.f_flash = 80000000L +board_build.arduino.memory_type = qio_opi +board_build.psram_type = opi +board_build.partitions = partitions/inksight_16mb_ota.csv +board_upload.flash_size = 16MB +board_upload.maximum_size = 16777216 +build_flags = + -DBOARD_PROFILE_YD_ESP32_S3_N16R8 + -DBOARD_HAS_PSRAM + -DARDUINO_USB_MODE=0 + -DARDUINO_USB_CDC_ON_BOOT=0 + -DEPD_WIDTH=400 + -DEPD_HEIGHT=300 + -DEPD_PANEL_42_SSD1683_BW + -DALLOW_INSECURE_FALLBACK=0 + # ── ESP32-WROOM32E + AI Chat (语音对话) ────────────────────── [env:epd_42_wroom32e_ai_chat] extends = common diff --git a/firmware/src/config.h b/firmware/src/config.h index 9285a2c4..cbb6239d 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -62,10 +62,26 @@ #endif #define PIN_LED 5 #define PIN_AI_CHAT_SW -1 +#elif defined(BOARD_PROFILE_YD_ESP32_S3_N16R8) +#define PIN_EPD_MOSI 11 +#define PIN_EPD_SCK 12 +#define PIN_EPD_CS 10 +#define PIN_EPD_DC 9 +#define PIN_EPD_RST 8 +#define PIN_EPD_BUSY 7 +#define PIN_BAT_ADC 4 +#define PIN_CFG_BTN 0 +#define PIN_LED -1 +#define PIN_RGB_LED 48 +#define PIN_AI_CHAT_SW -1 #else #error "Unsupported board profile" #endif +#ifndef PIN_RGB_LED +#define PIN_RGB_LED -1 +#endif + // ── Display constants ──────────────────────────────────────── // Default for 4.2" E-Paper (400x300, 1-bit). // Override via build flags: -D EPD_WIDTH=800 -D EPD_HEIGHT=480 @@ -118,6 +134,9 @@ static const int VOCAB_EXIT_HOLD_MS = 5000; // Long press duration to exit voc static const int SHORT_PRESS_MIN_MS = 50; // Minimum short press duration (debounce) static const int LIVE_POLL_MS = 5000; // Poll interval for pending remote actions static const int LIVE_WIFI_RETRY_MS = 5000; // Retry interval when WiFi is disconnected +static const unsigned long TEMP_ONLINE_WINDOW_MS = 10UL * 60UL * 1000UL; +static const unsigned long PORTAL_AUTO_TIMEOUT_MS = 3UL * 60UL * 1000UL; +static const unsigned long PORTAL_MANUAL_TIMEOUT_MS = 10UL * 60UL * 1000UL; static const unsigned long HEARTBEAT_INTERVAL_MS = 10UL * 60UL * 1000UL; static const int MAX_RETRY_COUNT = 5; // Max retries before deep sleep // WiFi -> captive portal fallback: when ALL saved networks fail to connect, diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 8193ebc0..406c30bf 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -5,11 +5,19 @@ #include #include #include +#include #include +#include +#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED +#include +#endif #include #include #include "config.h" +#if PIN_RGB_LED >= 0 +#include +#endif #if defined(BOARD_HAS_AUDIO) #include "audio.h" #include "audio_codec.h" @@ -90,6 +98,11 @@ enum class WakeupReason : uint8_t { UNKNOWN, }; +enum class PortalEntryReason : uint8_t { + MANUAL, + AUTO_WIFI_FAILURE, +}; + struct DeviceContext { DeviceState state = DeviceState::BOOT; WakeupReason wakeupReason = WakeupReason::POWER_ON; @@ -100,16 +113,18 @@ struct DeviceContext { bool ignoreConfigButtonUntilRelease = false; bool ignoreAiButtonUntilRelease = false; bool liveMode = false; + unsigned long temporaryOnlineUntil = 0; unsigned long lastLivePollAt = 0; unsigned long lastLiveWiFiRetryAt = 0; // Timing unsigned long setupDoneAt = 0; unsigned long lastClockTick = 0; + unsigned long portalStartedAt = 0; + unsigned long portalTimeoutMs = 0; // Pending actions (set by button handler, consumed by loop) bool wantRefresh = false; - bool wantEnterLiveMode = false; bool wantEnterAiChatMode = false; bool wantEnterVocabReview = false; bool wantVocabFlip = false; @@ -153,6 +168,33 @@ static int effectiveSleepMinutes() { #endif } +static bool temporaryOnlineActive(unsigned long now = millis()) { + return ctx.temporaryOnlineUntil != 0 && + (long)(now - ctx.temporaryOnlineUntil) < 0; +} + +static bool temporaryOnlineExpired(unsigned long now = millis()) { + return ctx.temporaryOnlineUntil != 0 && + (long)(now - ctx.temporaryOnlineUntil) >= 0; +} + +static void clearTemporaryOnlineWindow() { + ctx.temporaryOnlineUntil = 0; +} + +static void extendTemporaryOnlineWindow(const char *reason) { + ctx.temporaryOnlineUntil = millis() + TEMP_ONLINE_WINDOW_MS; + ctx.liveMode = true; + ctx.lastLivePollAt = 0; + ctx.lastLiveWiFiRetryAt = 0; + Serial.printf("[LIVE] Temporary online window: %lu min", + TEMP_ONLINE_WINDOW_MS / 60000UL); + if (reason && reason[0]) { + Serial.printf(" (%s)", reason); + } + Serial.println(); +} + static bool refreshActivityFlags() { bool focusFlag = false; bool alwaysActiveFlag = false; @@ -351,19 +393,36 @@ static bool fetchAndDisplayVocabPack() { static void checkConfigButton(); static void triggerImmediateRefresh(bool nextMode = false, bool keepWiFi = false, bool partialVocabRating = false, const uint8_t *partialOldImage = nullptr, bool skipNtp = false); static void handleLiveMode(); +static bool temporaryOnlineActive(unsigned long now); +static bool temporaryOnlineExpired(unsigned long now); +static void clearTemporaryOnlineWindow(); +static void extendTemporaryOnlineWindow(const char *reason); static bool waitForContentReady(); static void handleFailure(const char *reason); -static void enterDeepSleep(int minutes); -static void enterPortalMode(); +static void enterDeepSleep(int minutes, bool force = false); +static void enterPortalMode(PortalEntryReason reason = PortalEntryReason::MANUAL); +static void checkPortalTimeout(); // ── LED feedback ──────────────────────────────────────────── static void ledInit() { +#if PIN_LED >= 0 pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW); +#endif +#if PIN_RGB_LED >= 0 + neopixelWrite(PIN_RGB_LED, 0, 0, 0); +#endif } static void ledFeedback(const char *pattern) { +#if PIN_LED < 0 + (void)pattern; +#if PIN_RGB_LED >= 0 + neopixelWrite(PIN_RGB_LED, 0, 0, 0); +#endif + return; +#else if (strcmp(pattern, "ack") == 0) { for (int i = 0; i < 2; i++) { digitalWrite(PIN_LED, HIGH); delay(80); @@ -393,16 +452,17 @@ static void ledFeedback(const char *pattern) { } else if (strcmp(pattern, "off") == 0) { digitalWrite(PIN_LED, LOW); } +#endif } -static void enterPortalMode() { +static void enterPortalMode(PortalEntryReason reason) { g_userAborted = false; String mac = WiFi.macAddress(); String apName = "InkSight-" + mac.substring(mac.length() - 5); apName.replace(":", ""); ctx.liveMode = false; - ctx.wantEnterLiveMode = false; + clearTemporaryOnlineWindow(); ctx.wantRefresh = false; ctx.btnPressStart = 0; @@ -415,6 +475,13 @@ static void enterPortalMode() { #endif startCaptivePortal(); ctx.state = DeviceState::PORTAL; + ctx.portalStartedAt = millis(); + ctx.portalTimeoutMs = (reason == PortalEntryReason::AUTO_WIFI_FAILURE) + ? PORTAL_AUTO_TIMEOUT_MS + : PORTAL_MANUAL_TIMEOUT_MS; + Serial.printf("[PORTAL] %s portal timeout: %lus\n", + reason == PortalEntryReason::AUTO_WIFI_FAILURE ? "Auto" : "Manual", + ctx.portalTimeoutMs / 1000UL); ctx.ignoreConfigButtonUntilRelease = (digitalRead(PIN_CFG_BTN) == LOW); } // ── Shared voice helpers ──────────────────────────────────── @@ -828,15 +895,15 @@ static void runVoiceLoop(AudioService &audioService) { // ── setup (voice-only) ────────────────────────────────────── void setup() { + ledInit(); Serial.begin(115200); delay(3000); -#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E) +#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E) || defined(BOARD_PROFILE_YD_ESP32_S3_N16R8) analogReadResolution(12); analogSetAttenuation(ADC_11db); #endif Serial.println("\n=== InkSight Voice ==="); - ledInit(); loadConfig(); bool forcePortal = false; @@ -943,7 +1010,7 @@ static void handleLiveMode(); static bool waitForContentReady(); static void handleFailure(const char *reason); static void handleWiFiFailure(); -static void enterDeepSleep(int minutes); +static void enterDeepSleep(int minutes, bool force); static bool runAiChatConversation(); static bool decodeVoiceBmpToFrameBuffer(const uint8_t *bmpBytes, size_t bmpLen); @@ -952,16 +1019,16 @@ static bool decodeVoiceBmpToFrameBuffer(const uint8_t *bmpBytes, size_t bmpLen); // ═════════════════════════════════════════════════════════════ void setup() { + ledInit(); Serial.begin(115200); delay(3000); -#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E) +#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E) || defined(BOARD_PROFILE_YD_ESP32_S3_N16R8) analogReadResolution(12); analogSetAttenuation(ADC_11db); #endif Serial.println("\n=== InkSight ==="); gpioInit(); - ledInit(); ctx.wakeupReason = detectWakeupReason(); epdInit(); @@ -979,11 +1046,6 @@ void setup() { forcePortal = (digitalRead(PIN_CFG_BTN) == LOW); } - cacheInit(); - Serial.println("EPD ready"); - - loadConfig(); - bool hasConfig = (cfgSSID.length() > 0); if (forcePortal || !hasConfig) { @@ -991,13 +1053,6 @@ void setup() { : "No WiFi config -> portal"); delay(5000); enterPortalMode(); - String mac = WiFi.macAddress(); - String apName = "InkSight-" + mac.substring(mac.length() - 5); - apName.replace(":", ""); - ledFeedback("portal"); - showSetupScreen(apName.c_str()); - startCaptivePortal(); - ctx.state = DeviceState::PORTAL; return; } @@ -1105,23 +1160,26 @@ void setup() { } bool firstInstallLivePending = isFirstInstallLiveModePending(); - bool buttonWakeActive = (ctx.wakeupReason == WakeupReason::BUTTON); - if (firstInstallLivePending || alwaysActive || buttonWakeActive) { + bool buttonTemporaryOnline = (ctx.wakeupReason == WakeupReason::BUTTON); + if (alwaysActive) { ctx.liveMode = true; + clearTemporaryOnlineWindow(); ctx.lastLivePollAt = 0; ctx.lastLiveWiFiRetryAt = 0; if (firstInstallLivePending) { markFirstInstallLiveModeDone(); } postRuntimeMode("active"); - if (buttonWakeActive) { - Serial.println("[LIVE] Button wakeup: entering temporary active mode"); - } else { - Serial.println(firstInstallLivePending - ? "[LIVE] First install: temporary active mode" - : "[LIVE] Always active config enabled"); - } + Serial.println("[LIVE] Always active config enabled"); + } else if (firstInstallLivePending) { + markFirstInstallLiveModeDone(); + extendTemporaryOnlineWindow("first install"); + postRuntimeMode("active"); + } else if (buttonTemporaryOnline) { + extendTemporaryOnlineWindow("button wakeup"); + postRuntimeMode("active"); } else { + clearTemporaryOnlineWindow(); postRuntimeMode("interval"); if (focusListening) { Serial.println("[FOCUS] Focus listening enabled, keeping WiFi connected in interval mode"); @@ -1145,7 +1203,12 @@ void setup() { Serial.printf("[DEBUG] Staying awake, refresh every %d min (user config: %d min)\n", DEBUG_REFRESH_MIN, cfgSleepMin); #else - Serial.printf("Staying awake, refresh every %d min\n", cfgSleepMin); + if (temporaryOnlineActive()) { + Serial.printf("Staying awake for temporary online window (%lu min)\n", + TEMP_ONLINE_WINDOW_MS / 60000UL); + } else { + Serial.printf("Staying awake, refresh every %d min\n", cfgSleepMin); + } #endif } @@ -1156,6 +1219,7 @@ void setup() { void loop() { if (ctx.state == DeviceState::PORTAL) { handlePortalClients(); + checkPortalTimeout(); checkConfigButton(); checkAiChatButton(); delay(5); @@ -1165,30 +1229,8 @@ void loop() { checkConfigButton(); checkAiChatButton(); - if (ctx.wantEnterLiveMode) { - ctx.wantEnterLiveMode = false; - if (ctx.liveMode) { - ctx.liveMode = false; - Serial.println("[LIVE] Live mode disabled, back to interval mode"); - ledFeedback("ack"); - postRuntimeMode("interval"); - WiFi.disconnect(true); - WiFi.mode(WIFI_OFF); - delay(500); - enterDeepSleep(effectiveSleepMinutes()); - } else { - ctx.liveMode = true; - ctx.lastLivePollAt = 0; - ctx.lastLiveWiFiRetryAt = 0; - Serial.println("[LIVE] Live mode enabled"); - ledFeedback("ack"); - if (connectWiFi()) { - Serial.println("[LIVE] WiFi connected"); - postRuntimeMode("active"); - } - } #if VOCAB_REVIEW_BUILD - } else if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating || ctx.wantVocabExit) { + if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating || ctx.wantVocabExit) { bool doEnter = ctx.wantEnterVocabReview; bool doFlip = ctx.wantVocabFlip; bool doNextRating = ctx.wantVocabNextRating; @@ -1282,10 +1324,9 @@ void loop() { ledFeedback("fail"); } } - } else if (ctx.wantEnterAiChatMode) { -#else - } else if (ctx.wantEnterAiChatMode) { + } else #endif + if (ctx.wantEnterAiChatMode) { ctx.wantEnterAiChatMode = false; ctx.switchToModeId = ""; Serial.println("[AI CHAT] Dedicated switch long press -> enter conversation mode"); @@ -1449,8 +1490,8 @@ void loop() { // ── Deep sleep ────────────────────────────────────────────── -static void enterDeepSleep(int minutes) { - if (focusListening || alwaysActive) { +static void enterDeepSleep(int minutes, bool force) { + if (!force && (focusListening || alwaysActive)) { Serial.println(focusListening ? "[FOCUS] Focus listening enabled, skipping deep sleep" : "[LIVE] Always active enabled, skipping deep sleep"); @@ -1460,21 +1501,57 @@ static void enterDeepSleep(int minutes) { WiFi.disconnect(true); WiFi.mode(WIFI_OFF); epdSleep(); + ledFeedback("off"); 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); + pinMode(PIN_CFG_BTN, INPUT_PULLUP); + unsigned long releaseStartedAt = millis(); + while (digitalRead(PIN_CFG_BTN) == LOW && millis() - releaseStartedAt < 3000UL) { + delay(10); + } + delay(50); + bool wakeButtonReady = (digitalRead(PIN_CFG_BTN) == HIGH); + if (!wakeButtonReady) { + Serial.printf("[WAKE] GPIO%d is still LOW; timer-only wake for this sleep cycle\n", PIN_CFG_BTN); + } else { + const uint64_t wakeMask = 1ULL << PIN_CFG_BTN; +#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED + rtc_gpio_pullup_en((gpio_num_t)PIN_CFG_BTN); + rtc_gpio_pulldown_dis((gpio_num_t)PIN_CFG_BTN); + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); #else - esp_sleep_enable_ext1_wakeup(wakeMask, ESP_EXT1_WAKEUP_ALL_LOW); + gpio_pullup_en((gpio_num_t)PIN_CFG_BTN); + gpio_pulldown_dis((gpio_num_t)PIN_CFG_BTN); #endif - Serial.printf("[WAKE] Timer + GPIO%d button wake enabled\n", PIN_CFG_BTN); +#if SOC_GPIO_SUPPORT_DEEPSLEEP_WAKEUP + esp_deep_sleep_enable_gpio_wakeup(wakeMask, ESP_GPIO_WAKEUP_GPIO_LOW); +#elif CONFIG_IDF_TARGET_ESP32 + esp_sleep_enable_ext1_wakeup(wakeMask, ESP_EXT1_WAKEUP_ALL_LOW); +#else + esp_sleep_enable_ext1_wakeup(wakeMask, ESP_EXT1_WAKEUP_ANY_LOW); +#endif + Serial.printf("[WAKE] Timer + GPIO%d button wake enabled\n", PIN_CFG_BTN); + } #endif esp_deep_sleep_start(); } +static void checkPortalTimeout() { + if (ctx.state != DeviceState::PORTAL || ctx.portalStartedAt == 0 || ctx.portalTimeoutMs == 0) { + return; + } + unsigned long now = millis(); + if (now - ctx.portalStartedAt < ctx.portalTimeoutMs) { + return; + } + Serial.printf("[PORTAL] Timeout %lus, entering deep sleep\n", ctx.portalTimeoutMs / 1000UL); + ctx.portalStartedAt = 0; + ctx.portalTimeoutMs = 0; + enterDeepSleep(effectiveSleepMinutes(), true); +} + // ── Failure handler ───────────────────────────────────────── static void showFailureDiagnostic(const char *reason) { @@ -1530,8 +1607,8 @@ static void handleFailure(const char *reason) { // All saved WiFi networks failed to associate (connectWiFi already swept the // full list once). Do a few quick in-place retry sweeps to ride out a brief // blip (e.g. router rebooting), then fall back to the captive portal so the -// user can fix or add credentials. No multi-minute deep-sleep delays here — -// the user is typically standing by waiting to reconfigure. +// user can fix or add credentials. Auto-opened AP is time-limited so a +// battery-powered device does not stay in high-power portal mode indefinitely. static void handleWiFiFailure() { for (int i = 0; i < WIFI_PORTAL_RETRY_SWEEPS; i++) { Serial.printf("[DIAG] WiFi unreachable, quick retry sweep %d/%d in %lus\n", @@ -1546,7 +1623,7 @@ static void handleWiFiFailure() { } Serial.println("[DIAG] WiFi still unreachable -> captive portal"); resetRetryCount(); - enterPortalMode(); + enterPortalMode(PortalEntryReason::AUTO_WIFI_FAILURE); } // ── Live mode ─────────────────────────────────────────────── @@ -1555,6 +1632,19 @@ static void handleLiveMode() { if (!ctx.liveMode) return; unsigned long now = millis(); + if (ctx.temporaryOnlineUntil != 0 && alwaysActive) { + clearTemporaryOnlineWindow(); + } + if (temporaryOnlineExpired(now) && !focusListening && !alwaysActive) { + ctx.liveMode = false; + clearTemporaryOnlineWindow(); + if (WiFi.status() == WL_CONNECTED) { + postRuntimeMode("interval"); + } + Serial.println("[LIVE] Temporary online window expired, entering deep sleep"); + enterDeepSleep(effectiveSleepMinutes()); + return; + } #if DEBUG_MODE unsigned long refreshInterval = (unsigned long)DEBUG_REFRESH_MIN * 60000UL; #else @@ -1583,9 +1673,14 @@ static void handleLiveMode() { if (hasPendingRemoteAction(&shouldExitLive)) { Serial.println("[LIVE] Pending action detected, refreshing now"); bool wasAlwaysActive = alwaysActive; + bool wasTemporaryOnline = (ctx.temporaryOnlineUntil != 0); refreshActivityFlags(); triggerImmediateRefresh(false, true); ctx.setupDoneAt = millis(); + if (wasTemporaryOnline && !focusListening && !alwaysActive) { + extendTemporaryOnlineWindow("remote action"); + return; + } if (!focusListening && !alwaysActive) { ctx.liveMode = false; postRuntimeMode("interval"); @@ -1600,7 +1695,7 @@ static void handleLiveMode() { } return; } - if (shouldExitLive) { + if (shouldExitLive && ctx.temporaryOnlineUntil == 0) { ctx.liveMode = false; postRuntimeMode("interval"); WiFi.disconnect(true); @@ -1618,6 +1713,10 @@ static void handleLiveMode() { #endif triggerImmediateRefresh(false, true); ctx.setupDoneAt = millis(); + if (ctx.temporaryOnlineUntil != 0 && !focusListening && !alwaysActive) { + Serial.println("[LIVE] Fallback refresh complete, temporary window remains active"); + return; + } if (!focusListening && !alwaysActive) { ctx.liveMode = false; postRuntimeMode("interval"); @@ -2179,8 +2278,9 @@ static void checkConfigButton() { } else { unsigned long holdTime = millis() - ctx.btnPressStart; if (holdTime >= (unsigned long)CFG_BTN_HOLD_MS) { - Serial.printf("Config button held for %dms, restarting...\n", CFG_BTN_HOLD_MS); - ESP.restart(); + Serial.printf("Config button held for %dms, entering portal...\n", CFG_BTN_HOLD_MS); + ctx.btnPressStart = 0; + enterPortalMode(); } } } else { @@ -2190,8 +2290,7 @@ static void checkConfigButton() { if (pressDuration >= (unsigned long)SHORT_PRESS_MIN_MS && pressDuration < (unsigned long)CFG_BTN_HOLD_MS) { - Serial.println("[BTN] Single click -> toggle live mode"); - ctx.wantEnterLiveMode = true; + Serial.println("[BTN] Short press while awake; no action"); } } } diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index d624fcf9..a51f0e4f 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) || defined(BOARD_PROFILE_SMT_WROOM32E) +#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) || defined(BOARD_PROFILE_SMT_WROOM32E) || defined(BOARD_PROFILE_YD_ESP32_S3_N16R8) #include #endif diff --git a/webapp/app/config/page.tsx b/webapp/app/config/page.tsx index 61490887..adceff8a 100644 --- a/webapp/app/config/page.tsx +++ b/webapp/app/config/page.tsx @@ -2969,8 +2969,8 @@ function ConfigPageInner() {
{tr("始终保持活跃", "Keep device always active")}
{tr( - "开启后,设备会持续保持活跃,不进入间歇状态或深度睡眠。", - "When enabled, the device stays active continuously and will not enter interval mode or deep sleep.", + "开启后,设备会持续保持活跃,不进入间歇状态或深度睡眠。设备在线时会立即生效;若设备在深睡,请先短按 BOOT 唤醒后再保存,或等待下次唤醒后生效。", + "When enabled, the device stays active continuously and will not enter interval mode or deep sleep. For immediate effect, wake the device with BOOT before saving; otherwise it applies on the next wake.", )}