diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index b9b7d50..f33f991 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -481,6 +481,28 @@ bool ZephyrDataStore::saveMainIdentity(const mesh::LocalIdentity &identity) return atomicReplaceFile(MAIN_ID_FILE, buf, n); } +/* ── Shutdown-reason breadcrumb ────────────────────────────────────── */ + +void ZephyrDataStore::saveShutdownReason(uint8_t code) +{ + /* Best-effort: called at a software power-off, possibly at low battery. */ + (void)atomicReplaceFile(SHUTDOWN_FILE, &code, 1); +} + +uint8_t ZephyrDataStore::takeShutdownReason() +{ + uint8_t code = 0; + size_t len = 0; + + if (openRead(SHUTDOWN_FILE, &code, sizeof(code), len) && len >= 1) { + removeFile(SHUTDOWN_FILE); + return code; + } + /* Stray/empty file — clear it so it can't linger. */ + removeFile(SHUTDOWN_FILE); + return 0; +} + /* ── Preferences ───────────────────────────────────────────────────── */ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) diff --git a/zephcore/adapters/datastore/ZephyrDataStore.h b/zephcore/adapters/datastore/ZephyrDataStore.h index 18650ff..32b9e20 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.h +++ b/zephcore/adapters/datastore/ZephyrDataStore.h @@ -31,6 +31,14 @@ public: bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs &prefs); void savePrefs(const NodePrefs &prefs); + /* Shutdown-reason breadcrumb: written just before a software power-off + * (low-battery auto-shutdown) so the next boot can report why the node + * went down — the offline queue doesn't survive System OFF, so this + * flash marker is the only reliable channel. saveShutdownReason is + * best-effort (called at critically low battery). takeShutdownReason + * returns the stored code (0 = none) and clears it. */ + void saveShutdownReason(uint8_t code); + uint8_t takeShutdownReason(); void loadContacts(DataStoreHost *host); void saveContacts(DataStoreHost *host); void loadChannels(DataStoreHost *host); @@ -67,6 +75,7 @@ private: static constexpr const char *MNT_POINT = "/lfs"; static constexpr const char *PREFS_FILE = "/lfs/new_prefs"; static constexpr const char *MAIN_ID_FILE = "/lfs/_main.id"; + static constexpr const char *SHUTDOWN_FILE = "/lfs/shutdn"; /* External QSPI flash (optional) - contacts, channels, blobs */ static constexpr const char *EXT_MNT_POINT = "/ext"; diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index ecb9d8e..0144ea0 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -383,8 +383,30 @@ void ui_set_auto_shutdown_mv(uint16_t mv) s_auto_shutdown_mv = mv; } +/* Pre-shutdown hook + deferred power-off. When the hook reports an app is + * connected (live notice queued), the power-off is deferred by a grace period + * on a work item so the main loop keeps running and delivers the message. */ +static ui_shutdown_fn s_shutdown_hook; +static bool s_shutting_down; + +static void shutdown_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); +#ifdef CONFIG_POWEROFF + ui_prepare_for_system_off(); + sys_poweroff(); + CODE_UNREACHABLE; +#endif +} +static K_WORK_DELAYABLE_DEFINE(s_shutdown_work, shutdown_work_fn); + +void ui_set_shutdown_hook(ui_shutdown_fn fn) +{ + s_shutdown_hook = fn; +} + #ifdef CONFIG_ZEPHCORE_UI_DISPLAY -static void auto_shutdown_warn_screen(void) +static void auto_shutdown_warn_screen(bool hold) { /* Wake the panel (OLED may be blanked by auto-off; EPD is always * visible). Centre two lines; the message persists on e-paper after @@ -409,8 +431,12 @@ static void auto_shutdown_warn_screen(void) mc_display_finalize(); /* OLED blanks the instant power drops, so hold long enough to read it. - * EPD keeps the image with no power, so skip the delay. */ - if (!mc_display_is_epd()) { + * EPD keeps the image with no power, so skip the delay. The deferred- + * poweroff (grace) path passes hold=false: it must NOT block the main + * thread, because that thread has to service the app's message fetch + * during the grace window — the grace timer provides the on-screen dwell + * instead. */ + if (hold && !mc_display_is_epd()) { k_sleep(K_MSEC(3000)); } } @@ -418,6 +444,9 @@ static void auto_shutdown_warn_screen(void) void ui_auto_shutdown_check(void) { + if (s_shutting_down) { + return; /* power-off already committed (deferred grace running) */ + } if (!s_batt_provider || s_auto_shutdown_mv == 0) { return; /* no battery provider, or runtime-disabled */ } @@ -452,15 +481,35 @@ void ui_auto_shutdown_check(void) LOG_WRN("auto-shutdown: confirmed — powering off"); -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - auto_shutdown_warn_screen(); -#endif + /* Let the app layer report the shutdown. If it queued a live notice to a + * connected app, it returns true and we defer the power-off by a short + * grace so the notify→fetch→send round-trip can finish; otherwise it + * persisted the reason to flash (reported on next boot) and we power off + * now. */ + bool grace = s_shutdown_hook ? s_shutdown_hook(UI_SHUTDOWN_LOW_BATTERY) + : false; + s_shutting_down = true; #ifdef CONFIG_POWEROFF + if (grace) { +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + auto_shutdown_warn_screen(false); /* draw, don't block the loop */ +#endif + k_work_schedule(&s_shutdown_work, K_MSEC(UI_SHUTDOWN_GRACE_MS)); + return; /* main loop keeps running → delivers the notice */ + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + auto_shutdown_warn_screen(true); /* nothing to deliver — 3 s OLED hold */ +#endif ui_prepare_for_system_off(); sys_poweroff(); CODE_UNREACHABLE; #else + (void)grace; +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + auto_shutdown_warn_screen(true); +#endif LOG_WRN("auto-shutdown: CONFIG_POWEROFF not enabled — cannot power off"); #endif } @@ -469,6 +518,7 @@ void ui_auto_shutdown_check(void) void ui_set_auto_shutdown_mv(uint16_t mv) { (void)mv; } void ui_auto_shutdown_check(void) { } +void ui_set_shutdown_hook(ui_shutdown_fn fn) { (void)fn; } #endif /* CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0 */ diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index c11a495..3f3b2c1 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -155,6 +155,8 @@ WEAK void ui_set_auto_shutdown_mv(uint16_t mv) WEAK void ui_auto_shutdown_check(void) { } +WEAK void ui_set_shutdown_hook(ui_shutdown_fn fn) { ARG_UNUSED(fn); } + WEAK void ui_refresh_battery(void) { } WEAK void ui_invalidate_battery_cache(void) { } diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index be98a0a..36488f2 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -235,6 +235,27 @@ void ui_set_power_source_provider(bool (*provider)(void)); */ void ui_set_auto_shutdown_mv(uint16_t mv); +/* Reason codes passed to the shutdown hook. */ +#define UI_SHUTDOWN_LOW_BATTERY 1 + +/* Grace period (ms) the poweroff is deferred by when the hook asks for it + * (an app is connected and a live notice was queued), so the notify→fetch→ + * send round-trip can complete before power is cut. */ +#define UI_SHUTDOWN_GRACE_MS 1000 + +/** + * Register a pre-shutdown hook, called from ui_auto_shutdown_check() just + * before power-off. The companion uses it to report the shutdown to the + * connected app (v-contact). Return value: + * true = an app is connected and a live notice was queued — defer the + * poweroff by UI_SHUTDOWN_GRACE_MS so the app can fetch it. + * false = nothing to deliver live (persist to flash instead) — power off + * immediately. + * The hook runs on the main thread and must not block. + */ +typedef bool (*ui_shutdown_fn)(int reason); +void ui_set_shutdown_hook(ui_shutdown_fn fn); + /** * Low-battery auto-shutdown check (companion only). * diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index d917e0e..30fee23 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -997,6 +997,35 @@ static bool handle_vcontact_cli(const char *line, char *reply) return false; } +/* Pre-shutdown hook, registered with the UI layer and called on the main + * thread just before a low-battery power-off. If an app is connected, queue a + * live v-contact notice and return true so the UI defers the power-off by a + * short grace (letting the notify→fetch→send round-trip finish). If nobody is + * connected, persist the reason to flash and return false (power off now) — the + * offline queue doesn't survive System OFF, so the flash marker is the only way + * the shutdown gets reported, which it does on the next boot. */ +static bool companion_shutdown_hook(int reason) +{ + const char *msg = (reason == UI_SHUTDOWN_LOW_BATTERY) + ? "Powering off: low battery" + : "Powering off"; + + bool connected = zephcore_ble_is_connected(); +#if ZEPHCORE_USB_STACK + connected = connected || + (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB && + !zephcore_usb_companion_is_text_session()); +#endif + + if (connected && companion_mesh.isVContactEnabled()) { + companion_mesh.vcontactNotify(msg); + return true; /* deliver live — ask the UI for the grace delay */ + } + + data_store.saveShutdownReason((uint8_t)reason); + return false; /* nobody listening — flash marker, power off now */ +} + /* Transport-neutral CLI line execution — runs on the MAIN thread only * (CommonCLI::handleCommand touches mesh state shared with loop()). Both the * USB text sideband and the v-contact chat funnel through here. `reply` must @@ -1259,6 +1288,21 @@ int main(void) } data_store.begin(); + /* Fold a persisted shutdown reason into the boot notice. Written just + * before a previous low-battery System OFF when no app was connected to + * receive it live (the offline queue doesn't survive power-off). The + * hardware reset cause on this boot is just POR, so without this the + * reason would be lost. */ + { + uint8_t sdr = data_store.takeShutdownReason(); + if (sdr == UI_SHUTDOWN_LOW_BATTERY) { + size_t l = strlen(boot_cause_msg); + snprintf(boot_cause_msg + l, sizeof(boot_cause_msg) - l, + "%sLast shutdown: low battery", + l ? " | " : ""); + } + } + /* First-boot migration: fix NVS (BLE bonds) before bt_enable() runs. * * nRF52 stores BLE bonds in storage_partition (NVS) at 0xD0000. UF2 @@ -1404,6 +1448,7 @@ int main(void) ui_set_battery_provider(get_battery_mv); ui_set_power_source_provider([]() { return zephyr_board.isExternalPowered(); }); ui_set_auto_shutdown_mv(companion_mesh.prefs.auto_shutdown_mv); + ui_set_shutdown_hook(companion_shutdown_hook); companion_mesh.setRadioReconfigureCallback(radio_reconfigure); companion_mesh.setPinChangeCallback([](uint32_t new_pin) { zephcore_ble_set_passkey(new_pin);