diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml
index d3cc6ad..1b02c02 100644
--- a/.github/workflows/compile-sketch.yml
+++ b/.github/workflows/compile-sketch.yml
@@ -22,13 +22,13 @@ jobs:
# it compiled BETA code against master's pins and
# failed on the sprint headers forever.
# ref_name == BETA -> push / workflow_dispatch on BETA itself
- # Anything else is master/release. NOTE: merging BETA into master is
- # what promotes the channel — at that point the fallbacks below must be
- # bumped deliberately (a DovesLapTimer release tag carrying
- # CrossingEngine/SprintTimer, plus a decision on the SensorEgg flag),
- # here and in sim-build.yml / release.yml. (The condition is repeated
- # rather than hoisted into its own env var because a job-level `env:`
- # entry cannot reference its siblings.)
+ # Anything else is master/release. The release pin (v4.3.0) already
+ # carries CrossingEngine/SprintTimer, so BETA source builds against it
+ # too; bump it deliberately — here and in sim-build.yml / release.yml /
+ # BirdsEye/sim/CMakeLists.txt — only when the firmware starts needing
+ # library code newer than the tag. (The condition is repeated rather
+ # than hoisted into its own env var because a job-level `env:` entry
+ # cannot reference its siblings.)
# DovesLapTimer ref: the beta channel tracks the library's own BETA
# branch so the two move together; everything else pins the known-good
# release tag.
@@ -209,17 +209,38 @@ jobs:
# several (the DOVEX row/header shapes and the settings defaults now
# differ by channel). This job closes that hole from the BETA side.
#
- # One board is enough: the two arms here differ only in feature flags,
- # and the sense/non-sense split is already covered above.
- # LAPTIMER_REF deliberately stays BETA so the flags are the ONLY
- # variable — pinning the release tag as well would fail on BETA-era
- # library APIs and tell us nothing about the flags.
+ # Both boards: this arm is also the RELEASE configuration (project.h
+ # defaults, no feature flags), and release.yml ships both images — the
+ # non-Sense board has its own `#if` paths (no IMU) that must compile
+ # with the flags off too.
+ #
+ # Library ref:
+ # head_ref == BETA (the BETA -> master release PR): the release pin,
+ # so this job compiles exactly what release.yml will build — release
+ # library + no flags, both boards. Without it the release PR never
+ # compiled BETA source against the pinned library at all, and a
+ # library dependency newer than the tag first failed on the tag push.
+ # otherwise (feature PRs into BETA, pushes to BETA): BETA, so the flags
+ # are the ONLY variable between this job and the flags-on build above.
+ # Keep the pin in sync with LAPTIMER_REF above and with release.yml.
compile-stock-arm:
- name: xiao-nrf52840-sense (flags off)
+ name: ${{ matrix.board.name }} (flags off)
if: >-
github.base_ref == 'BETA' || github.head_ref == 'BETA' ||
github.ref_name == 'BETA'
runs-on: ubuntu-latest
+ env:
+ STOCK_LAPTIMER_REF: ${{ github.head_ref == 'BETA' && 'v4.3.0' || 'BETA' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ board:
+ - name: xiao-nrf52840-sense
+ fqbn: Seeeduino:nrf52:xiaonRF52840Sense
+ define: BIRDSEYE_BOARD_SENSE
+ - name: xiao-nrf52840
+ fqbn: Seeeduino:nrf52:xiaonRF52840
+ define: BIRDSEYE_BOARD_NONSENSE
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -227,10 +248,13 @@ jobs:
- name: Install adafruit-nrfutil
run: pip install --user adafruit-nrfutil
+ - name: Report configuration
+ run: echo "Flags off, DovesLapTimer ref ${STOCK_LAPTIMER_REF}, board ${{ matrix.board.fqbn }}"
+
- name: Compile sketch with the feature flags off
uses: arduino/compile-sketches@v1
with:
- fqbn: Seeeduino:nrf52:xiaonRF52840Sense
+ fqbn: ${{ matrix.board.fqbn }}
platforms: |
- name: Seeeduino:nrf52
source-url: https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json
@@ -245,9 +269,10 @@ jobs:
- name: ArxTypeTraits
- name: Adafruit NeoPixel
- source-url: https://github.com/TheAngryRaven/DovesLapTimer.git
- version: BETA
+ version: ${{ env.STOCK_LAPTIMER_REF }}
sketch-paths: |
- BirdsEye
+ # Exactly release.yml's extra_flags — nothing else.
cli-compile-flags: |
- --build-property
- - compiler.cpp.extra_flags=-DBIRDSEYE_BOARD_SENSE -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG
+ - compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 91fb3ab..9c8d102 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -46,6 +46,39 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ # The OTA manifest's version comes from the TAG, but the device reports
+ # FIRMWARE_VERSION from project.h over BLE (DIS). If they disagree —
+ # tag v4.2.0 on a tree still saying "4.1.0" — the published image can
+ # never satisfy the manifest and the companion offers the update
+ # forever. Refuse the tag before anything is built or published (the
+ # release + pages jobs need `build`, so a failure here stops both).
+ # Also require a CHANGELOG heading for the version: the release notes
+ # link there. Manual runs have no tag to check and skip this.
+ - name: Verify tag matches FIRMWARE_VERSION and CHANGELOG
+ if: startsWith(github.ref, 'refs/tags/')
+ run: |
+ set -euo pipefail
+ tag_version="${GITHUB_REF_NAME#v}"
+ # The literal in the non-override branch — the only
+ # `#define FIRMWARE_VERSION "..."` form in project.h (the override
+ # branch stringizes a token and never has a quoted literal).
+ matches="$(grep -cE '^[[:space:]]*#define[[:space:]]+FIRMWARE_VERSION[[:space:]]+"' BirdsEye/project.h || true)"
+ if [ "$matches" != "1" ]; then
+ echo "::error file=BirdsEye/project.h::Expected exactly one quoted FIRMWARE_VERSION literal, found ${matches}"
+ exit 1
+ fi
+ fw_version="$(sed -nE 's/^[[:space:]]*#define[[:space:]]+FIRMWARE_VERSION[[:space:]]+"([^"]*)".*/\1/p' BirdsEye/project.h)"
+ echo "Tag: ${GITHUB_REF_NAME} -> ${tag_version}; project.h FIRMWARE_VERSION: ${fw_version}"
+ if [ "$fw_version" != "$tag_version" ]; then
+ echo "::error file=BirdsEye/project.h::FIRMWARE_VERSION is \"${fw_version}\" but the tag is ${GITHUB_REF_NAME} — bump project.h (or re-tag) so the image reports the version the OTA manifest will advertise"
+ exit 1
+ fi
+ if ! awk -v h="## [${tag_version}]" 'index($0, h) == 1 { found = 1 } END { exit !found }' CHANGELOG.md; then
+ echo "::error file=CHANGELOG.md::No '## [${tag_version}]' heading — date the release section before tagging"
+ exit 1
+ fi
+ echo "Version check passed."
+
- name: Set up arduino-cli
uses: arduino/setup-arduino-cli@v2
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index efa339a..fd93905 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -270,7 +270,8 @@ the last resort. That watchdog **survives a soft reset** (only a pin,
brown-out, power-on or System OFF reset clears it), so every
firmware-initiated reboot hands the next boot a running WDT: `setup()`
checks for one first thing (`wdtBootCheck()`) and feeds it between the
-slow boot steps, and `wdtSetup()` leaves a running one alone — its
+slow boot steps (and per entry inside the SD walks that scale with the
+card's contents, like the track-directory scan), and `wdtSetup()` leaves a running one alone — its
registers are locked.
### Shutdown is System OFF, wake is a reboot
diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino
index 9d23a6c..377a91c 100644
--- a/BirdsEye/BirdsEye.ino
+++ b/BirdsEye/BirdsEye.ino
@@ -1049,7 +1049,10 @@ void setup() {
#ifdef HAS_DEBUG
Serial.begin(9600);
- while (!Serial);
+ // Fed while waiting: after a soft reset the carried-over WDT is counting,
+ // and with no terminal attached this wait never ends — unfed, it reset
+ // the device every ~4 s forever.
+ while (!Serial) wdtPet();
#endif
wdtPet();
debug(F("Boot cause: "));
diff --git a/BirdsEye/firmware_ota.ino b/BirdsEye/firmware_ota.ino
index 93f6ee0..5b3ae10 100644
--- a/BirdsEye/firmware_ota.ino
+++ b/BirdsEye/firmware_ota.ino
@@ -404,6 +404,9 @@ static bool fwCrcOfStageFile(uint32_t& outCrc) {
uint32_t state = crc32::kInit;
uint8_t buf[512];
while (true) {
+ // Up to a 408 KiB image read back in 512 B blocks inside one loop()
+ // iteration — seconds at SD speeds, so feed the ~4 s WDT per block.
+ wdtPet();
int r = f.read(buf, sizeof(buf));
if (r < 0) { f.close(); return false; }
if (r == 0) break;
diff --git a/BirdsEye/project.h b/BirdsEye/project.h
index 4d7e4f1..c53f34c 100644
--- a/BirdsEye/project.h
+++ b/BirdsEye/project.h
@@ -18,7 +18,8 @@
// via the Device Information Service (see bluetooth.ino) so a companion
// (DovesDataViewer) can compare it against the latest GitHub release and
// decide whether an OTA update is needed. Keep this in sync with the release
-// git tag (tag v2.0.0 -> "2.0.0") and the CHANGELOG.
+// git tag (tag v2.0.0 -> "2.0.0") and the CHANGELOG — release.yml refuses a
+// tag that disagrees with the quoted literal below.
//
// Beta/nightly builds override it from the build flag: the beta workflow
// passes -DFIRMWARE_VERSION_OVERRIDE=-beta. as a bare token —
diff --git a/BirdsEye/replay.ino b/BirdsEye/replay.ino
index 2338d9b..02bd7b7 100644
--- a/BirdsEye/replay.ino
+++ b/BirdsEye/replay.ino
@@ -75,6 +75,9 @@ bool buildReplayFileList() {
int filesScanned = 0;
while (numReplayFiles < MAX_REPLAY_FILES) {
+ // The walk is capped by .dovex files FOUND, not entries scanned, so a
+ // root full of other files is unbounded — feed the WDT per entry.
+ wdtPet();
File32 entry = root.openNextFile();
if (!entry) {
debug(F("Replay: openNextFile returned null after "));
diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino
index f9867eb..5803e6d 100644
--- a/BirdsEye/sd_functions.ino
+++ b/BirdsEye/sd_functions.ino
@@ -390,6 +390,13 @@ bool scanTrackDir(const char* folder, uint8_t kind) {
// Loop through each file in the directory
while (file.openNext(&trackDir, O_READ)) {
+ // Fed per entry: up to MAX_LOCATIONS files, each an 8 KB read + a full
+ // ArduinoJson parse at the 2 MHz SPI clock, all inside one call. At boot
+ // a WDT carried over a soft reset is already counting (see
+ // wdtBootCheck), and after a BLE track upload/delete the armed ~4 s WDT
+ // is — a big track set or a slow card outlasts it, and the reset lands
+ // mid-SD-read, which is exactly how a card gets wedged.
+ wdtPet();
if (numOfLocations >= MAX_LOCATIONS) {
file.close();
break;
diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino
index dec46f0..9fa2d56 100644
--- a/BirdsEye/settings.ino
+++ b/BirdsEye/settings.ino
@@ -216,6 +216,10 @@ static void ensureDefaultSettings() {
char buf[48];
for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) {
+ // Every key is a full open + read + parse of the file (and a
+ // read-modify-write when missing) — ~25 round trips at 2 MHz inside
+ // SETTINGS_SETUP(), with a WDT carried over a soft reset counting.
+ wdtPet();
if (!getSetting(defaults[i].key, buf, sizeof(buf))) {
setSetting(defaults[i].key, defaults[i].defaultValue);
}
diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt
index 5c65556..4b9fbd3 100644
--- a/BirdsEye/sim/CMakeLists.txt
+++ b/BirdsEye/sim/CMakeLists.txt
@@ -35,7 +35,7 @@ include(FetchContent)
# validates different code than ships — and can fail to link outright when the
# branch gains sources this file doesn't list. That is exactly what happened
# when the library's BETA gained CrossingEngine.cpp while master's sim was
-# hardcoded to BETA: master's firmware pins v4.2.0, its sim tracked BETA.
+# hardcoded to BETA: master's firmware then pinned v4.2.0, its sim tracked BETA.
#
# So: default to the stable tag (master/release policy) and let CI override
# per channel, mirroring LAPTIMER_REF in compile-sketch.yml. sim-build.yml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8e19cb..0924aad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,21 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
(FAULT, no erase offer), and a volume that mounts on the retry boots
normally. The recovered-on-probe path also now records the active SPI
clock it had been leaving at 0.
+- **The long SD walks feed the watchdog.** Building the track list opens
+ and JSON-parses every file under `/TRACKS` and `/TRACKS/SPRINT` (up to
+ 200, 8 KB each, at the 2 MHz SPI clock) in one call; it ran unfed, so
+ a large track set or a slow card on a soft-reset boot — or right after
+ a BLE track upload/delete — could outlast the ~4 s WDT and reset the
+ device mid-SD-read, the same wedge the carried-over-WDT fix above
+ closes. The track scan now feeds it per file, as do the boot settings
+ default check (one file round trip per key), the replay file browser's
+ root walk, and the OTA staged-image CRC read-back.
+- **`HAS_DEBUG` builds no longer boot-loop without a serial terminal
+ after a soft reset.** The `while (!Serial)` wait at the top of
+ `setup()` never fed the carried-over watchdog, so with nothing
+ attached to the USB port it reset the device every ~4 s. It now feeds
+ the WDT while it waits. Developer builds only — shipped images don't
+ define `HAS_DEBUG`.
- **SensorEgg GATT link can no longer wedge "streaming" with no link**
(plan 0018 review). If the egg dropped in the instant between the
bring-up finishing and the main loop committing it, the disconnect was
@@ -153,6 +168,30 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
the run ETs — same backwards-compatible trailing-column scheme as
SPRINT). The run state machine is the host-tested `drag_timer` unit.
+### Changed
+- **CI: stale DovesLapTimer-pin notes corrected.** `compile-sketch.yml`
+ claimed the release pin could not compile BETA source (no tag carrying
+ `CrossingEngine`/`SprintTimer`), and CLAUDE.md said the sim pins the
+ library's `BETA` branch. Neither is true any more: `v4.3.0`'s `src/` is
+ identical to the library's `BETA`, and the sim defaults to `v4.3.0`,
+ overridden to `BETA` only for BETA-targeted builds. Comments and docs
+ only — no build behavior changes.
+- **CI: the release PR now compiles the release configuration.** The
+ flags-off `compile-stock-arm` job builds both boards (it was Sense
+ only), and on the BETA → master PR it links the release library pin
+ (`v4.3.0`) instead of the library's `BETA` — so that PR compiles
+ exactly what `release.yml` will build (release library, no feature
+ flags, both boards). Before, BETA source met the pinned library and the
+ flags-off non-Sense image for the first time on the tag push.
+- **CI: a release tag must match the firmware's own version.** The OTA
+ manifest's version is taken from the git tag, but devices report
+ `FIRMWARE_VERSION` from `project.h` — tagging `v4.2.0` on a tree still
+ saying `4.1.0` published a manifest no updated device could ever
+ satisfy, so the companion app would offer the update forever.
+ `release.yml` now fails the tag build before anything is built or
+ published unless the tag equals `FIRMWARE_VERSION` and `CHANGELOG.md`
+ has a `## [x.y.z]` heading for it.
+
## [4.1.0] - 2026-08-24
MINOR — new settings and device behaviour, backwards compatible with the
diff --git a/CLAUDE.md b/CLAUDE.md
index 284e970..fd3f543 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -192,13 +192,13 @@ handoff spec.
| `wasm/birdseye-sim.mjs` | Hand-written public ESM wrapper (stable import; async `reset()` re-instantiates the core module) |
| `wasm/test.html` | Standalone browser harness: canvas blit (hash dirty-check), buttons, dovex file playback (≥13 columns — 4.0.0 logs carry 16), synthetic GPS-fix toggle + mph field (parked on the OKC asset track, 40 ms inject/step interleave) so fix-gated flows like the course creator are reachable |
| `wasm/smoke.mjs` | Node smoke test the wasm CI job runs (boot→menu, state/version/VFS, determinism across instances, reset) |
-| `CMakeLists.txt` | Native build; FetchContent pins: DovesLapTimer `BETA` (matches CI channel), SparkFun GNSS v3.1.9 (header-only use), ArduinoJson v6.21.5, ArxTypeTraits v0.3.2, Adafruit GFX 1.12.6 + SH110X 2.1.14 (real display stack) |
+| `CMakeLists.txt` | Native build; FetchContent pins: DovesLapTimer `DOVESLAPTIMER_REF` (default `v4.3.0`, the release pin; `sim-build.yml` overrides it to `BETA` for BETA-targeted builds, mirroring the firmware's CI channel), SparkFun GNSS v3.1.9 (header-only use), ArduinoJson v6.21.5, ArxTypeTraits v0.3.2, Adafruit GFX 1.12.6 + SH110X 2.1.14 (real display stack) |
### Non-Source
| Path | Contents |
|---|---|
-| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off |
+| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off. BETA-side PRs also run `compile-stock-arm` (flags off, both boards); on the BETA → master release PR it uses the `v4.3.0` pin, so that PR compiles exactly release.yml's configuration. `release.yml` refuses a tag whose version doesn't equal `FIRMWARE_VERSION` in `project.h` or has no `## [x.y.z]` CHANGELOG heading |
| `tests/` | Host doctest harness (CMake) for the pure-logic units |
| `docs/plans/` | Numbered design records (`NNNN-slug.md`, see its README) — the rationale behind each chunk of work; plan-executing commits cite the number. Same convention as DovesDataViewer |
| `CHANGELOG.md` | Keep-a-Changelog history; release workflow ties to version tags |
@@ -1000,7 +1000,11 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
`captureBootWakeCause()`): if `NRF_WDT->RUNSTATUS` says running it
pets and sets `wdtCarriedOver`; `setup()` pets between every slow
step (display delays, each `SD.begin()` attempt, the probe settle,
- GPS probe, camera/egg/strip init) — no-ops on a clean boot; and
+ GPS probe, camera/egg/strip init) — no-ops on a clean boot — and
+ **inside every unbounded SD walk** (the track-directory scan, the
+ settings default check, the replay browser's root walk, BLE
+ `LIST`/`TLIST`, the OTA read-back), which run at boot or under the
+ armed WDT and scale with what is on the card; and
`wdtSetup()` skips the (locked) configuration when one is already
running. A boot's cause is on the debug line (`Boot cause: SRST`) and
the SD format / FAULT pages.
@@ -2198,7 +2202,10 @@ This device operates in ignition-noise environments. Three layers of defense:
BLE; a plain IDE build with no flag defaults to `sense`.
- **Firmware version** is a single `#define FIRMWARE_VERSION` in `project.h`.
Keep it in sync with the release git tag (`v2.0.0` -> `"2.0.0"`); it is
- reported over BLE (DIS) for the OTA update check. `FIRMWARE_VARIANT`
+ reported over BLE (DIS) for the OTA update check, and `release.yml`
+ fails a tag build whose tag doesn't match it (or whose version has no
+ CHANGELOG heading) — the manifest version comes from the tag, so a
+ mismatch would offer the update forever. `FIRMWARE_VARIANT`
(also in `project.h`) feeds the DIS model string. The version literal can be
overridden at build time with `-DFIRMWARE_VERSION_OVERRIDE=` (a bare
token; `project.h` stringizes it) — the `beta` workflow uses this to stamp