Skip to content

SD format page: survive the WDT across a soft reset, verify the mount, show the boot cause - #163

Merged
TheAngryRaven merged 1 commit into
BETAfrom
claude/sd-format-code-issue-mlctto
Sep 12, 2026
Merged

TheAngryRaven merged 1 commit into
BETAfrom
claude/sd-format-code-issue-mlctto

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Summary

Field report (2026-09): a newly wired unit claimed its SD card was unformatted, the on-device format reported OK and rebooted, and the same page came straight back. A full power cycle fixed it with no further format, so the format had worked and the boot after it had not.

Root cause: the nRF52 hardware watchdog survives NVIC_SystemReset(). Only a pin, brown-out, power-on or System OFF reset clears it. Every firmware-initiated reboot (transfer exits, OTA, the format page, the reboot combo) therefore hands the next boot a running ~4 s WDT that setup() never fed until wdtSetup() at its very end. A clean boot fits in the budget; a slow SD init (SdFat's 2 s ACMD41 timeout, three attempts) does not, and the WDT reset the device mid-SD-transaction. With CS grounded and no power switch, nothing in firmware can reset the card afterwards, so every following soft boot found "card answers, no FAT volume" and offered to format a good card.

Four changes, all QOL-safe on a healthy unit:

  • WDT carry-over. setup() now calls wdtBootCheck() first: a WDT already running is fed immediately and wdtCarriedOver is set. setup() pets between every slow step (display delays, each SD.begin() attempt, the probe settle, GPS probe, camera/egg/strip init). Those pets are no-ops on a clean boot. wdtSetup() leaves a running, register-locked WDT alone instead of silently failing to reconfigure it.
  • Post-format mount is verified. sdPerformFormat() requires the fresh volume to mount before "Format OK". A mount failure stays on the confirm page as Formatted: no mount / Power-cycle the unit instead of rebooting into an identical loop. sdFormatLastFailed becomes sdFormatFailure (NONE / ERASE / MOUNT).
  • The boot probe needs consecutive evidence. SD_SETUP() re-probes the volume once after a 250 ms settle and only declares the card unformatted when the card layer answered both times and the volume mounted neither time. A card-layer flap between probes is "dead" (FAULT, never an erase offer). The rule is the new host-tested sd_probe unit. The recovered-on-probe path also records the active SPI clock it had been leaving at 0.
  • Boot cause is visible. wake_cause::shortName() puts the cause on the debug line, on the format page's last line (boot:WDT err:20, with SdFat's last card error code) and on the SD FAULT page. A WDT tag there is the confirming evidence for this failure mode.

Type of change

  • Bug fix (no user-visible behavior change beyond the fix)
  • New feature / behavior
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

How it was verified

  • Host unit tests pass (ctest --test-dir tests/build): 623 cases, 327632 assertions, including the new sd_probe and wake_cause::shortName tests
  • clang-tidy clean on sd_probe.cpp and wake_cause.cpp
  • Native simulator builds with the edited .ino sources and its full suite passes (boot soak, determinism, golden fixtures unchanged, both lap oracles, two-session carryover)
  • Compiles for the XIAO nRF52840 Sense (CI)
  • Tested on real hardware. Not possible in this session: the failure needs a unit that boots slowly after a soft reset. The change is defensive on a healthy unit (extra WDT pets are no-ops when no WDT is running), and the diagnostic line makes the next field occurrence self-explaining.

Checklist

  • CHANGELOG.md updated under [Unreleased]
  • ARCHITECTURE.md / CLAUDE.md updated (file map, subsystem 4 format flow, subsystem 10 WDT note, key constants)
  • New testable logic has a matching test in tests/ (sd_probe_test.cpp, wake_cause_test.cpp)
  • Branch is focused

Related issues

None filed. Reference for the WDT behaviour: Nordic DevZone, "use initialized WDT after software reset on a NRF52" (a Nordic engineer: "Soft reset doesn't clear WDT").

🤖 Generated with Claude Code

https://claude.ai/code/session_01VhDTiCUTGJhV7XvogdU9CQ


Generated by Claude Code

…, show the boot cause

