Skip to content

Latest commit

 

History

History
586 lines (524 loc) · 33.5 KB

File metadata and controls

586 lines (524 loc) · 33.5 KB

Architecture

This document explains how BirdsEye is put together and the reasoning behind the parts that aren't obvious from reading the code. For the exhaustive file-by-file map and the full constant tables, see CLAUDE.md; this doc is the narrative version for humans.

Overview

BirdsEye is a GPS lap timer and data logger built on the Seeed XIAO nRF52840 Sense — an ARM Cortex-M4F with 256 KB RAM, 1 MB flash, BLE 5.0, and an onboard LSM6DS3 IMU. It runs the Adafruit-based Bluefruit nRF52 core (not mbed), which means a SoftDevice handles the BLE stack and a UF2 bootloader handles flashing.

The device drives at 25 Hz off the GPS, logs to an SD card, shows live timing on a 128×64 OLED, reads RPM from an inductive tachometer, and serves logged files over BLE.

Source layout

BirdsEye/
  BirdsEye.ino          entry point: globals, setup(), loop(), state machine
  <subsystem>.ino + .h  one module per subsystem (see below)
  <unit>.cpp + .h       pure, Arduino-free logic (host-testable)
  sim/                  browser/WASM simulator host build (SIM flag; see below)
tests/                  doctest + CMake harness for the pure units
.github/workflows/      CI: compile, lint, unit tests, clang-tidy, release

Arduino concatenates the .ino files into one translation unit, so cross-module globals declared in BirdsEye.ino are visible everywhere. Each module's .h documents its public surface; the .ino includes its own header first so declaration/definition drift is caught at compile time.

The pure units (haversine, gps_time, gps_validation, dovex_header, filename_validator, course_creator, track_json, local_time, …) deliberately avoid Arduino headers. The same .cpp is compiled into both the firmware (Arduino picks up .cpp files in the sketch folder) and the host test binary (CMake). There is no copy-paste — the tests exercise the exact code that ships.

Main loop

loop() runs at roughly 250 Hz and is a flat dispatch with a few early-return short-circuits:

loop()
 ├─ wdtPet()                     feed the 4 s hardware watchdog
 ├─ if BLE active -> BLUETOOTH_LOOP(); minimal UI; return
 ├─ GPS_LOOP()                   drain buffer, fire PVT callback, feed timer, log
 ├─ TACH_LOOP()                  drain pulse ring buffer, Kalman-filter RPM
 ├─ ACCEL_LOOP()                 read g-force (rate-limited to 50 Hz)
 ├─ BLUETOOTH_LOOP()             service deferred BLE work
 ├─ SENSOREGG_LOOP()             drain SensorEgg scan buffer, parse payload
 ├─ trackDetectionLoop()         haversine match -> create CourseManager
 ├─ checkForNewLapData()         append completed laps to history
 ├─ checkAutoIdle()              60 s < 2 mph -> end session
 ├─ autoRaceModeCheck()          RPM/speed on menu -> enter race
 ├─ CAMERA_LOOP()                step the Insta360 auto-record FSM
 ├─ NEOPIXEL_LOOP()              LED strip frame (30 Hz, self-throttled)
 ├─ button hold combos           shutdown / reboot; menu idle -> shutdown
 ├─ readButtons() / gpsStatusPageLoop() / displayLoop() / resetButtons()

On the beta channel each of those calls is bracketed by the loop profiler (see Subsystems below); off beta the brackets are macros that expand to the bare call, so the dispatch above is literally unchanged.

Each subsystem exposes *_SETUP() (called once from setup()) and *_LOOP() (called each iteration). ISRs stay trivially short and hand off to the matching *_LOOP().

