Skip to content

feat(nrf): hardware watchdog with reset-reason and phase breadcrumbs - #139

Merged
jonasniesner merged 2 commits into
OpenDisplay:mainfrom
davelee98:feat/nrf-hardware-watchdog
Aug 3, 2026
Merged

feat(nrf): hardware watchdog with reset-reason and phase breadcrumbs#139
jonasniesner merged 2 commits into
OpenDisplay:mainfrom
davelee98:feat/nrf-hardware-watchdog

Conversation

@davelee98

@davelee98 davelee98 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Draft — depends on #135. This branch is stacked on feat/phase4-auth-abuse-disconnect
(PR #135), which hasn't merged into main yet, so the diff currently includes all of
Phases 2-4 in addition to the watchdog work below. Once #135 merges, this diff will shrink
to just the watchdog commits automatically (no rebase needed) — mark ready for review then.

Fork-local stacked PR tracking the same branch, for incremental review in the meantime:
davelee98#10

Summary

  • Adds a hardware watchdog on nRF (portable watchdog.h API, ESP32 stubbed) to recover from unbounded waits below loop() that no software timeout can catch (nrfx SPIM busy-spin, Wire_nRF52 TWIM spins) — see docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md.
  • Reset-reason decode + a retained GPREGRET2 phase breadcrumb, so a watchdog reset's boot log names which panel/pwrmgm phase it froze in instead of just "something reset."
  • 3-strike boot-loop containment: after 3 consecutive watchdog resets, safe mode skips all panel work but keeps BLE/DFU reachable.
  • Timeout is 120 s (OPENDISPLAY_NRF_WDT_S), below this plan's documented 240 s worst-case REFRESH_FULL span on a 7-colour split-buffer panel — a known, deliberate gap, called out in both platformio.ini and a status-update section at the top of the plan doc. Do not ship to that panel class without re-deriving the timeout.
  • pwrmgm() (previously blind to the watchdog) now has 4 breadcrumb phases covering its power-up sub-steps, added after a watchdog reset landed there with no breadcrumb to explain why.
  • WDT-DEBUG-tagged debug logging at every EPD-session and pwrmgm() stage transition (grep "WDT-DEBUG" to strip later).
  • Fixes a real gap where the reset-reason/breadcrumb boot log lines raced USB re-enumeration after a reset and were silently dropped (debug builds only, bounded 2 s).

Test plan

  • pio run -e nrf52840custom and -e nrf52840custom-debug build clean
  • pio run (all 11 default envs) builds clean
  • Not verified on hardware — T2 in the plan doc (measured worst-case refresh span) is the gate before this reaches devices, especially given the 120 s timeout change
  • Confirm safe-mode / strike-counter behavior on a physical reset cycle

nRF had no watchdog, so any unbounded wait was permanent: nrfx_spim.c's
`while (!nrf_spim_event_check(END)){}` and the six Wire_nRF52 TWIM spins
have no timeout and no yield, and checkTransferTimeouts() cannot help
because it runs FROM loop(), which is what is stuck. This makes that
class recoverable -- the residual PLAN_PHASE2_BOUND_WAITS D-L accepted
and D-K assumed unrecoverable.

Portable module, not an nRF-only one: every feed site and breadcrumb
stamp lives in code that compiles for both targets, so an nRF-only API
would mean #ifdef TARGET_NRF around ~20 call sites in shared files.
Follows the ble_transport pattern -- one header with no vendor includes,
two whole-file-gated implementations, ESP32 stubbed. Its reset-reason
decode moves out of main.cpp, a net #ifdef reduction there.

Timeout is 300 s, and the number alone is not what makes it safe. The
longest span the firmware cannot instrument is a REFRESH_FULL on a
7-colour split-buffer panel: 4 BUSY_WAIT entries x 30 s, sent to BOTH
controllers, ~240 s inside one bbepRefresh(). What keeps that from
resetting a healthy device is the feed immediately before all 15
bb_epaper entry points, so the dog faces one call rather than that call
plus everything preceding it. Margin is ~1.25x -- re-check it when
adding a panel.

Three details that are easy to get wrong:

  - RESETREAS must come from readResetReason(). The core reads AND
    clears the register in init() before setup(), so reading the
    peripheral (or sd_power_reset_reason_get) returns zero forever and
    reports every watchdog reset as a power-on.
  - GPREGRET2 needs two access paths. sd_power_gpregret_* are numbered
    from SOC_SVC_BASE_NOT_AVAILABLE and cannot be used before
    ble.begin(); direct register access is correct while the SoftDevice
    is disabled, SVCs once it is enabled.
  - A running WDT cannot be stopped or reconfigured, and which resets
    clear it is NOT established by anything in-tree. So inherit-detection
    via RUNSTATUS runs on every build INCLUDING the disabled one, and
    feeds every enabled RREN channel of whatever it finds. A disabled
    build that inherited a live dog it never fed would be a brick.

Boot-loop containment: armed before the boot panel path so boot wedges
are covered, with a 3-strike counter in GPREGRET2 bits 5:4 entering a
safe mode that refuses panel work at both epdSessionAcquire and pwrmgm.
Strikes clear after 10 min of uptime rather than on a successful
refresh -- refresh-based clearing would never accumulate (every boot
refreshes) and would make safe mode permanent (safe mode never
refreshes).

Not verified on hardware. The 240 s figure is read from bb_ep.inl, not
measured; T2 in the plan is the gate before this reaches devices.
Safe mode rejects transfers late and generically -- a clean NACK needs a
"device in safe mode" code, which must originate in opendisplay-protocol.

Plan, decisions and four rounds of review findings:
docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md
Timeout dropped from 300s to 120s, which is now below the ~240s worst-case
REFRESH_FULL span on a 7-colour split-buffer panel -- a healthy refresh on
that panel class will trip the watchdog mid-refresh. Known gap, documented
in platformio.ini; re-check before shipping to such a panel.

Adds two new breadcrumb phases (IDLE_OFF/IDLE_WARM replacing the shared
IDLE) so a freeze while the panel session is idle can be told apart from
one during keep-alive, plus four more (PWRMGM_AXP2101/RAIL/PINS/WIRE)
instrumenting pwrmgm()'s previously-blind power-up path, added after a
watchdog reset landed there with no breadcrumb to explain why. A
phaseName() lookup makes the retained phase human-readable in the boot
log instead of a bare integer.

WDT-DEBUG-tagged od_log_debug lines pair with each EPD-session and
pwrmgm() breadcrumb (stamped first, since the log call itself can hang on
the same USB CDC mutex delay() depends on) -- grep "WDT-DEBUG" to remove
the whole set later.

Also adds a bounded (2s cap), debug-build-only wait for the USB CDC host
to reconnect before the first log line: without it, the reset-reason and
retained-breadcrumb lines -- the whole point of this feature -- reliably
lose the race against USB re-enumeration after a reset and are silently
discarded by od_log's dark-port check, which doesn't count them as drops.
@davelee98
davelee98 force-pushed the feat/nrf-hardware-watchdog branch from 780a41e to 4b54a88 Compare August 3, 2026 19:15
@davelee98
davelee98 marked this pull request as ready for review August 3, 2026 19:16
@davelee98
davelee98 requested a review from jonasniesner as a code owner August 3, 2026 19:16
@jonasniesner
jonasniesner merged commit 24071dd into OpenDisplay:main Aug 3, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants