From 9a03a4308e46f7ca4996def6ffdae6805d2f5101 Mon Sep 17 00:00:00 2001 From: Brandon Satrom Date: Fri, 13 Mar 2026 15:30:30 -0500 Subject: [PATCH] feat:implement brownout detection on STM32L433 host --- .../plans/brownout-detection-safe-shutdown.md | 283 ++++++++++++++++++ songbird-firmware/platformio.ini | 3 +- songbird-firmware/src/core/SongbirdConfig.h | 19 ++ songbird-firmware/src/core/SongbirdPower.cpp | 213 +++++++++++++ songbird-firmware/src/core/SongbirdPower.h | 92 ++++++ songbird-firmware/src/core/SongbirdState.cpp | 42 +++ songbird-firmware/src/core/SongbirdState.h | 55 +++- songbird-firmware/src/main.cpp | 43 +++ .../src/notecard/SongbirdNotecard.cpp | 34 +++ .../src/notecard/SongbirdNotecard.h | 13 + songbird-firmware/src/rtos/SongbirdSync.h | 1 + songbird-firmware/src/rtos/SongbirdTasks.cpp | 136 ++++++++- 12 files changed, 921 insertions(+), 13 deletions(-) create mode 100644 nimbalyst-local/plans/brownout-detection-safe-shutdown.md create mode 100644 songbird-firmware/src/core/SongbirdPower.cpp create mode 100644 songbird-firmware/src/core/SongbirdPower.h diff --git a/nimbalyst-local/plans/brownout-detection-safe-shutdown.md b/nimbalyst-local/plans/brownout-detection-safe-shutdown.md new file mode 100644 index 0000000..eda004f --- /dev/null +++ b/nimbalyst-local/plans/brownout-detection-safe-shutdown.md @@ -0,0 +1,283 @@ +--- +planStatus: + planId: plan-brownout-detection-safe-shutdown + title: STM32 Brownout Detection & Safe Shutdown + status: in-review + planType: feature + priority: high + owner: satch + stakeholders: [] + tags: + - firmware + - power-management + - stm32 + - reliability + created: "2026-03-13" + updated: "2026-03-13T00:00:00.000Z" + progress: 100 +--- +# STM32 Brownout Detection & Safe Shutdown + +## Implementation Progress + +- [x] Phase 1: Add boot cause detection (read RCC->CSR, store reason in state) +- [x] Phase 1: Update SongbirdState with new fields (lastBootTimestamp, consecutiveBrownouts, lastShutdownReason) +- [x] Phase 1: Log brownout boot to health.qo +- [x] Phase 2: Boot-loop prevention logic in setup() +- [x] Phase 3: Create SongbirdPower.h/cpp with PVD init and ISR +- [x] Phase 3: Add g_pvdShutdownRequested flag to SongbirdSync +- [x] Phase 4: Implement pvdSafeShutdown() in MainTask +- [x] Phase 4: Add notecardSendShutdownNote() to SongbirdNotecard + +## Problem + +When the Songbird battery drains to critically low voltage, the STM32L433 enters a brownout condition and boot-loops. This happens because: + +1. The MCU attempts to boot but voltage is insufficient to sustain full operation +2. Flash writes and I2C transactions become unreliable at low voltage +3. The Notecard may fail to respond, causing init retries and longer boot time +4. Each boot attempt drains the already-depleted battery further +5. No persistent record of the shutdown event is captured + +This creates a poor user experience and risks data loss. A sales demo device found dead in a boot-loop is a bad demo story. + +## Goals + +- Detect impending brownout **before** hardware reset occurs (early warning via PVD) +- Perform a safe, coordinated shutdown when voltage drops critically low +- Persist state before power loss so warm boot can resume correctly +- Log the brownout event to Notehub for fleet visibility +- Detect boot-loop conditions and enter a low-power wait state +- Optionally play an audio warning before voltage becomes too low for the buzzer + +## Non-Goals + +- Changing the BOR (Brown-Out Reset) hardware threshold via option bytes (acceptable as-is) +- Battery fuel gauge or coulomb counting (no hardware support) +- USB-powered operation changes + +--- + +## Background: STM32L433 Power Supervision Hardware + +### BOR (Brown-Out Reset) + +- **Always active** on STM32L433 — cannot be disabled +- Generates a hardware reset when VDD falls below threshold +- 5 programmable levels via Flash Option Bytes: + | Level | Voltage | + |-------|---------| + | BOR0 | ~1.7V | + | BOR1 | ~2.0V | + | BOR2 | ~2.2V | + | BOR3 | ~2.5V | + | BOR4 | ~2.8V | +- Default level (out of factory) is typically BOR1 (~2.0V) for STM32L4 +- Flash requires ≥2.7V for reliable operation — BOR level should be set appropriately +- On reset, `RCC->CSR` bit `BORRSTF` is set; detectable at startup + +### PVD (Programmable Voltage Detector) + +- **Optional** software-controlled early-warning monitor +- Generates an interrupt (via EXTI line 16) before voltage drops to BOR threshold +- 8 selectable levels from ~2.0V to ~2.9V +- Interrupt fires on rising and/or falling edge +- Enabled via `HAL_PWR_EnablePVD()` / `HAL_PWR_ConfigPVD()` +- Interrupt vector: `PVD_PVM_IRQn` +- Key HAL types: `PWR_PVDTypeDef`, fields: `PVDLevel`, `Mode` + +### Reset Cause Detection + +Reading `RCC->CSR` at startup reveals why the MCU reset: +- `RCC_CSR_BORRSTF` — brownout occurred +- `RCC_CSR_PINRSTF` — external pin reset +- `RCC_CSR_IWDGRSTF` — watchdog reset +- `RCC_CSR_SFTRSTF` — software reset +- Clear flags with `RCC->CSR |= RCC_CSR_RMVF` + +--- + +## Design + +### Two-Layer Defense + +``` +VDD dropping + │ + ▼ +[PVD threshold ~2.9V] ← Early warning interrupt fires + │ Safe shutdown sequence starts + │ (flush notes, save state, sleep) + │ + ▼ +[BOR threshold ~2.0–2.2V] ← Hardware reset (last resort) + │ + ▼ +[Boot loop prevention] ← Detect BORRSTF + rapid boots, + enter low-power wait +``` + +### PVD Threshold Selection + +The Notecard and I2C peripherals require ~2.7–3.0V for reliable operation. We want PVD to fire while there is still enough voltage to: +1. Complete any in-flight I2C transactions +2. Send a final note to Notehub +3. Save state to Notecard payload +4. Put Notecard to sleep + +**STM32L4 PVD levels (confirmed from ****`stm32l4xx_hal_pwr.h`**** and RM0394):** + +| Constant | Threshold | +| --- | --- | +| PWR_PVDLEVEL_0 | ~2.0V | +| PWR_PVDLEVEL_1 | ~2.2V | +| PWR_PVDLEVEL_2 | ~2.4V | +| PWR_PVDLEVEL_3 | ~2.5V | +| PWR_PVDLEVEL_4 | ~2.6V | +| PWR_PVDLEVEL_5 | ~2.8V | +| PWR_PVDLEVEL_6 | ~2.9V | +| PWR_PVDLEVEL_7 | External VREFINT | + +**Proposed PVD level: ****`PWR_PVDLEVEL_6`**** (\~2.9V)** — the highest fixed threshold available. + +Note that 2.9V is lower than the Notecard's ideal operating voltage (~3.0V+). This means the shutdown sequence must be short and conservative — no retries, strict timeouts. The window between PVD firing and BOR (at ~2.0V) is meaningful but voltage drops can be fast under load. The safe shutdown sequence must complete within a few seconds. + +### Boot-Loop Detection + +On every cold boot, read `RCC->CSR` before clearing flags: +- If `BORRSTF` is set → log brownout as boot cause +- Track rapid successive boots using state's `bootCount` + a new `lastBootTimestamp` in `SongbirdState` +- If `bootCount` increments faster than a threshold (e.g., 3 boots in < 30 seconds), enter a **low-power hold** state: + 1. Send a single `alert.qo` brownout boot-loop alert (one attempt, no retry) before suspending tasks + 2. Suspend all tasks, put Notecard to sleep + 3. Delay 60 seconds in low-power mode to allow charger/battery to recover voltage + 4. Reset `consecutiveBrownouts` counter before resuming + +### Safe Shutdown Sequence (PVD ISR → Handler) + +When the PVD interrupt fires (voltage crossing threshold going down): + +1. **Set a global atomic flag** `g_pvdShutdownRequested` (from ISR context — no blocking calls) +2. **MainTask detects the flag** in its main loop (high priority check, before button/config processing) +3. MainTask executes coordinated safe shutdown: + a. Play a single low-battery warning tone (buzzer should still be reliable at 2.9V) + b. Cancel any active locate sequences + c. Suspend sensor reads (stop queuing new notes) + d. Drain the note queue — attempt to send any pending notes (up to `PVD_QUEUE_DRAIN_LIMIT`, with `PVD_SHUTDOWN_NOTE_TIMEOUT` deadline) + e. Send a `health.qo` note with `{"shutdown": "pvd_low_battery", "voltage": }` + f. Call `stateSave()` to persist state + g. Call `notecardEnterSleep()` + h. STM32 enters `STOP2` low-power mode (deepest sleep retaining SRAM) + +### New Files + +``` +src/ +└── core/ + ├── SongbirdPower.h # Power monitoring interface + └── SongbirdPower.cpp # PVD init, ISR, boot cause detection +``` + +### Changes to Existing Files + +| File | Change | +| --- | --- | +| `platformio.ini` | Add `-D HAL_PWR_EX_MODULE_ENABLED` if needed for extended PWR | +| `SongbirdConfig.h` | Add `PVDLEVEL_SHUTDOWN` constant, `BOOT_LOOP_MAX_COUNT`, `BOOT_LOOP_WINDOW_MS` | +| `SongbirdState.h/cpp` | Add `lastBootTimestamp`, `consecutiveBrownouts`, `lastShutdownReason` to `SongbirdState` struct; update `STATE_VERSION` to 5 | +| `main.cpp` | Call `powerInit()` early in `setup()` before FreeRTOS starts; read and log boot cause | +| `SongbirdTasks.cpp` (MainTask) | Check `g_pvdShutdownRequested` at top of loop; implement `pvdSafeShutdown()` | +| `SongbirdNotecard.h/cpp` | Add `notecardSendShutdownNote(float voltage, const char* reason)` | +| `SongbirdSync.h/cpp` | Add `g_pvdShutdownRequested` volatile atomic flag | + +--- + +## Implementation Steps + +### Phase 1: Boot Cause Detection & Logging +1. Read `RCC->CSR` reset flags in `setup()` before clearing +2. Store boot cause in `SongbirdState` (`lastShutdownReason`) +3. Log to `health.qo` if brownout detected +4. Clear flags with `RCC_CSR_RMVF` + +### Phase 2: Boot-Loop Prevention +1. Add `lastBootTimestamp` and `consecutiveBrownouts` to state +2. In `setup()`, detect rapid boot pattern +3. If threshold exceeded, enter low-power hold: + - Suspend FreeRTOS task creation + - Delay 60s in low-power mode + - Reset `consecutiveBrownouts` counter + +### Phase 3: PVD Early Warning +1. Create `SongbirdPower.h/cpp` +2. `powerInit()`: Configure PVD level + mode, enable NVIC, call `HAL_PWR_EnablePVD()` +3. Implement `PVD_PVM_IRQHandler()` → `HAL_PWR_PVD_IRQHandler()` → `HAL_PWR_PVDCallback()` +4. In callback: set `g_pvdShutdownRequested = true` +5. Add `g_pvdShutdownRequested` check in MainTask loop + +### Phase 4: Safe Shutdown Handler +1. Implement `pvdSafeShutdown()` in MainTask context +2. Short-circuit note queue: drain up to N pending items with timeout +3. Send shutdown health note +4. Save state, enter Notecard sleep +5. Enter STM32 STOP2 via `HAL_PWREx_EnterSTOP2Mode()` + +### Phase 5: Testing & Tuning +1. Test PVD threshold calibration at bench (power supply with current limiting) +2. Test boot-loop prevention +3. Test warm boot after PVD shutdown (state restoration) +4. Verify health note appears in Notehub + +--- + +## Configuration Constants (proposed additions to SongbirdConfig.h) + +```c +// PVD threshold for safe shutdown early warning +// PWR_PVDLEVEL_6 = ~2.9V on STM32L4 (confirmed, highest fixed threshold) +#define PVD_SHUTDOWN_LEVEL PWR_PVDLEVEL_6 + +// Boot-loop prevention +#define BOOT_LOOP_MAX_COUNT 3 // Max consecutive brownout boots +#define BOOT_LOOP_WINDOW_SEC 30 // Window to detect boot loop (seconds) +#define BOOT_LOOP_HOLD_SEC 60 // How long to wait before retrying + +// Safe shutdown +#define PVD_SHUTDOWN_NOTE_TIMEOUT 5000 // ms to attempt final note send +#define PVD_QUEUE_DRAIN_LIMIT 3 // Max queued notes to flush before shutdown +``` + +--- + +## Risk & Considerations + +| Risk | Mitigation | +| --- | --- | +| PVD fires too early (too much voltage headroom wasted) | Tune threshold; use hysteresis mode | +| Not enough time to complete I2C transactions before BOR | Keep shutdown sequence minimal; set strict timeouts | +| ISR fires during existing I2C transaction | ISR only sets flag; all I2C work happens in task context | +| State version mismatch after adding new fields | Increment `STATE_VERSION` to 5; existing warm boots will fail checksum → cold boot (acceptable) | +| Voltage drops faster than shutdown completes | Keep sequence to ≤3s total; abort non-critical steps early if voltage keeps falling | +| `HAL_PWR_PVDCallback` conflicts with Arduino HAL | May need `__weak` override; verify linkage in stm32duino framework | + +--- + +## Resolved Decisions + +1. **PVD levels confirmed:** STM32L433 supports `PWR_PVDLEVEL_0` (~2.0V) through `PWR_PVDLEVEL_6` (~2.9V) as fixed thresholds; `PWR_PVDLEVEL_7` is external VREFINT. We use `PWR_PVDLEVEL_6` (~2.9V). + +2. **BOR option byte:** Yes — raise BOR level to `OB_BOR_LEVEL_4` (~2.8V) to ensure a meaningful gap above deep discharge and improve Flash write reliability. **Important caveat:** Option bytes cannot be updated via OTA (ODFU). The Notecard DFU mechanism only updates application firmware, not Flash option bytes. The BOR change requires physical ST-Link access and applies only to devices re-flashed at the bench. Field devices will retain their existing BOR level. The PVD-based safe shutdown (implemented in firmware) is what provides protection for all devices including those already deployed. + +3. **Boot-loop hold alert:** Send a single `alert.qo` note before entering the hold state, then conserve power. One alert is enough for fleet visibility; do not retry. + +4. **Audio warning:** Play a single short low-battery tone at the start of the PVD safe shutdown sequence. The buzzer should be operational at 2.9V (Qwiic Buzzer I2C minimum is well below this). Only one playback — do not loop. + +--- + +## References + +- STM32L4 Reference Manual RM0394 — Section 5 (Power Control), Table 48 (PVD levels) +- [`stm32l4xx_hal_pwr.h`](https://github.com/STMicroelectronics/STM32CubeL4/blob/master/Drivers/STM32L4xx_HAL_Driver/Inc/stm32l4xx_hal_pwr.h) +- [STM32L4 System Power Training](https://www.st.com/resource/en/product_training/stm32l4_system_power.pdf) +- [PVD interrupt configuration — STM32 Community](https://community.st.com/t5/stm32-mcus-products/how-to-get-pvd-programmable-voltage-detector-interrupt-working/td-p/463714) +- [BOR level configuration via option bytes](https://www.beyondlogic.org/using-stm32-hal-with-zephyr-setting-the-brown-out-reset-threshold/) diff --git a/songbird-firmware/platformio.ini b/songbird-firmware/platformio.ini index c1b1bc9..47f6a22 100644 --- a/songbird-firmware/platformio.ini +++ b/songbird-firmware/platformio.ini @@ -32,10 +32,11 @@ build_flags = ; Product configuration -D PRODUCT_UID=\"com.blues.songbird\" - -D FIRMWARE_VERSION=\"1.5.3\" + -D FIRMWARE_VERSION=\"1.6.1\" ; Enable HAL modules -D HAL_TIM_MODULE_ENABLED -D HAL_PWR_MODULE_ENABLED + -D HAL_PWR_EX_MODULE_ENABLED ; Include paths for modular structure -I src/audio diff --git a/songbird-firmware/src/core/SongbirdConfig.h b/songbird-firmware/src/core/SongbirdConfig.h index 3f0a901..815adc6 100644 --- a/songbird-firmware/src/core/SongbirdConfig.h +++ b/songbird-firmware/src/core/SongbirdConfig.h @@ -203,6 +203,25 @@ typedef enum { #define DEFAULT_GPS_SIGNAL_TIMEOUT_MIN 15 // Minutes to wait for GPS signal before disabling #define DEFAULT_GPS_RETRY_INTERVAL_MIN 30 // Minutes between GPS retry attempts +// ============================================================================= +// Brownout / PVD Power Management +// ============================================================================= + +// PVD threshold for safe shutdown early warning +// PWR_PVDLEVEL_6 = ~2.9V on STM32L4 (confirmed, highest fixed threshold) +// Must include stm32l4xx_hal_pwr.h (via Arduino STM32 framework) to use this constant +#define PVD_SHUTDOWN_LEVEL PWR_PVDLEVEL_6 + +// Boot-loop prevention: if BORRSTF resets occur this many times within the window, +// enter a low-power hold to allow battery/charger to recover +#define BOOT_LOOP_MAX_COUNT 3 // Max consecutive brownout boots before hold +#define BOOT_LOOP_WINDOW_SEC 30 // Window to consider boots "consecutive" (seconds) +#define BOOT_LOOP_HOLD_SEC 60 // Seconds to hold in low-power before retrying + +// Safe shutdown: time budget for note queue drain during PVD shutdown +#define PVD_SHUTDOWN_NOTE_TIMEOUT_MS 4000 // ms total deadline for draining notes +#define PVD_QUEUE_DRAIN_LIMIT 3 // Max queued notes to flush before forced sleep + // ============================================================================= // Task Intervals (milliseconds) // ============================================================================= diff --git a/songbird-firmware/src/core/SongbirdPower.cpp b/songbird-firmware/src/core/SongbirdPower.cpp new file mode 100644 index 0000000..808d556 --- /dev/null +++ b/songbird-firmware/src/core/SongbirdPower.cpp @@ -0,0 +1,213 @@ +/** + * @file SongbirdPower.cpp + * @brief Power monitoring implementation for Songbird + * + * Songbird - Blues Sales Demo Device + * Copyright (c) 2025 Blues Inc. + */ + +#include "SongbirdPower.h" +#include "SongbirdState.h" + +#include +#include + +// ============================================================================= +// Module State +// ============================================================================= + +static BootCause s_bootCause = BOOT_CAUSE_UNKNOWN; + +// Boot-loop counter backed purely by SRAM (not Notecard payload). +// +// The STM32L433 retains SRAM across BOR resets as long as VDD stays above +// the SRAM retention floor (~1.7V). This means the counter survives the rapid +// power-cycle of a boot-loop without requiring I2C to the Notecard, which may +// be unreliable at critically low voltage. +// +// A magic-value guard distinguishes "counter was initialised this power cycle" +// from stale SRAM on the very first cold boot after a true power-off. +// +// If the device does fully discharge and lose SRAM, the counter resets to 0 — +// that's correct behaviour because a full discharge means the boot-loop +// condition has naturally cleared. +#define BOOT_LOOP_SRAM_MAGIC 0xB007U +static uint16_t s_bootLoopMagic = 0; +static uint8_t s_sramBrownoutCount = 0; + +// ============================================================================= +// PVD Shutdown Flag (set from ISR, consumed by MainTask) +// ============================================================================= + +// Declared extern in SongbirdSync.h — defined here as the owning translation unit. +// Must be volatile so the compiler does not optimize away reads in the task loop. +volatile bool g_pvdShutdownRequested = false; + +// ============================================================================= +// Initialization +// ============================================================================= + +void powerInit(void) { + // ------------------------------------------------------------------------- + // 1. Read and record boot cause from RCC->CSR before clearing flags + // ------------------------------------------------------------------------- + uint32_t csr = RCC->CSR; + + if (csr & RCC_CSR_BORRSTF) { + s_bootCause = BOOT_CAUSE_BROWNOUT; + } else if (csr & RCC_CSR_IWDGRSTF) { + s_bootCause = BOOT_CAUSE_WATCHDOG; + } else if (csr & (RCC_CSR_PINRSTF | RCC_CSR_SFTRSTF)) { + s_bootCause = BOOT_CAUSE_NORMAL; + } else { + s_bootCause = BOOT_CAUSE_UNKNOWN; + } + + // Clear all reset flags + RCC->CSR |= RCC_CSR_RMVF; + + #ifdef DEBUG_MODE + DEBUG_SERIAL.print("[Power] Boot cause: "); + DEBUG_SERIAL.println(powerGetBootCauseString()); + #endif + + // ------------------------------------------------------------------------- + // 2. Configure and enable PVD (~2.9V falling-edge interrupt) + // ------------------------------------------------------------------------- + // Enable PWR clock (required before accessing PWR registers) + __HAL_RCC_PWR_CLK_ENABLE(); + + PWR_PVDTypeDef pvdConfig; + pvdConfig.PVDLevel = PVD_SHUTDOWN_LEVEL; // ~2.9V + pvdConfig.Mode = PWR_PVD_MODE_IT_FALLING; // Interrupt on falling edge only + + HAL_PWR_ConfigPVD(&pvdConfig); + + // Configure NVIC for PVD/PVM interrupt + HAL_NVIC_SetPriority(PVD_PVM_IRQn, 0, 0); // Highest priority + HAL_NVIC_EnableIRQ(PVD_PVM_IRQn); + + HAL_PWR_EnablePVD(); + + #ifdef DEBUG_MODE + DEBUG_SERIAL.println("[Power] PVD enabled at ~2.9V (PWR_PVDLEVEL_6)"); + #endif +} + +// ============================================================================= +// Boot Cause Accessors +// ============================================================================= + +BootCause powerGetBootCause(void) { + return s_bootCause; +} + +const char* powerGetBootCauseString(void) { + switch (s_bootCause) { + case BOOT_CAUSE_BROWNOUT: return "brownout"; + case BOOT_CAUSE_WATCHDOG: return "watchdog"; + case BOOT_CAUSE_NORMAL: return "normal"; + default: return "unknown"; + } +} + +bool powerWasBrownoutReset(void) { + return s_bootCause == BOOT_CAUSE_BROWNOUT; +} + +// ============================================================================= +// Boot-Loop Detection +// ============================================================================= + +bool powerCheckAndHandleBootLoop(void) { + if (!powerWasBrownoutReset()) { + // Not a brownout boot — reset both the SRAM counter and the Notecard + // backed counter (best-effort; Notecard may not be available yet). + s_bootLoopMagic = BOOT_LOOP_SRAM_MAGIC; + s_sramBrownoutCount = 0; + stateResetConsecutiveBrownouts(); + return false; + } + + // Brownout boot detected. + // + // Primary counter: SRAM (reliable at low voltage, no I2C required). + // Secondary counter: Notecard state (best-effort, used for cloud visibility). + // + // Initialise SRAM counter on first use (cold boot from power-off). + if (s_bootLoopMagic != BOOT_LOOP_SRAM_MAGIC) { + s_bootLoopMagic = BOOT_LOOP_SRAM_MAGIC; + s_sramBrownoutCount = 0; + } + + if (s_sramBrownoutCount < 255) { + s_sramBrownoutCount++; + } + + // Mirror into state (best-effort — may fail if Notecard I2C unreliable). + stateIncrementConsecutiveBrownouts(); + + uint8_t count = s_sramBrownoutCount; // Use SRAM as authoritative count + + #ifdef DEBUG_MODE + DEBUG_SERIAL.print("[Power] Consecutive brownout boots (SRAM): "); + DEBUG_SERIAL.println(count); + #endif + + if (count < BOOT_LOOP_MAX_COUNT) { + // Not yet at threshold — allow normal boot + return false; + } + + // Boot-loop threshold reached — enter low-power hold. + DEBUG_SERIAL.print("[Power] Boot-loop detected ("); + DEBUG_SERIAL.print(count); + DEBUG_SERIAL.print(" consecutive brownouts). Holding "); + DEBUG_SERIAL.print(BOOT_LOOP_HOLD_SEC); + DEBUG_SERIAL.println("s to allow battery recovery..."); + + // Reset both counters so the next boot after the hold starts fresh. + s_sramBrownoutCount = 0; + stateResetConsecutiveBrownouts(); + + // Hold in a simple delay loop. The STM32 HAL delay is sufficient here + // because FreeRTOS hasn't started yet. LED blinks to indicate hold state. + uint32_t holdMs = (uint32_t)BOOT_LOOP_HOLD_SEC * 1000UL; + uint32_t start = millis(); + bool ledState = false; + + while (millis() - start < holdMs) { + // Blink built-in LED slowly to indicate hold + ledState = !ledState; + digitalWrite(LED_PIN, ledState ? HIGH : LOW); + delay(500); + } + + digitalWrite(LED_PIN, LOW); + + DEBUG_SERIAL.println("[Power] Boot-loop hold complete. Resuming boot."); + return true; +} + +// ============================================================================= +// PVD Interrupt Handler +// ============================================================================= + +/** + * @brief PVD/PVM interrupt handler + * + * Called by the NVIC when VDD crosses the PVD threshold (falling edge = low). + * MUST NOT perform any blocking operations — only set a flag for task context. + */ +extern "C" void PVD_PVM_IRQHandler(void) { + HAL_PWR_PVD_PVM_IRQHandler(); +} + +/** + * @brief PVD callback — called from HAL_PWR_PVD_IRQHandler() + * + * Override the weak HAL default. Sets the global shutdown flag. + */ +extern "C" void HAL_PWR_PVDCallback(void) { + g_pvdShutdownRequested = true; +} diff --git a/songbird-firmware/src/core/SongbirdPower.h b/songbird-firmware/src/core/SongbirdPower.h new file mode 100644 index 0000000..7878244 --- /dev/null +++ b/songbird-firmware/src/core/SongbirdPower.h @@ -0,0 +1,92 @@ +/** + * @file SongbirdPower.h + * @brief Power monitoring interface for Songbird + * + * Provides: + * - PVD (Programmable Voltage Detector) initialization and ISR + * for early-warning brownout detection (~2.9V threshold) + * - Boot cause detection via RCC->CSR reset flags + * - Boot-loop detection and prevention logic + * + * Design: + * - PVD ISR sets a volatile flag (g_pvdShutdownRequested) only — + * no blocking operations in interrupt context. + * - MainTask polls the flag and executes the safe shutdown sequence. + * - Boot cause is determined before FreeRTOS starts, stored in state, + * and optionally reported to Notehub after connection. + * + * Songbird - Blues Sales Demo Device + * Copyright (c) 2025 Blues Inc. + */ + +#ifndef SONGBIRD_POWER_H +#define SONGBIRD_POWER_H + +#include +#include "SongbirdConfig.h" + +// ============================================================================= +// Boot Cause +// ============================================================================= + +typedef enum { + BOOT_CAUSE_NORMAL = 0, // Pin reset, power-on, or software reset + BOOT_CAUSE_BROWNOUT, // BOR (Brown-Out Reset) detected + BOOT_CAUSE_WATCHDOG, // Independent watchdog reset + BOOT_CAUSE_UNKNOWN // Could not determine +} BootCause; + +// ============================================================================= +// Power Module Interface +// ============================================================================= + +/** + * @brief Initialize the power monitoring subsystem + * + * - Reads and clears RCC->CSR reset flags (call before clearing elsewhere) + * - Configures and enables PVD at PVD_SHUTDOWN_LEVEL (~2.9V) + * - Enables NVIC for PVD_PVM_IRQn + * + * Must be called early in setup(), before FreeRTOS scheduler starts. + * Does NOT require I2C mutex (no Notecard access). + */ +void powerInit(void); + +/** + * @brief Get the boot cause determined at startup + * + * Valid after powerInit() has been called. + * + * @return Boot cause enum value + */ +BootCause powerGetBootCause(void); + +/** + * @brief Get the boot cause as a short string for logging + * + * @return "brownout", "watchdog", "normal", or "unknown" + */ +const char* powerGetBootCauseString(void); + +/** + * @brief Check if this boot was caused by a brownout reset + * + * @return true if RCC_CSR_BORRSTF was set at startup + */ +bool powerWasBrownoutReset(void); + +/** + * @brief Check whether boot-loop condition is detected and handle it + * + * Should be called from setup() after state is loaded but before + * FreeRTOS tasks are created. If a boot-loop is detected, this function + * enters a low-power hold for BOOT_LOOP_HOLD_SEC seconds. + * + * A boot-loop is defined as BOOT_LOOP_MAX_COUNT consecutive brownout + * resets within BOOT_LOOP_WINDOW_SEC seconds. + * + * @return true if a boot-loop was detected (hold was entered) + */ +bool powerCheckAndHandleBootLoop(void); + +#endif // SONGBIRD_POWER_H diff --git a/songbird-firmware/src/core/SongbirdState.cpp b/songbird-firmware/src/core/SongbirdState.cpp index 0bb3973..9fb372c 100644 --- a/songbird-firmware/src/core/SongbirdState.cpp +++ b/songbird-firmware/src/core/SongbirdState.cpp @@ -68,6 +68,12 @@ void stateInit(void) { s_state.gpsActiveStartTime = 0; s_state.lastGpsRetryTime = 0; + // Brownout / Power Management + s_state.lastBootTimestamp = 0; + s_state.consecutiveBrownouts = 0; + strncpy(s_state.lastShutdownReason, "unknown", sizeof(s_state.lastShutdownReason) - 1); + s_state.lastShutdownReason[sizeof(s_state.lastShutdownReason) - 1] = '\0'; + s_bootStartTime = millis(); s_warmBoot = false; @@ -352,3 +358,39 @@ bool stateValidateChecksum(const SongbirdState* state) { uint32_t calculated = stateCalculateChecksum(state); return calculated == state->checksum; } + +// ============================================================================= +// Brownout / Power Management +// ============================================================================= + +void stateSetShutdownReason(const char* reason) { + if (reason == NULL) return; + strncpy(s_state.lastShutdownReason, reason, sizeof(s_state.lastShutdownReason) - 1); + s_state.lastShutdownReason[sizeof(s_state.lastShutdownReason) - 1] = '\0'; +} + +const char* stateGetShutdownReason(void) { + return s_state.lastShutdownReason; +} + +void stateIncrementConsecutiveBrownouts(void) { + if (s_state.consecutiveBrownouts < 255) { + s_state.consecutiveBrownouts++; + } +} + +void stateResetConsecutiveBrownouts(void) { + s_state.consecutiveBrownouts = 0; +} + +uint8_t stateGetConsecutiveBrownouts(void) { + return s_state.consecutiveBrownouts; +} + +void stateRecordBootTimestamp(void) { + s_state.lastBootTimestamp = millis(); +} + +uint32_t stateGetBootTimestamp(void) { + return s_state.lastBootTimestamp; +} diff --git a/songbird-firmware/src/core/SongbirdState.h b/songbird-firmware/src/core/SongbirdState.h index ea66590..44298b5 100644 --- a/songbird-firmware/src/core/SongbirdState.h +++ b/songbird-firmware/src/core/SongbirdState.h @@ -21,7 +21,7 @@ // Magic number to validate state data #define STATE_MAGIC 0x534F4E47 // "SONG" -#define STATE_VERSION 4 +#define STATE_VERSION 5 /** * @brief Persistent state structure @@ -52,7 +52,11 @@ typedef struct { uint32_t gpsActiveStartTime; // millis() when GPS became active without signal uint32_t lastGpsRetryTime; // millis() when GPS was last re-enabled for retry - uint8_t reserved[1]; // Reserved for future use + // Brownout / Power Management (v5) + uint32_t lastBootTimestamp; // millis() at boot start (used for boot-loop detection) + uint8_t consecutiveBrownouts; // Number of back-to-back brownout resets + char lastShutdownReason[16]; // "pvd", "brownout", "normal", "unknown" + uint32_t checksum; // CRC32 checksum } SongbirdState; @@ -308,6 +312,53 @@ void stateSetGpsActiveStartTime(uint32_t time); */ uint32_t stateGetGpsActiveStartTime(void); +// ============================================================================= +// Brownout / Power Management +// ============================================================================= + +/** + * @brief Set the last shutdown reason string + * + * @param reason Short string: "pvd", "brownout", "normal", "unknown" + */ +void stateSetShutdownReason(const char* reason); + +/** + * @brief Get the last shutdown reason string + * + * @return Pointer to reason string + */ +const char* stateGetShutdownReason(void); + +/** + * @brief Increment the consecutive brownout counter + */ +void stateIncrementConsecutiveBrownouts(void); + +/** + * @brief Reset the consecutive brownout counter to zero + */ +void stateResetConsecutiveBrownouts(void); + +/** + * @brief Get the consecutive brownout count + * + * @return Number of back-to-back brownout resets + */ +uint8_t stateGetConsecutiveBrownouts(void); + +/** + * @brief Record the current boot timestamp (call at top of setup()) + */ +void stateRecordBootTimestamp(void); + +/** + * @brief Get the boot timestamp recorded this session + * + * @return millis() value at time of boot, or 0 if not recorded + */ +uint32_t stateGetBootTimestamp(void); + // ============================================================================= // Checksum // ============================================================================= diff --git a/songbird-firmware/src/main.cpp b/songbird-firmware/src/main.cpp index 11c6e8f..8d6b790 100644 --- a/songbird-firmware/src/main.cpp +++ b/songbird-firmware/src/main.cpp @@ -35,6 +35,7 @@ // Songbird modules #include "SongbirdConfig.h" +#include "SongbirdPower.h" // ============================================================================= // Serial Configuration (STLink VCP) @@ -83,6 +84,11 @@ void setup() { pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, HIGH); // LED on during init + // Initialize power monitoring FIRST: + // - Reads and clears RCC->CSR reset flags (must happen before anything else clears them) + // - Enables PVD early-warning interrupt (~2.9V threshold) + powerInit(); + pinMode(BUTTON_PIN, INPUT_PULLUP); // External panel mount button pinMode(BUTTON_PIN_ALT, INPUT_PULLUP); // Internal Cygnet button (backup) pinMode(LOCK_LED_PIN, OUTPUT); @@ -128,6 +134,43 @@ void setup() { DEBUG_SERIAL.println("[Init] Notecard initialized"); } + // Attempt to restore persistent state so boot-loop counter is available. + // This is a lightweight pre-check; MainTask will do a full restore later. + // If restore fails (cold boot), state is freshly initialized below. + bool preRestored = stateRestore(); + if (!preRestored) { + stateInit(); + } + + // Record boot timestamp and update boot cause in state + stateRecordBootTimestamp(); + stateSetShutdownReason(powerGetBootCauseString()); + + // Check for and handle brownout boot-loop condition. + // If triggered, this blocks for BOOT_LOOP_HOLD_SEC to allow battery recovery. + bool bootLoopDetected = powerCheckAndHandleBootLoop(); + (void)bootLoopDetected; // handled internally; flag is informational + + DEBUG_SERIAL.print("[Init] Boot cause: "); + DEBUG_SERIAL.println(powerGetBootCauseString()); + + // Check if PVD fired during setup() before the scheduler started. + // If voltage is already at the brownout threshold before RTOS even launches, + // attempt a minimal emergency shutdown rather than starting all 6 tasks. + if (g_pvdShutdownRequested) { + DEBUG_SERIAL.println("[Power] PVD fired during setup! Emergency shutdown..."); + // State was already loaded/initialized above — save it now. + // Wire is already at 400kHz; Notecard was already initialized. + stateSetShutdownReason("pvd_early"); + stateSave(); + notecardEnterSleep(); + // If power wasn't cut, blink and wait for BOR. + while (1) { + digitalWrite(LED_PIN, !digitalRead(LED_PIN)); + delay(200); + } + } + // Initialize synchronization primitives if (!syncInit()) { DEBUG_SERIAL.println("[Init] ERROR: Sync init failed!"); diff --git a/songbird-firmware/src/notecard/SongbirdNotecard.cpp b/songbird-firmware/src/notecard/SongbirdNotecard.cpp index cbe8c50..373f139 100644 --- a/songbird-firmware/src/notecard/SongbirdNotecard.cpp +++ b/songbird-firmware/src/notecard/SongbirdNotecard.cpp @@ -538,6 +538,40 @@ bool notecardSendHealthNote(const HealthData* health) { return true; } +bool notecardSendShutdownNote(float voltage, const char* reason) { + if (!s_initialized || reason == NULL) { + return false; + } + + J* req = s_notecard.newRequest("note.add"); + JAddStringToObject(req, "file", NOTEFILE_HEALTH); + JAddBoolToObject(req, "sync", true); // Force immediate sync + + J* body = JCreateObject(); + JAddStringToObject(body, "shutdown", reason); + if (voltage > 0) { + JAddNumberToObject(body, "voltage", voltage); + } + JAddNumberToObject(body, "uptime_sec", (uint32_t)(millis() / 1000)); + JAddItemToObject(req, "body", body); + + J* rsp = s_notecard.requestAndResponse(req); + if (rsp == NULL || s_notecard.responseError(rsp)) { + if (rsp) s_notecard.deleteResponse(rsp); + NC_ERROR(); + return false; + } + + s_notecard.deleteResponse(rsp); + + #ifdef DEBUG_MODE + DEBUG_SERIAL.print("[Notecard] Shutdown note sent: "); + DEBUG_SERIAL.println(reason); + #endif + + return true; +} + // ============================================================================= // Command Reception // ============================================================================= diff --git a/songbird-firmware/src/notecard/SongbirdNotecard.h b/songbird-firmware/src/notecard/SongbirdNotecard.h index ffffe82..a89c149 100644 --- a/songbird-firmware/src/notecard/SongbirdNotecard.h +++ b/songbird-firmware/src/notecard/SongbirdNotecard.h @@ -152,6 +152,19 @@ bool notecardSendCommandAck(const CommandAck* ack); */ bool notecardSendHealthNote(const HealthData* health); +/** + * @brief Send a shutdown note to health.qo with reason and voltage + * + * Called during PVD safe shutdown or brownout boot detection to record + * the event in Notehub. Forces immediate sync. + * Caller must hold I2C mutex. + * + * @param voltage Battery voltage at time of shutdown (0 if unavailable) + * @param reason Short reason string: "pvd_low_battery", "boot_loop", etc. + * @return true if note sent successfully + */ +bool notecardSendShutdownNote(float voltage, const char* reason); + // ============================================================================= // Command Reception // ============================================================================= diff --git a/songbird-firmware/src/rtos/SongbirdSync.h b/songbird-firmware/src/rtos/SongbirdSync.h index 0f86e0b..87f19d7 100644 --- a/songbird-firmware/src/rtos/SongbirdSync.h +++ b/songbird-firmware/src/rtos/SongbirdSync.h @@ -109,6 +109,7 @@ extern EventGroupHandle_t g_sleepEvent; // Coordinates deep sleep extern volatile bool g_sleepRequested; // Set by MainTask to request sleep extern volatile bool g_systemReady; // Set when all tasks initialized +extern volatile bool g_pvdShutdownRequested; // Set by PVD ISR when voltage drops below ~2.9V // ============================================================================= // Function Declarations diff --git a/songbird-firmware/src/rtos/SongbirdTasks.cpp b/songbird-firmware/src/rtos/SongbirdTasks.cpp index ab589e1..4473474 100644 --- a/songbird-firmware/src/rtos/SongbirdTasks.cpp +++ b/songbird-firmware/src/rtos/SongbirdTasks.cpp @@ -15,6 +15,7 @@ #include "SongbirdEnv.h" #include "SongbirdCommands.h" #include "SongbirdState.h" +#include "SongbirdPower.h" // ============================================================================= // Task Handles @@ -95,6 +96,99 @@ static void queueImmediateTrackNote(OperatingMode mode) { } } +// ============================================================================= +// PVD Safe Shutdown +// ============================================================================= + +/** + * @brief Execute safe shutdown sequence when PVD fires (voltage ~2.9V) + * + * Called from MainTask context (not ISR). Performs a time-bounded + * coordinated shutdown: + * 1. Play single low-battery warning tone + * 2. Stop sensor reads / locate sequences + * 3. Drain pending notes (up to PVD_QUEUE_DRAIN_LIMIT or deadline) + * 4. Send health.qo shutdown note + * 5. Save state, enter Notecard sleep + * + * After this function the device should power down via the Notecard ATTN/EN + * mechanism. If that fails, the BOR will eventually reset the MCU. + * + * Must be called while holding no mutexes. + */ +static void pvdSafeShutdown(void) { + DEBUG_SERIAL.println("[Power] PVD fired! Safe shutdown starting..."); + + // Reserve the last 600ms strictly for stateSave() + notecardEnterSleep(). + // Note queue drain and shutdown note must finish before queueDrainDeadline. + uint32_t deadline = millis() + PVD_SHUTDOWN_NOTE_TIMEOUT_MS; + uint32_t queueDrainDeadline = deadline - 600; + + // 1. Queue a low-battery warning event for AudioTask (non-blocking — does not + // touch I2C from this context, avoiding mutex contention with AudioTask). + // Stop any active locate sequence first so AudioTask doesn't keep the buzzer busy. + audioStopLocate(); + audioQueueEvent(AUDIO_EVENT_LOW_BATTERY); + // Give AudioTask a brief window to play the tone before we monopolise I2C. + vTaskDelay(pdMS_TO_TICKS(50)); + + // 2. Drain pending notes from the queue (bounded by limit AND deadline). + // Use time-remaining for all per-item mutex timeouts so we don't overshoot. + NoteQueueItem item; + uint8_t drained = 0; + while (drained < PVD_QUEUE_DRAIN_LIMIT && millis() < queueDrainDeadline) { + uint32_t remaining = queueDrainDeadline - millis(); + if (!syncReceiveNote(&item, MIN(200, remaining))) break; + + remaining = queueDrainDeadline - millis(); + if (remaining < 100) break; // Not enough time left + if (syncAcquireI2C(MIN(400, remaining - 50))) { + switch (item.type) { + case NOTE_TYPE_TRACK: + notecardSendTrackNote(&item.data.track, s_currentConfig.mode, true); + break; + case NOTE_TYPE_ALERT: + notecardSendAlertNote(&item.data.alert); + break; + case NOTE_TYPE_CMD_ACK: + notecardSendCommandAck(&item.data.ack); + break; + case NOTE_TYPE_HEALTH: + notecardSendHealthNote(&item.data.health); + break; + } + syncReleaseI2C(); + drained++; + } + } + + // 3. Send shutdown health note with current voltage (if budget allows). + if (millis() < queueDrainDeadline) { + if (syncAcquireI2C(MIN(400, queueDrainDeadline - millis()))) { + float voltage = notecardGetVoltage(NULL); + notecardSendShutdownNote(voltage, "pvd_low_battery"); + syncReleaseI2C(); + } + } + + // 4. Save state and enter Notecard sleep (reserved 600ms budget). + // Use a generous mutex timeout — this is the critical path. + if (syncAcquireI2C(500)) { + stateSetShutdownReason("pvd"); + stateSave(); + notecardEnterSleep(); + // notecardEnterSleep() cuts power via ATTN/EN — should not return + syncReleaseI2C(); + } + + // If Notecard sleep didn't cut power (e.g. no ATTN/EN wired), spin and + // wait for BOR to reset the MCU. + DEBUG_SERIAL.println("[Power] Waiting for power loss..."); + while (1) { + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + // ============================================================================= // Task Creation // ============================================================================= @@ -241,18 +335,17 @@ void MainTask(void* pvParameters) { // during startup when we hold I2C for extended Notecard operations audioPlayEvent(AUDIO_EVENT_POWER_ON, s_currentConfig.audioVolume); - // Try to restore state from previous sleep - bool warmBoot = false; - if (syncAcquireI2C(I2C_MUTEX_TIMEOUT_MS)) { - warmBoot = stateRestore(); - syncReleaseI2C(); - } + // State was already loaded/initialized in setup() before FreeRTOS started. + // Use stateIsWarmBoot() to determine cold vs. warm boot path. + bool warmBoot = stateIsWarmBoot(); - if (!warmBoot) { - // Cold boot - initialize state - stateInit(); + // Check PVD flag before any long blocking operation — the flag may have been + // set during setup() just before the scheduler started, and this is the + // earliest point MainTask can act on it. + if (g_pvdShutdownRequested) { pvdSafeShutdown(); } - // Configure Notecard (only on cold boot) + if (!warmBoot) { + // Cold boot - configure Notecard (only on cold boot) // Note: GPS and tracking are configured inside notecardConfigure() if (syncAcquireI2C(I2C_MUTEX_TIMEOUT_MS)) { notecardConfigure(s_currentConfig.mode); @@ -264,6 +357,9 @@ void MainTask(void* pvParameters) { s_currentConfig.mode = stateGet()->currentMode; } + // Check PVD again before notecardWaitConnection() which can block up to 30s. + if (g_pvdShutdownRequested) { pvdSafeShutdown(); } + // Wait for Notehub connection bool connected = false; if (syncAcquireI2C(I2C_MUTEX_TIMEOUT_MS)) { @@ -274,6 +370,18 @@ void MainTask(void* pvParameters) { if (connected) { // Play connected melody directly (not queued) to avoid mutex contention audioPlayEvent(AUDIO_EVENT_CONNECTED, s_currentConfig.audioVolume); + + // If this was a brownout reset, log it to Notehub now that we're connected + if (powerWasBrownoutReset()) { + if (syncAcquireI2C(I2C_MUTEX_TIMEOUT_MS)) { + float voltage = notecardGetVoltage(NULL); + notecardSendShutdownNote(voltage, "brownout_reset"); + syncReleaseI2C(); + } + #ifdef DEBUG_MODE + DEBUG_SERIAL.println("[MainTask] Brownout reset logged to Notehub"); + #endif + } } // Fetch initial configuration from environment variables @@ -314,6 +422,14 @@ void MainTask(void* pvParameters) { // Main loop for (;;) { + // Check for PVD low-voltage shutdown request (highest priority) + // Flag is set by PVD ISR in SongbirdPower.cpp — handle before anything else + if (g_pvdShutdownRequested) { + pvdSafeShutdown(); + // Should not return — but clear flag defensively + g_pvdShutdownRequested = false; + } + // Check for configuration updates from EnvTask SongbirdConfig newConfig; if (syncReceiveConfig(&newConfig)) {