Subsystems

  • GPS (gps_functions) — SparkFun u-blox GNSS v3, UBX-PVT binary at 25 Hz. A registered callback fills a cached gpsData struct. Validated rows stream to the DOVEX log.
  • Tachometer (tachometer) — falling-edge ISR timestamps pulses into a ring buffer; the loop computes mean inter-pulse period and runs it through a 1-D Kalman filter (tach_filter). The filter sees one number per pulse, so its defining feature is an outlier gate: a single ignition ring or missed spark is a several-thousand-RPM measurement and must be coasted past, not averaged in. Three consecutive rejections mean the estimate is the wrong one and the measurement is adopted. Both noise models scale with engine speed, because RPM = K/period makes a fixed timing error worth quadratically more RPM the faster the engine turns. The tach_filter setting switches between this, the pre-0009 filter, and no filter at all, so the pickup can be characterised at the track (plan 0009).
  • Accelerometer (accelerometer) — onboard LSM6DS3, ±16 g, raw g-force. Degrades gracefully if absent (non-Sense board).
  • SD + tracks (sd_functions) — SdFat (FAT16/32), track JSON parsing (two formats, auto-detected), and an in-RAM track manifest for proximity detection. Built for a soldered-in module: a missing /TRACKS folder is created automatically, and a card that responds without a mountable FAT volume boots into a hold-to-confirm on-device format page (sd_format_page pure unit) rather than a dead-end fault screen.
  • Display/UI (display_ui, display_pages) — OLED driver abstraction, multi-sample debounced buttons, page routing.
  • Bluetooth (bluetooth + the ble_stream pure unit) — BLE service for file transfer, settings, and track sync, plus buttonless Secure DFU (BLEDfu) for OTA firmware updates and a Device Information Service (BLEDis) that reports FIRMWARE_VERSION for the update check. Download throughput is treated as something to verify, not request: the connect callback asks for MTU 247, 2M PHY and Data Length Extension, and bleTuneLink() then reads back what actually negotiated and corrects it — re-asking for DLE if the request was lost to a busy link-layer, and making a second Apple-compliant connection-interval request only when the link is slower than 15 ms. Chunks stream from a compacting 4 KB read-ahead so an SD read never sits in the radio's critical path. Plan 0008 has the reasoning; the transfer page reports live KB/s so the next regression is visible on the device.
  • Camera (camera_ble + the camera_fsm / insta360_protocol pure units) — hands-free Insta360 X4 auto-record: the device emulates the Insta360 GPS Remote as a pure BLE peripheral, wakes the paired camera on engine start, and starts/stops/powers it off automatically via ce82 remote-button notifications.
  • SensorEgg (sensoregg + the sensoregg_protocol pure unit) — wireless EGT proof of concept: a passive BLE observer receives the DovesSensorEgg pod's PW-ADV advertising broadcasts, v1 (14-byte: EGT + cold junction as int16 deci-degC, fault flags, sequence counter) and v2 (16-byte: + aux intake-air thermistor, real battery percent), and feeds the Temp1/Junction1/Temp2 DOVEX columns and the Temp1/Temp2 race pages. Observer + peripheral coexist natively on S140; passive scanning never transmits, so the camera link is untouched. Readings older than 1 s go NaN — never held across a dropout. Gated on the BIRDSEYE_ENABLE_SENSOREGG build flag: on in the beta channel, off in master/release, where the scanner and temp pages are compiled out, BLE returns to lazy init, and the DOVEX Temp1/Junction1/Temp2 columns are written as nan so the log format stays identical across channels.
  • NeoPixel strip (neopixel + the led_frame / led_modes / led_status / led_animations / sector_purple pure units) — 11 WS2812 pixels on the NFC pads (converted to GPIO by a one-time, one-way UICR write on first flag-on boot): two status LEDs flanking a 9-px strip. The strip shows a scale until pace is meaningful and then a pace pip (slower = left of center in red, faster = right in green); the scale is RPM on a session with a tachometer and target speed on one without, because an RPM bar on a tach-less car is nine dark pixels until the first lap lands. The two status LEDs are assigned by the user from the companion app — target RPM, target speed, GPS lock, camera sync, last lap, last sector, EGT, or off — with the GPS and camera modes staying lit on the main menu, since "am I ready to drive" is a paddock question. The lap and sector indicators compare against the previous lap or sector rather than the session best; a best-based one only ever answers purple or red. There is also a boot animation, and a two-stage purple celebration for a session-best sector or lap (both detected race-free against the lap timer's lap-line best-update by snapshotting bests at sector and lap open). One rule everything obeys: a global brightness cap applied at a single choke point — no LED channel ever exceeds it. Which cap is in force is a local-time decision: led_brightness by day, led_brightness_night after dark (see Local time below). The strip's 5 V boost converter has its EN pin driven low in sleep, so System OFF really powers the LEDs down. BIRDSEYE_ENABLE_NEOPIXEL is on in every channel as of 4.1.0, which is what makes the strip a core feature — at the price of a one-way, fleet-wide UICR NFC→GPIO conversion on the first boot after updating.
  • Course creator (course_creator + track_json pure units, glued into the menu/pages/SD modules) — authors a track course on the device by walking to each cone and holding for a 3 s GPS average. Autocross venues re-lay their course every event, so the alternative was a laptop in a paddock. No text is ever entered on-device: names come from the GPS clock and are renamed later in the web app. This is also the firmware's only track-JSON writer — everywhere else the format is read-only.
  • Local time (local_time pure unit) — UTC plus a fixed signed minute offset (utc_offset_min), giving the device a local wall clock. Its only consumer is the LED day/night brightness swap, which needs "7am" to mean the driver's 7am. No DST, and nothing logged goes through it — see Local time is presentation-only below.
  • Loop profiling (profiling + the loop_profile pure unit, beta channel only) — times every subsystem call in loop(), rolls the result up once a second onto a LOOP PROFILE race page, and drives pin 30 as a scope-readable profiling output. Built to answer two board questions with measurement instead of argument: nRF52840 or nRF5340, and is the Arduino core costing enough to be worth leaving. It takes pin 30 from the NeoPixel boost EN line to do it — see Profiling costs the 5 V rail below.
  • Replay (replay) — instant DOVEX header replay.
  • Settings (settings) — JSON key/value store on the SD card.
  • CourseManager (external library) — owns course detection, sector timing, and the Lap Anything fallback once a track is matched.

Design decisions worth knowing

These are the parts that look over-engineered until you've watched them fail the simple way.

GPS serial ring buffer (TIMER3 ISR) — two buffers, two failure modes

At 25 Hz PVT the GPS emits ~2.5 KB/s. An SD-card garbage-collection pause can block the main loop for 100 ms – 2 s — long enough to overflow the UART buffering and lose fixes. A TIMER3 ISR drains Serial1 into a 4 KB RAM ring buffer every 5 ms, independent of the main loop, and the GPS library reads from that buffer. This is why TIMER3 is reserved project-wide.

That ring only covers downstream stalls. Upstream of it sits the core's Serial1 RX ring, and the TIMER3 handler runs at NVIC priority 3 — below the SoftDevice's radio interrupts (priority 0–2, unmaskable by the app). Radio airtime (the SensorEgg scan window, camera connection events) defers the drain, and only the core ring absorbs bytes in the meantime; its stock 64 bytes gave ~1 ms of slack at 57600 baud, which is exactly where a ~0.9% 25 Hz PVT drop rate came from once BLE was always on. The core ring is therefore grown to 256 bytes via a required -DSERIAL_BUFFER_SIZE=256 build flag (asserted in project.h), and the pipeline carries permanent counters — dropped PVT frames, worst TIMER3 deferral (hardware timer capture), drain-burst high-water, and overflow events for both rings — surfaced on the GPS debug page so radio-induced loss, SD-stall loss, and checksum corruption can be told apart on hardware. The window math lives in the host-tested gps_stats unit.

SD access arbitration

The BLE callbacks run in a separate FreeRTOS task from loop(), and SdFat is not thread-safe. Every SD user (logging, replay, BLE transfer, track parsing) must take a single mutex via acquireSDAccess(mode) / releaseSDAccess(mode). Two layers make this sound:

  1. Atomic transitions. acquireSDAccess() evaluates the grant rules and commits the new owner inside a FreeRTOS critical section (taskENTER_CRITICAL, BASEPRI-masked so the SoftDevice's radio interrupts are untouched) — a plain check-then-set on the shared flag would be a TOCTOU between the two tasks. The grant/deny decision table itself (same-mode re-acquire is idempotent; the brief TRACK_PARSE mode is preemptible as leak recovery) is the host-tested sd_access_policy pure unit.
  2. Single-task SdFat. Every BLE command that touches the card — LIST/GET/DELETE, TLIST/TGET/TPUT/TDEL, settings, firmware OTA — is parsed in the callback (filenames validated there, RAM only) and executed by BLUETOOTH_LOOP() on the main loop. Nothing calls SdFat from the Bluefruit callback task, so the filesystem only ever has one task in it; directory listings hold the lock for the whole walk, and DELETE refuses while a transfer is streaming.

DOVEX crash safety

A .dovex file reserves the first 1 KB for session metadata (driver, course, lap times) but the device writes that header last, when the session ends cleanly. On creation the region is pre-filled with newline padding and the GPS rows stream in after byte 1024. If the device loses power mid-session the header is blank but every logged GPS row is still intact and recoverable. Header layout/parsing lives in the tested dovex_header unit.

EMI hardening

The device lives next to an ignition system. Defenses: multi-sample button reads with a refire lockout, a Kalman-filtered tach that absorbs ISR jitter, a reduced 2 MHz SD SPI clock, an I2C bus-recovery routine that bit-bangs the display bus free if it hangs, and a 4 s hardware watchdog as the last resort.

Shutdown is System OFF, wake is a reboot

There is no power switch (deliberately — the next hardware revision drops it), so "off" is nRF52 System OFF at ~µA with GPIO SENSE armed on the tach pin and the three buttons, plus VBUS. The tach's SENSE polarity is not hardcoded: the pickup circuit's output stage idles high or low depending on the build, so shutdown samples the parked line and arms SENSE for the opposite level (majority vote in the host-tested wake_cause unit) — arming toward the idle level satisfied DETECT immediately and battery sleep reboot-looped. Waking is a full chip reset: setup() runs fresh, and the very first thing it does is read (then clear) the sticky RESETREAS + GPIO LATCH registers to decode why it booted (the host-tested wake_cause unit). An engine-start (tach) wake routes the GPS status page's exit straight into race mode with logging — the old software sleep loop's RPM wake, rebuilt on hardware. GPREGRET is never touched; register 0 belongs to the OTA/bootloader handoff. The one soft exception is VBUS: while a cable is present the device parks in a live charging loop instead of System OFF, wakes fully on any button, and powers off when unplugged. Two reasons — the fast-charge (HICHG) pin is software-held when onboard charging is compiled in, and, regardless of that, VBUS is an always-armed System OFF wake source, so powering down with the cable in risks an immediate wake-reset loop.

Onboard charging itself is a build flag, BIRDSEYE_ENABLE_ONBOARD_CHARGING, off in every shipped build since 3.0.1: the hardware now carries an external charging circuit (the XIAO's BQ25100 tops out around 100 mA even with HICHG held). With the flag off the firmware never drives HICHG, and USB is treated as a host connection rather than a charge session — a VBUS wake boots normally instead of shortcutting to the charge screen, and the main menu is no longer pulled down after USB_MENU_CHARGE_IDLE_MS just because a cable is plugged in. Setting the flag to 1 restores the pre-3.0.1 behavior wholesale.

GPS boot recovery

The SAM-M10Q keeps its config in volatile RAM (backed by V_BCKP), and with shutdown being a real power-down the module can be in any state at boot: software backup mode holding a 57600 config (the normal wake), already configured and running (an MCU-only reset), or factory 9600 NMEA (true cold power / brownout). Boot therefore sends the u-blox backup-wake byte first (harmless if awake), probes 57600 before 9600 so warm boots connect near-instantly, and pays a cold-boot delay only when nothing answers. A begin() ping proves the module answers — not that data flows — so boot also arms a 5 s PVT-arrival watchdog; if no fix data arrives, GPS_BAUD_RECOVERY() renegotiates the baud rate and reconfigures. A GPS that never appears is re-probed a bounded number of times from the status page and surfaced as "CHECK WIRING".

GPS status boot page

Every boot lands on a MyChron-style satellite status page: sat counts, HDOP, lock state, and per-satellite CNO signal bars (UBX-NAV-SAT). The GPS runs a 5 Hz status config while the page is up and switches to the 25 Hz PVT-only race config on exit. The page holds until a stable lock (fix + fully-resolved time held 3 s) then auto-advances; any button skips it immediately; a tach-wake boot or a running engine turns the exit into race-mode entry. Its hold/auto-close/destination logic is the host-tested gps_status_page unit, and the bar selection/geometry is sat_bars.

OTA firmware updates

The board has no internet radio — only BLE — so it cannot pull a release itself. OTA is a two-hop flow: the companion (DovesDataViewer, over Web Bluetooth) downloads the firmware .zip from a GitHub release, writes the buttonless-DFU command to reboot the board into the Adafruit/Nordic Secure DFU bootloader, then force-feeds the image over GATT. The bootloader is a passive receiver: it validates the package's signed init packet (device type, SoftDevice requirement, CRC) before writing, so a corrupt or wrong image is rejected rather than bricking the board. The companion picks which image (version, Sense vs non-Sense) by reading the installed FIRMWARE_VERSION and DIS model (BirdsEye-sense / BirdsEye-nonsense) over the Device Information Service and comparing against a manifest.json the release workflow publishes to the gh-pages branch (GitHub Pages serves it with permissive CORS, so the browser can fetch both the manifest and the .zip — raw release-asset URLs can't be relied on for that). Sense and non-Sense are the same MCU + SoftDevice, so a mismatched image still boots — it just skips IMU init.

Insta360 camera auto-record

An Insta360 X4 has no wired trigger, but it does trust its own BLE accessory: the "GPS Remote". So the device is the remote — a pure BLE peripheral. It hosts the remote's GATT (service 0xCE80: ce81 write camera→us, ce82 notify us→camera, ce83 read), advertises the remote's manufacturer-data payload (carrying the camera's serial) to wake a powered-off camera, and the camera connects back to us as central and subscribes to ce82. All control is a ce82 button notification, byte-for-byte the physical remote's frames: recording toggles with the shutter button, and power-off streams the remote's 3-second power-hold. We never act as central — no scanning, no connecting to the camera's own 0xBE80 service. (That central path was an earlier design; it let us send explicit start/stop-video and a 1 Hz GPS-overlay frame, but power-off only exists as a remote ce82 hold, and one BLE link can hold only one role — you cannot be central to the camera and have the camera be central to you at once. The role conflict is what made power-off impossible, so the device commits fully to the remote role. The in-camera GPS overlay rides that same remote channel: a capture of the genuine link showed the remote streams GPS on ce82 at 10 Hz as a non-standard NMEA-RMC frame, which the firmware now emits continuously while connected — doubling as the remote's liveness heartbeat. GPS still logs to SD independently.)

The single peripheral slot is shared with the file-transfer service via an explicit bleOwner (NONE/TRANSFER/CAMERA); the owner model keeps a camera link from ever triggering the transfer path's auto-reboot-on-disconnect, and opening the transfer page force-releases the camera first. The link is Just-Works bonded (the genuine remote link is encrypted), and BLE comes up lazily on the first camera action, so an unpaired device pays nothing.

The lifecycle is a pure FSM (camera_fsm), deliberately RPM-driven and simple: wake on engine start, connect+subscribe, record once RPM has held a few seconds, stop after the engine has been off a while, then WATCH. It consumes a telemetry snapshot each loop tick and returns at most one action for the glue to execute. Recording starts from the WATCHING hub once RPM has held above the ON threshold for ~5 s — there is no GPS-lock gate (GPS still streams to the camera continuously). Because the shutter is a toggle, a wrong record belief flips the camera the wrong way — so the FSM does not merely believe: it confirms record state from the camera's own 0x10 status frame (a live .HH:MM:SS timer while recording; the 0x02 word is unreliable) and reconciles recordingActive against it. On a mid-session BLE drop it preserves the belief and on reconnect adopts the camera's real state instead of blind-toggling; if the camera reports idle while we think we're recording, it re-asserts the shutter once. Recording stops after ~30 s of engine-off (RPM only — a stationary but running grid idle keeps recording), returning to WATCHING. WATCHING keeps the camera on and connected so a brief on-track stall recovers straight back into recording when RPM returns; the camera powers off only when the device shuts down (there is no post-record cooldown/power-off timeout). All temporal behavior lives inside the FSM, host-tested with a fake clock, and it is the board-portable core intended to move unchanged to the nRF54 ("Falcon") target.

One deliberate coupling bounds the feature: the camera's 30 s-engine-off auto-stop also ends the race log session (the glue latches it and the main sketch calls endRaceSession() + returns to the menu), and while the camera is recording checkAutoIdle() yields so the speed-based log idle can't cut the log out from under it. So with a camera paired+recording the log ends on engine-off, not on 60 s-stationary; without a camera, logging is unchanged (a tach-less setup reads RPM≈0, which is why the RPM-idle can't be made universal). The protocol bytes live in the host-tested insta360_protocol unit with golden-byte tests: the wake advert + scan response, the ce82 button frames, the ce82 GPS/RMC frame, and the 0x10 record-timer parse are all captured from a genuine remote (the wake advert was even replayed to wake a sleeping X4).

Local time is presentation-only

The GPS delivers UTC and that is what the device logs. DOVEX row timestamps are Unix epoch milliseconds, and the header datetime, the log filenames and the generated course names are all UTC too. The utc_offset_min setting (a fixed signed minute offset — local_time) buys the device a local wall clock for exactly one purpose: deciding when to swap the LED strip to its night brightness, where "7am" has to mean the driver's 7am. A US Central driver at 07:30 local is at 12:30 UTC, which a naive UTC gate calls the middle of the night.

Nothing in the logging pipeline may call into local_time. A log is routinely viewed somewhere other than where it was recorded, so timezone presentation belongs to the app doing the viewing, which knows the reader's preference; baking a recording-side offset into the data would just move the guess earlier and make it unrecoverable.

There is deliberately no DST. A fixed offset walks the boundary an hour twice a year, which is beneath the resolution of a dim-after-dark gate, whereas rule tables are a standing correctness liability (legislatures keep moving the dates) and tzdata is ~100 KB shipped to a sealed device. local_time is where rules would go if that ever changes — which is why its DateTime carries a 4-digit year even though the sketch's gpsData.year is 2-digit.

The settings file has a hard size ceiling

Every settings read path caps at sizeof(settingsFileBuffer) - 1. A file larger than that parses as IncompleteInput, so every key read fails — which SETTINGS_SETUP() correctly interprets as corruption, quarantines to SETTINGS.json.bad, and regenerates (losing the BLE name, PIN and pairing), whereupon ensureDefaultSettings() grows the file back over the cap and it happens again on the next boot. A settings key is therefore not free: an innocuous four-key addition in plan 0010 took an 18-key, 436-byte file to 543 bytes and would have shipped that loop.

Both the file buffer and the StaticJsonDocument are 1024 bytes (raised from 512) and must stay equal — the invariant is that the buffer can always hold what the document serializes. setSettingInner() also refuses any write whose document overflowed() or whose measureJson() exceeds the buffer, so the failure mode is one loudly refused write with the previous file intact, rather than a silent unreadable one.

Profiling costs the 5 V rail

A profiling build (BIRDSEYE_ENABLE_PROFILING, beta only) drives pin 30 HIGH for the span being profiled and LOW outside it, so a scope reads the loop period off the rising edges with no software in the measurement path. Pin 30 is also the NeoPixel boost converter's EN line, and it cannot be both.

The profiler takes it. A profiling build therefore never drives EN — not at setup, not at sleep, not on the charging-loop resume — and the regulator sits at its hardware default (EN pulled up = rail on). That is what makes the trade work at all: the rail does not need firmware control, it needs to be switchable, and switching it is a requirement of use, not of testing.

Two consequences, both deliberate:

  • The rail stays up through System OFF. GPIO levels are retained there and the driven LOW was the only thing holding it down (same retention as the "blue conn LED stays on after sleep" report above). A profiling unit left asleep on a battery with a strip wired to it goes flat.
  • If the EN jumper is still physically connected on the rig, the toggling chops the rail at loop rate. Pull it or tie EN high before profiling.

Master and release are untouched: the flag defaults to 0, the section brackets are macros that expand to the bare call, and pin 30 goes on being EN.

The profiler reports the shape of an iteration — and measures idle rather than assuming it

loop() runs back to back with nothing rate-limiting it, so the first version of this subsystem asserted there was no idle time and that a duty cycle would be meaningless. That was an assumption, it was baked into the measurement, and it produced a wrong first reading — see Two clocks below. The profiler now measures how much wall time the CPU spends executing loop() and reports the balance as SLP (scheduler dispatch, other FreeRTOS tasks, sleep). If that number is large, the loop rate is not what limits this firmware.

Alongside it, the shape of an iteration: how long one takes (mean and worst case — an SD garbage-collection stall of 100 ms–2 s is invisible in a mean), how that time divides between subsystems, and how much of it no subsystem accounts for. OTH — loop time no section bracketed — is reported rather than hidden, because it is the honesty check on the instrumentation. Every slot is a share of the same wall-clock second, so all fourteen sum to ~100%.

Two clocks, and why mixing them was a real bug

Durations are measured with the Cortex-M4 DWT cycle counter (64 ticks/µs): most sections are well under a microsecond and micros() would quantise half of them to zero. DWT is verified to be counting at setup rather than assumed — a debug probe can hold TRCENA off — and the page marks the micros() fallback with a leading *.

But DWT counts cycles, not time. It stops whenever the core halts. The first implementation also used it to close the one-second rollup window, which meant the window was one second of CPU-awake time: the loop rate came out multiplied by the sleep factor, and every share was a fraction of awake time wearing a wall-time label. The window is now closed on millis() and all shares are computed against wall time. The pure unit still accumulates ticks and is handed ticksPerUs at rollup, so per-call resolution is preserved; its accumulators saturate rather than wrap, since a uint32 of DWT ticks is only ~67 s and a pegged window reads as pegged where a wrapped one would read as near-idle.

Data formats

  • .dovex — 1 KB reserved header (metadata + lap times) then streaming CSV GPS rows after byte 1024. Default and only logging format. Every timestamp in it is UTC and stays that way (see Local time above).
  • Track JSON (/TRACKS/*.json) — new object format with courses[] and lengthFt, or an older bare-array format (parsed, but falls back to Lap Anything since it has no length to rank courses by).
  • Settings JSON (/SETTINGS.json) — string key/value store.

See CLAUDE.md and the README for field-level detail.

Simulator (BirdsEye/sim/)

A demo tool, not an emulator: the REAL firmware sources — the same unmodified .ino files that ship — compiled for a desktop (and, later, WASM/browser) target under the SIM flag. sim_main.cpp replicates Arduino's concatenation into one translation unit (sim_prototypes.h stands in for the IDE's auto-generated prototypes), against:

  • arduino_shim/ — Arduino core + nRF52 register surface. Time is a virtual clock the host advances (delay() consumes virtual time; no wall clock anywhere, which is what makes runs deterministic).
  • sdfat_shim/ — an in-memory VFS preloaded with fixed assets/SETTINGS.json (deterministic boot) and an example track. Logs written during a run evaporate with it; persistence is out of scope.
  • stubs/ — no-op implementations of the deliberately-excluded modules (bluetooth, camera_ble, usb_msc, firmware_ota) and of the SparkFun GNSS driver (real header for the UBX types; PVT is injected by the host straight into onPVTReceived(), never parsed from bytes).
  • Real DovesLapTimer/CourseManager sources — lap timing fidelity is the point.
  • The REAL Adafruit display stack (GFX → GrayOLED → SH1106G, pinned versions): the hardware boundary is two BusIO device pointers, shimmed in busio_shim/ to succeed-and-discard, so every framebuffer pixel comes from the real drawPixel() — pixel-perfect by construction. The 1024-byte buffer is exposed zero-copy plus an FNV-1a frame hash.

Inputs are injected at the same boundaries the hardware uses: GPS as a real UBX_NAV_PVT_data_t handed straight to onPVTReceived() (the SparkFun parser is never involved), the tach as synthesized pulses through the real falling-edge ISR at exact virtual microseconds (the debounce/ring-buffer/Kalman path is all real firmware), buttons as pin levels through the real multi-sample debounce, and accel via the IMU shim. A lap-timing oracle test drives a synthetic constant-speed lap trace around the OKC start line — whose lap period is exact by construction — through the whole pipeline (boot page → auto race entry → haversine track detection → CourseDetector → DovesLapTimer) and requires every recorded lap to match within one GPS frame (40 ms); the same driver's --dovex mode replays a hardware-recorded log against the lap list in its own header.

The sim-build workflow builds it and runs a 60 s boot soak, a two-runs-byte-identical determinism check, golden display fixtures — a scripted walk of the real menus capturing the frame hash (and expected page id) at fixed virtual times for 8 pages, committed in sim/golden/ — and the lap oracles, on every PR. The sim compiling is itself a CI gate, which is what keeps SIM from rotting the way the old WOKWI flag did.

The same sources also build to a browser module (Emscripten; sim/wasm/): birdseye-sim.mjs wraps the emitted core and exposes the contract in sim/API.md (framebuffer + frame hash, JSON PVT injection, RPM, state/version JSON, VFS reads; reset() re-instantiates the module for a true fresh boot). CI builds it, smoke-tests it under node (including cross-instance determinism), and uploads the dist folder; the artifacts are vendored into DovesDataViewer/public/sim/ so the viewer only ever runs a deliberately-committed build. The wasm module renders byte-identically to the native golden fixtures.

Testing & CI

The pure units are unit-tested with doctest on a host toolchain; the firmware itself is compile-checked for the XIAO in CI, linted with arduino-lint, statically analyzed with clang-tidy, and gated on flash size. The sim-build workflow additionally compiles the whole firmware TU natively under SIM and boots it (see Simulator above). Releases are built and published (.hex + .uf2) by the release workflow on a version tag. Hardware behavior still needs manual verification on a real device — CI proves it builds and that the logic units are correct, not that a lap was timed right on track.