Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions .github/workflows/compile-sketch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -209,28 +209,52 @@ 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

- 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
Expand All @@ -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
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion BirdsEye/BirdsEye.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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: "));
Expand Down
3 changes: 3 additions & 0 deletions BirdsEye/firmware_ota.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion BirdsEye/project.h
Original file line number Diff line number Diff line change
Expand Up @@ -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=<base>-beta.<gitsha> as a bare token —
Expand Down
3 changes: 3 additions & 0 deletions BirdsEye/replay.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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 "));
Expand Down
7 changes: 7 additions & 0 deletions BirdsEye/sd_functions.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions BirdsEye/settings.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion BirdsEye/sim/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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=<token>` (a bare
token; `project.h` stringizes it) — the `beta` workflow uses this to stamp
Expand Down
Loading