Skip to content

Commit 24071dd

Browse files
authored
feat(nrf): hardware watchdog with reset-reason and phase breadcrumbs (#139)
* feat(nrf): hardware watchdog with reset-reason and phase breadcrumbs 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 * fix(nrf): watchdog debug instrumentation and reset-reason logging fix 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.
1 parent 4c0de5d commit 24071dd

8 files changed

Lines changed: 1237 additions & 30 deletions

‎docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md‎

Lines changed: 420 additions & 0 deletions
Large diffs are not rendered by default.

‎platformio.ini‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ build_flags =
8484
; build. If it silently stops doing so we are back on SPIM3 with no error. Check
8585
; with: pio run -e nrf52840custom -v 2>&1 | grep -c 'SPI_32MHZ_INTERFACE=1.*SPI\.cpp'
8686
-DSPI_32MHZ_INTERFACE=1
87+
; Hardware watchdog timeout, seconds. 0 disables; valid range is 60..3600
88+
; (enforced by a static_assert in src/watchdog_nrf.cpp).
89+
;
90+
; KNOWN GAP: 120 s is BELOW the single longest span the firmware cannot
91+
; instrument -- a REFRESH_FULL on a 7-colour split-buffer panel sends the init
92+
; sequence to BOTH controllers, and each of its 4 BUSY_WAIT entries can take
93+
; 30 s, i.e. ~240 s inside one bbepRefresh() call. The per-entry-point feed in
94+
; display_service.cpp (see odWatchdogFeed() call sites) still bounds the dog to
95+
; that one call rather than that call plus everything preceding it, but at
96+
; 120 s a HEALTHY refresh on that panel class will trip the watchdog mid-refresh.
97+
; Chosen anyway on 2026-08-03 (see conversation); re-check before shipping to any
98+
; 7-colour split-buffer panel. See docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md
99+
; sections 3.2 and W-2 for the original 300 s derivation.
100+
;
101+
; The three other nRF envs inherit this via ${env:nrf52840custom.build_flags}.
102+
-DOPENDISPLAY_NRF_WDT_S=120
87103
platform = https://github.com/maxgerhardt/platform-nordicnrf52
88104
framework = arduino
89105
board_build.variants_dir = variants
@@ -154,7 +170,7 @@ lib_deps =
154170
bitbank2/FastEPD@^2.2.0
155171
build_flags =
156172
-DTARGET_ESP32
157-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
173+
-DOPENDISPLAY_ENABLE_WIFI ; LAN transport + ROM tinfl. Set ONLY on -DBOARD_HAS_PSRAM envs (src/wifi_service.h)
158174
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
159175
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
160176
-DBOARD_HAS_PSRAM
@@ -184,7 +200,7 @@ lib_deps =
184200
bitbank2/FastEPD@^2.2.0
185201
build_flags =
186202
-DTARGET_ESP32
187-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
203+
-DOPENDISPLAY_ENABLE_WIFI ; LAN transport + ROM tinfl. Set ONLY on -DBOARD_HAS_PSRAM envs (src/wifi_service.h)
188204
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
189205
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
190206
-DBOARD_HAS_PSRAM
@@ -213,7 +229,7 @@ lib_deps =
213229
bitbank2/FastEPD@^2.2.0
214230
build_flags =
215231
-DTARGET_ESP32
216-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
232+
-DOPENDISPLAY_ENABLE_WIFI ; LAN transport + ROM tinfl. Set ONLY on -DBOARD_HAS_PSRAM envs (src/wifi_service.h)
217233
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
218234
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
219235
-DBOARD_HAS_PSRAM
@@ -242,7 +258,7 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
242258
framework = arduino
243259
build_flags =
244260
-DTARGET_ESP32
245-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
261+
-DOPENDISPLAY_ENABLE_WIFI ; LAN transport + ROM tinfl. Set ONLY on -DBOARD_HAS_PSRAM envs (src/wifi_service.h)
246262
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
247263
-DBOARD_HAS_PSRAM
248264
-DARDUINO_USB_MODE=0
@@ -291,7 +307,8 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
291307
framework = arduino
292308
build_flags =
293309
-DTARGET_ESP32
294-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
310+
; -DOPENDISPLAY_ENABLE_WIFI deliberately absent: no PSRAM here (see src/wifi_service.h).
311+
; BLE-only, and the inflate engine falls back to uzlib.
295312
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
296313
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
297314
-DARDUINO_USB_MODE=1
@@ -311,7 +328,8 @@ extra_scripts =
311328
post:scripts/esp32c6_nimble_mempool_link.py
312329
build_flags =
313330
-DTARGET_ESP32
314-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
331+
; -DOPENDISPLAY_ENABLE_WIFI deliberately absent: no PSRAM here (see src/wifi_service.h).
332+
; BLE-only, and the inflate engine falls back to uzlib.
315333
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
316334
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
317335
-DARDUINO_USB_MODE=1
@@ -331,7 +349,8 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
331349
framework = arduino
332350
build_flags =
333351
-DTARGET_ESP32
334-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
352+
; -DOPENDISPLAY_ENABLE_WIFI deliberately absent: no PSRAM here (see src/wifi_service.h).
353+
; BLE-only, and the inflate engine falls back to uzlib.
335354
#-DOPENDISPLAY_ZLIB_WINDOW_BITS=15
336355
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
337356
-DARDUINO_USB_MODE=1
@@ -355,7 +374,7 @@ lib_deps =
355374
bitbank2/FastEPD@^2.2.0
356375
build_flags =
357376
-DTARGET_ESP32
358-
-DOPENDISPLAY_ENABLE_WIFI ; WiFi/LAN transport: S3 + C6 + C3 (classic ESP32 excluded)
377+
-DOPENDISPLAY_ENABLE_WIFI ; LAN transport + ROM tinfl. Set ONLY on -DBOARD_HAS_PSRAM envs (src/wifi_service.h)
359378
-DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=1
360379
-DBOARD_HAS_PSRAM
361380
-DARDUINO_USB_MODE=1

‎src/boot_screen.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "boot_screen.h"
2+
#include "watchdog.h"
23
#include <Arduino.h>
34
#include <string.h>
45
#include <stdlib.h>
@@ -510,6 +511,7 @@ static bool bootLayoutFit(uint16_t w, uint16_t h, uint16_t h_full, int blockH, i
510511
// and stream it. planeRow scratch lives just past the 2bpp row in staticRowBuffer
511512
// (200B + 100B for an 800px panel, well within the 680B buffer).
512513
static void writeGray4PlaneRow(const uint8_t* row2bpp, int pitch2bpp, int planePitch, uint16_t w, int bitSel) {
514+
odWatchdogBreadcrumb(OD_WDT_PHASE_STREAM);
513515
uint8_t* planeRow = staticRowBuffer + pitch2bpp;
514516
memset(planeRow, 0x00, planePitch);
515517
for (uint16_t x = 0; x < w; x++) {
@@ -1031,6 +1033,7 @@ bool writeBootScreenWithQr() {
10311033
} else if (e1004Stream) {
10321034
e1004_write_stream_bytes(row + (size_t)halfPass * e1004HalfPitch, e1004HalfPitch);
10331035
} else {
1036+
odWatchdogBreadcrumb(OD_WDT_PHASE_STREAM);
10341037
bbepWriteData(&bbep, row, pitch);
10351038
}
10361039
#else
@@ -1039,6 +1042,7 @@ bool writeBootScreenWithQr() {
10391042
} else if (e1004Stream) {
10401043
e1004_write_stream_bytes(row + (size_t)halfPass * e1004HalfPitch, e1004HalfPitch);
10411044
} else {
1045+
odWatchdogBreadcrumb(OD_WDT_PHASE_STREAM);
10421046
bbepWriteData(&bbep, row, pitch);
10431047
}
10441048
#endif
@@ -1058,6 +1062,7 @@ bool writeBootScreenWithQr() {
10581062
memset(row, 0x00, pitch);
10591063
bbepSetAddrWindow(&bbep, 0, 0, w, h);
10601064
bbepStartWrite(&bbep, PLANE_1);
1065+
odWatchdogBreadcrumb(OD_WDT_PHASE_STREAM);
10611066
for (uint16_t y = 0; y < h; y++) bbepWriteData(&bbep, row, pitch);
10621067
}
10631068
}

0 commit comments

Comments
 (0)