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