diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7e82dd..1f3f4ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -608,8 +608,12 @@ contact named `v` that exists only toward the connected BLE/USB app. Chatting with it runs the same text CLI as the USB serial sideband; the reply comes back as normal chat messages. The firmware also uses it to emit unsolicited notices: a one-shot low-battery alert and a restart-reason message -(all causes: PIN/SOFTWARE/BROWNOUT/POR/WATCHDOG/LOCKUP — offline-queue only, -so routine power-on "noise" costs nothing over the air). +(every cause `zephcore_boot_reset_cause_str()` can label, which is more than +the six the companion used to hard-code — offline-queue only, so routine +power-on "noise" costs nothing over the air). Two causes are silent when they +are the only ones raised: a debugger reset, and a wake from a low-power +shutdown, which would otherwise announce itself after every power cycle. Both +still appear in the boot log. **Identity**: seed = `SHA256("zc-vcontact" || self_prv_key || counter)`, pubkey = that seed's Ed25519 public point — stable per node, unique per device. @@ -1226,8 +1230,16 @@ node enabled on any board — the `wdt` nodes visible in board `.dts` files are inherited SoC definitions, and the `RTCWDT` references in the TTGO board configs concern the ESP32 ROM bootloader's own watchdog, not something ZephCore arms. The only consumer of the concept is the boot breadcrumb in `main_companion.cpp`, -which reads `RESET_WATCHDOG` out of `hwinfo_get_reset_cause()` and reports it in -the "Restarted:" v-contact message (see [6.2.1](#621-v-contact-loopback-admin-contact)). +which reports `RESET_WATCHDOG` in the "Restarted:" v-contact message (see +[6.2.1](#621-v-contact-loopback-admin-contact)). It reads the cause from +`zephcore_boot_reset_cause()` rather than from `hwinfo_get_reset_cause()` +directly: `helpers/boot_info.c` captures the register once at POST_KERNEL on +every role, logs it, and clears it where the platform implements a clear, so +no later reader can consume it. nRF implements `hwinfo_clear_reset_cause()`; +ESP32 does not, so the weak stub returns `-ENOSYS` and nothing is cleared +there. That is harmless on ESP32, whose cause comes from `esp_reset_reason()` +and does not accumulate across boots, but the capture is what readers should +rely on rather than the register. Everything below is software: bounded stall detection in the layer that owns the state machine. Each entry names what it recovers, because several are diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index c331e10..f5f809f 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -726,6 +726,9 @@ target_sources(app PRIVATE adapters/sensors/ZephyrEnvSensors.cpp helpers/AdvertDataHelpers.cpp helpers/MeshTimeSync.cpp + # Captures the reset cause before anything consumes it; every role needs it + # at boot, so it is never conditional. + helpers/boot_info.c helpers/oled_power.c helpers/fatal_reboot.c # LED master gate ("set leds on|off"). Always compiled: helpers/ui/ui_common.c diff --git a/zephcore/helpers/boot_info.c b/zephcore/helpers/boot_info.c new file mode 100644 index 0000000..7c8dc98 --- /dev/null +++ b/zephcore/helpers/boot_info.c @@ -0,0 +1,128 @@ +/* + * SPDX-License-Identifier: MIT + * Boot-time reset-cause capture. See boot_info.h. + */ + +#include "boot_info.h" + +#include +#include +#include +#include + +#include + +LOG_MODULE_REGISTER(zephcore_boot, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +static uint32_t s_reset_cause; +static bool s_reset_cause_valid; + +/* POST_KERNEL: before main() on every role. */ +static int boot_info_init(void) +{ + uint32_t cause = 0; + + if (hwinfo_get_reset_cause(&cause) != 0) { + /* Nothing to capture; the accessor reports false. */ + return 0; + } + + s_reset_cause = cause; + s_reset_cause_valid = true; + + /* Logged here, not only in the role that renders it: the clear below + * would otherwise lose the cause on roles that never read it. */ + LOG_INF("Boot reset cause: 0x%08x", cause); + + /* Not reported again next boot. -ENOSYS where unimplemented (ESP32). */ + (void)hwinfo_clear_reset_cause(); + + return 0; +} + +SYS_INIT(boot_info_init, POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT); + +bool zephcore_boot_reset_cause(uint32_t *out) +{ + if (!s_reset_cause_valid) { + return false; + } + + *out = s_reset_cause; + return true; +} + +/* + * One row per RESET_* bit in . A bit with no row is + * rendered as nothing and is invisible to zephcore_boot_reset_cause_labelled(). + */ +static const struct { + uint32_t bit; + const char *label; +} s_labels[] = { + { RESET_PIN, "PIN" }, + { RESET_SOFTWARE, "SOFTWARE" }, + { RESET_BROWNOUT, "BROWNOUT" }, + { RESET_POR, "POR" }, + { RESET_WATCHDOG, "WATCHDOG" }, + { RESET_DEBUG, "DEBUG" }, + { RESET_SECURITY, "SECURITY" }, + { RESET_LOW_POWER_WAKE, "LOWPOWER" }, + { RESET_CPU_LOCKUP, "LOCKUP" }, + { RESET_PARITY, "PARITY" }, + { RESET_PLL, "PLL" }, + { RESET_CLOCK, "CLOCK" }, + { RESET_HARDWARE, "HARDWARE" }, + { RESET_USER, "USER" }, + { RESET_TEMPERATURE, "TEMPERATURE" }, + { RESET_BOOTLOADER, "BOOTLOADER" }, + { RESET_FLASH, "FLASH" }, +}; + +int zephcore_boot_reset_cause_str(char *buf, size_t cap) +{ + if (buf == NULL || cap == 0) { + return 0; + } + + buf[0] = '\0'; + + if (!s_reset_cause_valid) { + return 0; + } + + size_t n = 0; + + for (size_t i = 0; i < ARRAY_SIZE(s_labels); i++) { + if ((s_reset_cause & s_labels[i].bit) == 0) { + continue; + } + + int w = snprintf(buf + n, cap - n, " %s", s_labels[i].label); + + if (w < 0 || (size_t)w >= cap - n) { + /* Stop rather than emit half a label. */ + buf[n] = '\0'; + break; + } + + n += (size_t)w; + } + + return (int)n; +} + +uint32_t zephcore_boot_reset_cause_labelled(void) +{ + uint32_t mask = 0; + + if (!s_reset_cause_valid) { + return 0; + } + + for (size_t i = 0; i < ARRAY_SIZE(s_labels); i++) { + mask |= s_reset_cause & s_labels[i].bit; + } + + return mask; +} diff --git a/zephcore/helpers/boot_info.h b/zephcore/helpers/boot_info.h new file mode 100644 index 0000000..727ff9c --- /dev/null +++ b/zephcore/helpers/boot_info.h @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MCU's reset cause, captured and cleared once at POST_KERNEL on every + * role. hwinfo's flags accumulate between resets on some platforms, so + * clearing after reading is what isolates the most recent one. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * The cause hwinfo reported at boot. out must be non-NULL. False means hwinfo + * could not report one, which differs from a cause of 0. Call from main() or + * later; an initialiser at or before POST_KERNEL may get false. + */ +bool zephcore_boot_reset_cause(uint32_t *out); + +/* + * Render as space-prefixed labels, e.g. " PIN SOFTWARE". Returns characters + * written, excluding the NUL; 0 means nothing to render. Writes nothing when + * buf is NULL or cap is 0, otherwise always NUL-terminates, truncating at the + * first label that does not fit. + */ +int zephcore_boot_reset_cause_str(char *buf, size_t cap); + +/* + * The part of the cause the renderer would name. Use this rather than the raw + * cause when deciding whether a cause is worth reporting, so an unlabelled bit + * cannot trigger a report that then names unrelated labels. + */ +uint32_t zephcore_boot_reset_cause_labelled(void); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 8d5a9af..05f709b 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -24,6 +24,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include +#include #include #include #include @@ -1386,36 +1387,70 @@ int main(void) LOG_INF("=== ZephCore starting ==="); /* Log reset reason so we can diagnose random reboots */ - char boot_cause_msg[96]; /* fits "Restarted:" + all six cause labels */ + /* "Restarted:" (10) + every label (126) + shutdown reason (29) + NUL. + * Worst case this function can produce; vcontactNotify() truncates to 63 + * on the RTC-less path, so this is not an end-to-end guarantee. */ + char boot_cause_msg[176]; boot_cause_msg[0] = '\0'; + /* Every label the renderer can emit, space-prefixed: 126 + NUL. Declared + * out here, rather than in the block that fills it, so its lifetime does + * not depend on when the logging subsystem copies a %s argument. It does + * copy eagerly today, on every packaging path, but that is an internal + * detail and a log frontend would not. */ + char boot_cause_labels[128]; { uint32_t cause; - if (hwinfo_get_reset_cause(&cause) == 0) { - LOG_INF("Reset cause: 0x%08x%s%s%s%s%s%s", cause, - (cause & RESET_PIN) ? " PIN" : "", - (cause & RESET_SOFTWARE) ? " SOFTWARE" : "", - (cause & RESET_BROWNOUT) ? " BROWNOUT" : "", - (cause & RESET_POR) ? " POR" : "", - (cause & RESET_WATCHDOG) ? " WATCHDOG" : "", - (cause & RESET_CPU_LOCKUP)? " LOCKUP" : ""); - hwinfo_clear_reset_cause(); - /* Every known cause becomes a v-contact message once the mesh + /* Captured at POST_KERNEL by helpers/boot_info.c, which also did the + * clearing this block used to do. Reading the captured copy rather + * than the register is what lets a later diagnostic or CLI report + * name the same cause -- the register is already cleared by now. */ + if (zephcore_boot_reset_cause(&cause)) { + /* One renderer, shared with every other reader. A hand-rolled + * list here would drift from it, and the one it replaced had + * already drifted: it knew six bits of the seventeen the + * renderer labels, so a SECURITY or CLOCK reset logged as bare + * hex and raised no notice at all. The trade is + * that the notice loses the plain-English hints the local copy + * carried: it now reads "PIN" and "POR" rather than + * "PIN(reset button)" and "POR(power-on)". */ + (void)zephcore_boot_reset_cause_str(boot_cause_labels, + sizeof(boot_cause_labels)); + + LOG_INF("Reset cause: 0x%08x%s", cause, boot_cause_labels); + + /* Every labelled cause becomes a v-contact message once the mesh * is up (queued below, after RTC restore + prefs load) — the * message rides the offline queue only, so the "noise" of a * routine power-on costs nothing over the air and doubles as * a power-integrity breadcrumb (dead battery, loose contact). * A deliberate CLI/app reboot shows as SOFTWARE, doubling as - * a "reboot completed" confirmation. */ - if (cause & (RESET_PIN | RESET_SOFTWARE | RESET_BROWNOUT | - RESET_POR | RESET_WATCHDOG | RESET_CPU_LOCKUP)) { + * a "reboot completed" confirmation. + * + * zephcore_boot_reset_cause_labelled() is 0 when the cause is + * 0 or carries only bits this build has no label for, which is + * the same "nothing worth saying" the old explicit bit test + * meant. + * + * A debugger-initiated reset is the developer's own doing and + * is not worth a chat message on its own. A quiet cause still + * renders in the log line, and in the notice when some other + * cause raised one, so nothing is hidden -- this decides only + * whether to speak unprompted. + * + * A wake from a low-power shutdown is one the user asked + * for: the button power-off persists no reason, so announcing + * the wake would put an unexplained "Restarted: LOWPOWER" in + * the chat after every power cycle. The low-battery shutdown + * does persist a reason, and it is folded in further below. + * + * Costs one case on STM32: hwinfo_stm32.c folds LPWRRSTF, an + * illegal low-power entry, onto the same bit as routine wakes, + * so that fault raises no notice. Still logged by boot_info.c. */ + const uint32_t quiet = RESET_LOW_POWER_WAKE | RESET_DEBUG; + + if ((zephcore_boot_reset_cause_labelled() & ~quiet) != 0) { snprintf(boot_cause_msg, sizeof(boot_cause_msg), - "Restarted:%s%s%s%s%s%s", - (cause & RESET_PIN) ? " PIN(reset button)" : "", - (cause & RESET_SOFTWARE) ? " SOFTWARE" : "", - (cause & RESET_BROWNOUT) ? " BROWNOUT" : "", - (cause & RESET_POR) ? " POR(power-on)" : "", - (cause & RESET_WATCHDOG) ? " WATCHDOG" : "", - (cause & RESET_CPU_LOCKUP) ? " LOCKUP" : ""); + "Restarted:%s", boot_cause_labels); } } } @@ -1428,8 +1463,9 @@ int main(void) /* 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. */ + * hardware cause on this boot does not say "low battery" -- on nRF52840 + * a System OFF wake reports LOW_POWER_WAKE, which is quiet above -- so + * without this the reason would be lost. */ { uint8_t sdr = data_store.takeShutdownReason(); if (sdr == UI_SHUTDOWN_LOW_BATTERY) {