Field report (2026-09): a new unit claimed its SD card was unformatted,
the on-device format reported OK and rebooted, and the same page came
straight back — until a full power cycle, after which the card mounted
fine with no further format. The format had worked; the boot after it
had not.

Root cause: the nRF52 hardware watchdog survives NVIC_SystemReset()
(only a pin, brown-out, power-on or System OFF reset clears it). Every
firmware-initiated reboot therefore hands the next boot a running ~4 s
WDT that setup() never fed until wdtSetup() at its very end. A clean
boot fits; a slow SD init (SdFat's 2 s ACMD41 timeout, three attempts)
does not, and the WDT reset the device mid-SD-transaction — leaving a
card that firmware cannot reset (CS grounded, no power switch) reading
as "card answers, no FAT volume" on every following soft boot.

- setup() now calls wdtBootCheck() first: a WDT already running is fed
  immediately (wdtCarriedOver), setup() pets between every slow step
  (display delays, each SD.begin() attempt, the probe settle, GPS
  probe, camera/egg/strip init — all no-ops on a clean boot), and
  wdtSetup() leaves a running, register-locked WDT alone.
- sdPerformFormat() requires the fresh volume to mount before "Format
  OK": a mount failure stays on the confirm page as "Formatted: no
  mount / Power-cycle the unit" instead of rebooting into an identical
  loop. sdFormatLastFailed becomes sdFormatFailure (NONE/ERASE/MOUNT).
- SD_SETUP() re-probes the volume once after a 250 ms settle and only
  declares the card unformatted on consecutive no-volume probes with
  the card layer answering both times; a card-layer flap is "dead"
  (FAULT, never an erase offer). The rule is the new host-tested
  sd_probe unit. The recovered-on-probe path also records the SPI
  clock it had been leaving at 0.
- wake_cause::shortName() puts the boot cause on the debug line, the
  format page's last line (boot:WDT err:20, with SdFat's error code)
  and the SD FAULT page, so a watchdog-induced loop is visible.

Sim: sd_probe added to the core sources, sdErrorCode()/sdErrorData()
stubbed on the SdFat shim, wdtBootCheck prototype added. Goldens
unchanged (the GPS status page is untouched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhDTiCUTGJhV7XvogdU9CQ
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 2174/2208 (98.5%)
Functions 🟢 222/223 (99.6%)
Branches 🟢 1591/1762 (90.3%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 213/221 (96.4%) 🟢 21/21 (100.0%) 🟡 119/136 (87.5%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/drag_timer.cpp 🟢 150/154 (97.4%) 🟢 10/10 (100.0%) 🟡 76/94 (80.9%)
BirdsEye/drag_tree.cpp 🟢 112/114 (98.2%) 🟡 7/8 (87.5%) 🟢 87/94 (92.6%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 17/17 (100.0%) 🟢 2/2 (100.0%) 🟢 14/14 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 84/84 (100.0%) 🟢 7/7 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 67/68 (98.5%) 🟢 6/6 (100.0%) 🟢 50/52 (96.2%)
BirdsEye/led_status.cpp 🟢 106/108 (98.1%) 🟢 11/11 (100.0%) 🟢 71/75 (94.7%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
BirdsEye/loop_profile.cpp 🟢 65/65 (100.0%) 🟢 7/7 (100.0%) 🟢 35/36 (97.2%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sd_probe.cpp 🟢 6/6 (100.0%) 🟢 1/1 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/sector_purple.cpp 🟢 84/85 (98.8%) 🟢 3/3 (100.0%) 🟡 57/64 (89.1%)
BirdsEye/sensoregg_protocol.cpp 🟢 87/88 (98.9%) 🟢 13/13 (100.0%) 🟢 74/76 (97.4%)
BirdsEye/setting_parse.cpp 🟢 29/30 (96.7%) 🟢 2/2 (100.0%) 🟢 38/42 (90.5%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 91/91 (100.0%) 🟢 13/13 (100.0%) 🟡 72/82 (87.8%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 23/24 (95.8%) 🟢 3/3 (100.0%) 🟢 27/28 (96.4%)

@TheAngryRaven
TheAngryRaven merged commit 1995a51 into BETA Sep 12, 2026
9 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