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.
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.
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.
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().
- GPS (
gps_functions) — SparkFun u-blox GNSS v3, UBX-PVT binary at 25 Hz. A registered callback fills a cachedgpsDatastruct. 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. Thetach_filtersetting 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/TRACKSfolder 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_pagepure 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+ theble_streampure 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 reportsFIRMWARE_VERSIONfor 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, andbleTuneLink()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+ thecamera_fsm/insta360_protocolpure 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+ thesensoregg_protocolpure unit) — wireless EGT proof of concept: a passive BLE observer receives the DovesSensorEgg pod'sPW-ADVadvertising 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 theTemp1/Junction1/Temp2DOVEX 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 theBIRDSEYE_ENABLE_SENSOREGGbuild 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 DOVEXTemp1/Junction1/Temp2columns are written asnanso the log format stays identical across channels. - NeoPixel strip (
neopixel+ theled_frame/led_modes/led_status/led_animations/sector_purplepure 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_brightnessby day,led_brightness_nightafter 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_NEOPIXELis 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_jsonpure 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_timepure 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+ theloop_profilepure unit, beta channel only) — times every subsystem call inloop(), 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.
These are the parts that look over-engineered until you've watched them fail the simple way.
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.
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:
- 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 briefTRACK_PARSEmode is preemptible as leak recovery) is the host-testedsd_access_policypure unit. - 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 byBLUETOOTH_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, andDELETErefuses while a transfer is streaming.
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.
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.
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.
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".
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.
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.
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).
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.
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.
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.
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%.
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.
.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 withcourses[]andlengthFt, 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.
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 fixedassets/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 intoonPVTReceived(), 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 realdrawPixel()— 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.
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.