diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml
index 46af1be..1c29804 100644
--- a/.github/workflows/beta.yml
+++ b/.github/workflows/beta.yml
@@ -9,7 +9,7 @@ name: beta
# builds you flash from your phone via DovesDataViewer's beta channel while
# wrenching at the track.
#
-# Three things differ from release.yml:
+# Four things differ from release.yml:
# 1. The image self-reports a beta version. The build injects
# -DFIRMWARE_VERSION_OVERRIDE=-beta. so a flashed unit
# reports its exact commit over BLE (DIS) — you can read the version off
@@ -23,6 +23,14 @@ name: beta
# from master/release, which default the flag to 0. See project.h for
# the full flag list — anything else defaulted off stays off here too
# (BIRDSEYE_ENABLE_ONBOARD_CHARGING in particular).
+# 4. Beta is also the loop-profiling channel: -DBIRDSEYE_ENABLE_PROFILING=1
+# turns on the LOOP PROFILE page and the pin-30 scope output (plan
+# 0011). That flag TAKES PIN 30 FROM THE NEOPIXEL BOOST EN LINE, so a
+# beta image can no longer switch the 5 V rail — including at sleep,
+# where the level is retained. A beta unit left asleep on a battery
+# with LEDs wired will drain it. That is the accepted cost of getting
+# real per-subsystem CPU numbers off the hardware; prod images are
+# unaffected. See project.h and BirdsEye/profiling.h.
#
# NOTE: the build steps intentionally mirror release.yml's build job. If the
# BSP version, library list, or asset layout changes there, mirror it here.
@@ -93,7 +101,8 @@ jobs:
"Adafruit SSD1306" \
"SparkFun u-blox GNSS v3" \
"Seeed Arduino LSM6DS3" \
- "ArxTypeTraits"
+ "ArxTypeTraits" \
+ "Adafruit NeoPixel"
# Beta builds track the lap-timer library's own BETA branch so the
# two beta channels move together (master/release pin a tag).
arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#BETA
@@ -103,7 +112,7 @@ jobs:
run: |
arduino-cli compile \
--fqbn "${{ matrix.board.fqbn }}" \
- --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DFIRMWARE_VERSION_OVERRIDE=${{ steps.ver.outputs.betaver }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG -DBIRDSEYE_ENABLE_SENSOREGG=1" \
+ --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DFIRMWARE_VERSION_OVERRIDE=${{ steps.ver.outputs.betaver }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG -DBIRDSEYE_ENABLE_SENSOREGG=1 -DBIRDSEYE_ENABLE_NEOPIXEL=1 -DBIRDSEYE_ENABLE_PROFILING=1" \
--output-dir dist \
--warnings none \
BirdsEye
diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml
index 697dc99..92087d5 100644
--- a/.github/workflows/clang-tidy.yml
+++ b/.github/workflows/clang-tidy.yml
@@ -41,4 +41,13 @@ jobs:
BirdsEye/lap_format.cpp \
BirdsEye/tach_filter.cpp \
BirdsEye/camera_fsm.cpp \
- BirdsEye/insta360_protocol.cpp
+ BirdsEye/insta360_protocol.cpp \
+ BirdsEye/led_frame.cpp \
+ BirdsEye/led_modes.cpp \
+ BirdsEye/led_status.cpp \
+ BirdsEye/led_animations.cpp \
+ BirdsEye/sector_purple.cpp \
+ BirdsEye/local_time.cpp \
+ BirdsEye/setting_parse.cpp \
+ BirdsEye/ble_stream.cpp \
+ BirdsEye/loop_profile.cpp
diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml
index e664982..d3cc6ad 100644
--- a/.github/workflows/compile-sketch.yml
+++ b/.github/workflows/compile-sketch.yml
@@ -34,10 +34,15 @@ jobs:
# release tag.
LAPTIMER_REF: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && 'BETA' || 'v4.3.0' }}
# Feature flags for this build (see project.h). BETA enables the
- # SensorEgg POC exactly as beta.yml does, so the flag-on build is
- # compile-checked on the PR rather than first failing on the publish
- # workflow. Everything else builds the master/release defaults (off).
- FEATURE_FLAGS: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && '-DBIRDSEYE_ENABLE_SENSOREGG=1' || '' }}
+ # SensorEgg POC, the NeoPixel strip and loop profiling exactly as
+ # beta.yml does, so the flag-on build is compile-checked on the PR
+ # rather than first failing on the publish workflow. Everything else
+ # builds the project.h defaults — which since 4.1.0 means the
+ # NeoPixel strip is ON (it is a core feature now; the flag defaults
+ # to 1) and only the SensorEgg POC and profiling are off. So the
+ # difference between the two arms is SensorEgg, profiling, and the
+ # DovesLapTimer ref.
+ FEATURE_FLAGS: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && '-DBIRDSEYE_ENABLE_SENSOREGG=1 -DBIRDSEYE_ENABLE_NEOPIXEL=1 -DBIRDSEYE_ENABLE_PROFILING=1' || '' }}
# Build both XIAO nRF52840 variants. The Sense board has the onboard
# LSM6DS3 IMU; the plain board does not (accelerometer logging degrades
# gracefully). Same MCU/BLE/bootloader otherwise.
@@ -77,6 +82,7 @@ jobs:
- name: SparkFun u-blox GNSS v3
- name: Seeed Arduino LSM6DS3
- name: ArxTypeTraits
+ - name: Adafruit NeoPixel
- source-url: https://github.com/TheAngryRaven/DovesLapTimer.git
version: ${{ env.LAPTIMER_REF }}
sketch-paths: |
@@ -193,3 +199,55 @@ jobs:
name: size-deltas-${{ matrix.board.name }}
path: sketches-reports
if-no-files-found: ignore
+
+ # Flag-OFF compile, for BETA-targeted work only.
+ #
+ # The `compile` job above picks ONE flag set per PR: BETA-targeted work
+ # builds with SensorEgg + profiling ON, everything else with them off.
+ # That means code inside a `#else` arm of one of those flags reaches
+ # master having never been through a compiler — and plan 0013 added
+ # 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.
+ compile-stock-arm:
+ name: xiao-nrf52840-sense (flags off)
+ if: >-
+ github.base_ref == 'BETA' || github.head_ref == 'BETA' ||
+ github.ref_name == 'BETA'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install adafruit-nrfutil
+ run: pip install --user adafruit-nrfutil
+
+ - name: Compile sketch with the feature flags off
+ uses: arduino/compile-sketches@v1
+ with:
+ fqbn: Seeeduino:nrf52:xiaonRF52840Sense
+ platforms: |
+ - name: Seeeduino:nrf52
+ source-url: https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json
+ libraries: |
+ - name: ArduinoJson
+ - name: SdFat - Adafruit Fork
+ - name: Adafruit GFX Library
+ - name: Adafruit SH110X
+ - name: Adafruit SSD1306
+ - name: SparkFun u-blox GNSS v3
+ - name: Seeed Arduino LSM6DS3
+ - name: ArxTypeTraits
+ - name: Adafruit NeoPixel
+ - source-url: https://github.com/TheAngryRaven/DovesLapTimer.git
+ version: BETA
+ sketch-paths: |
+ - BirdsEye
+ cli-compile-flags: |
+ - --build-property
+ - compiler.cpp.extra_flags=-DBIRDSEYE_BOARD_SENSE -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dc89a64..91fb3ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,7 +69,8 @@ jobs:
"Adafruit SSD1306" \
"SparkFun u-blox GNSS v3" \
"Seeed Arduino LSM6DS3" \
- "ArxTypeTraits"
+ "ArxTypeTraits" \
+ "Adafruit NeoPixel"
# Release builds pin the lap-timer library to a known-good tag —
# bump deliberately. (Beta builds track its BETA branch instead.)
arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#v4.3.0
diff --git a/.gitignore b/.gitignore
index 26265e0..73465a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
# Host unit-test build (also covered by tests/.gitignore)
build/
tests/build/
+# Sim wasm build (emcmake; CI uploads its dist/ as the artifact)
+build-wasm/
# Coverage artifacts generated by the coverage workflow / local runs
public-badges/
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a5776d6..c5e458b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -36,8 +36,8 @@ 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`, …)
-deliberately avoid Arduino headers.
+`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.
@@ -61,10 +61,15 @@ loop()
├─ 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()`.
@@ -76,7 +81,16 @@ to the matching `*_LOOP()`.
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.
+ 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
@@ -87,10 +101,19 @@ to the matching `*_LOOP()`.
(`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`) — 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.
+- **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
@@ -110,6 +133,34 @@ to the matching `*_LOOP()`.
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
@@ -117,6 +168,19 @@ to the matching `*_LOOP()`.
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
@@ -328,10 +392,121 @@ 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.
+ 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).
diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino
index aee6605..ee21c1b 100644
--- a/BirdsEye/BirdsEye.ino
+++ b/BirdsEye/BirdsEye.ino
@@ -10,6 +10,7 @@
// display_pages.ino - All display page rendering functions
// display_ui.ino - Display setup, button handling, menu navigation
// gps_functions.ino - GPS setup, loop, time functions, data logging
+// profiling.ino - Main-loop CPU profiling (beta channel only)
// replay.ino - Session replay system
// sd_functions.ino - SD card setup, track parsing, access management
// settings.ino - Persistent JSON settings on SD (/SETTINGS.json)
@@ -98,6 +99,10 @@
#include "gps_status_page.h"
#include "haversine.h"
#include "idle_policy.h"
+#include "local_time.h"
+#include "setting_parse.h"
+#include "neopixel.h"
+#include "profiling.h"
#include "replay.h"
#include "sat_bars.h"
#include "sd_format_page.h"
@@ -181,6 +186,47 @@ char settingDeviceName[32] = "BirdsEye";
// sprint track are within detection range (sprint_select::chooseKind).
// It never overrides what is actually detected.
bool settingRaceModePrefSprint = false;
+// NeoPixel strip (plan 0006): global brightness cap (0-255, 0 = LEDs
+// disabled entirely) and the target RPM (true RPM) the LED scale/flasher
+// are anchored to. Read at boot only, like every other setting.
+//
+// settingTargetRpm was `settingRevLimit` / the `rev_limit` key until plan
+// 0013. It is the SHIFT point — where the driver wants to know the engine
+// is at its working ceiling — and never was a limiter; settingOverrevLimit
+// below is the real limit. The old name taught the wrong thing to
+// everyone who opened SETTINGS.json, so both the key and the global moved.
+uint8_t settingLedBrightness = 64;
+int settingTargetRpm = 15000;
+// Plan 0007: the PROBLEM limit — an engine at overrev_limit is broken,
+// not just at its ceiling. 0 = disabled. And the Temp1 (EGT) alert
+// threshold in Celsius for the right status LED.
+int settingOverrevLimit = 0;
+int settingTemp1AlertC = 650;
+// Plan 0010: minutes east of UTC. PRESENTATION ONLY — the DOVEX rows,
+// the header datetime and every generated filename stay UTC, and the
+// webapp converts on the viewing side (you might be reading a log from
+// another state). The single consumer is the LED day/night swap, which
+// needs the driver's 7am rather than Greenwich's.
+int16_t settingUtcOffsetMin = 0;
+// Night-time LED cap and the LOCAL hours the swap happens on. Equal
+// hours = no swap. A night cap of 0 blanks the strip but leaves the
+// 5 V rail up — only led_brightness 0 cuts the rail (NEOPIXEL_SETUP).
+uint8_t settingLedBrightnessNight = 16;
+uint8_t settingLedDayStartHour = 7;
+uint8_t settingLedNightStartHour = 19;
+// Plan 0013: the speed the 9-px bar scales against on a session with no
+// tachometer (mph, stored in mph — the companion app converts for
+// display), and what each status LED is assigned to show. The defaults
+// reproduce the pre-0013 hardcoded pair on a SensorEgg build; a stock
+// build gets the lap indicator on the right instead of a Temp1 readout
+// with no probe behind it.
+int settingTargetSpeedMph = 60;
+led_status::Mode settingLedStatusLeft = led_status::Mode::kRpm;
+#if BIRDSEYE_ENABLE_SENSOREGG
+led_status::Mode settingLedStatusRight = led_status::Mode::kEgt;
+#else
+led_status::Mode settingLedStatusRight = led_status::Mode::kLap;
+#endif
// Track manifest for proximity detection
TrackManifestEntry trackManifest[MAX_LOCATIONS];
@@ -276,9 +322,6 @@ bool bleTransferInProgress = false;
uint32_t bleFileSize = 0;
uint32_t bleBytesTransferred = 0;
uint16_t bleNegotiatedMtu = 23;
-bool bleWaitingForMTU = false; // Deferred MTU negotiation (avoids delay in callback)
-unsigned long bleMTURequestTime = 0; // Timestamp when MTU was requested
-uint16_t bleMTUConnHandle = 0; // Connection handle for deferred MTU read
// Note: bleCurrentFile is declared after SdFat include
///////////////////////////////////////////
@@ -327,13 +370,26 @@ volatile uint8_t tachRingTail = 0; // Main-loop read index (only TACH_LOOP wri
volatile bool tachRingOverflow = false; // ISR sets on drop; TACH_LOOP clears
// Revolutions per ignition pulse — the single place the engine's geometry
-// enters the RPM path. Set once at boot from spark_mode + cylinder_count;
-// the default (1 cyl, wasted spark) is 1.0, exactly today's behaviour.
+// enters the RPM path. Set once at boot from spark_mode ALONE: one clamp
+// on one plug wire sees one cylinder's ignition, so the engine's cylinder
+// count is not a term (see tach_filter.h "ENGINE GEOMETRY"). Wasted spark
+// / 2-stroke = 1.0, 4-stroke single-fire = 2.0.
// Applied ONCE, before the Kalman filter, in TACH_LOOP(). No consumer may
// re-derive or re-apply it — they all read the corrected tachLastReported.
static float tachRevsPerPulse = 1.0f;
+
+// The engine's cylinder count as configured (clamped). Descriptive only:
+// nothing in the RPM path reads it — it says whether crank speed is being
+// inferred from a single cylinder's firing rate, which is what the
+// settings-UI warning and the boot log report.
+static int tachCylinderCount = 1;
static const uint32_t tachStopTimeoutUs = 500000; // 500ms = engine stopped
+// Which RPM estimator TACH_LOOP runs (plan 0009). Set once at boot from the
+// `tach_filter` setting; kSmooth is the shipped default and the other two
+// exist so the filter can be A/B'd against a live engine at the track.
+static tach_filter::Mode tachFilterMode = tach_filter::Mode::kSmooth;
+
///////////////////////////////////////////
// ACCELEROMETER GLOBALS
///////////////////////////////////////////
@@ -706,6 +762,15 @@ const int PAGE_COURSE_POINT = -15; // "Save current pos" averaging hold
const int PAGE_COURSE_PRUNE = -16; // "Track full - drop N old runs?"
// running menu (these must be in order)
+#if BIRDSEYE_ENABLE_PROFILING
+// Loop CPU profile (plan 0011). Only exists on a profiling build, and
+// when it does it is the FIRST page of the race rotation — the numbers
+// are only interesting under a real 25 Hz + logging + LED workload, so
+// the rotation is where they belong, and a bench session should not have
+// to arrow past six pages to reach them. Sits below GPS_DEBUG so the
+// diagnostic block stays contiguous.
+const int GPS_PROFILE = 2;
+#endif
const int GPS_DEBUG = 3;
const int GPS_STATS = 4;
@@ -757,13 +822,24 @@ int lastPage = 0;
// "pageStart" defines where the UI starts, you cannot backup beyond this
#ifdef ENDURANCE_MODE
+ // ENDURANCE_MODE keeps a const start, so it never picks up the debug or
+ // profile pages — it is a deliberately trimmed rotation.
const int runningPageStart = GPS_SPEED;
#else
// Runtime, not const: the debug_pages setting decides whether the two
// diagnostic pages (GPS_DEBUG, GPS_STATS) are in the rotation. Default is
// hidden — the boot settings read raises the start only on an explicit
// "show", so a blank or garbled value gives an end user the clean rotation.
+ #if BIRDSEYE_ENABLE_PROFILING
+ // A profiling build starts one page lower still, unconditionally: the
+ // page is the reason the build exists, so it must not be reachable only
+ // via a setting on the card. Because the rotation is a contiguous range,
+ // this also pulls GPS_DEBUG and GPS_STATS in — i.e. profiling implies
+ // debug_pages=show, which on a bench build is what you want anyway.
+ int runningPageStart = GPS_PROFILE;
+ #else
int runningPageStart = GPS_SPEED;
+ #endif
#endif
int runningPageEnd = LOGGING_STOP; // only changes if sd:/tracks not found
@@ -953,12 +1029,24 @@ void setup() {
// value) keeps the clean end-user rotation starting at the speed page.
// ENDURANCE_MODE already starts at GPS_SPEED, so it ignores the setting.
if (getSetting("debug_pages", buf, sizeof(buf))) {
- if (strcasecmp(buf, "show") == 0) runningPageStart = GPS_DEBUG;
+ // Lower it only. On a profiling build the start is already below
+ // GPS_DEBUG and an unguarded assignment here would hide the very
+ // page that build exists for.
+ if (strcasecmp(buf, "show") == 0 && runningPageStart > GPS_DEBUG) {
+ runningPageStart = GPS_DEBUG;
+ }
}
#endif
// Engine geometry. Anything other than an explicit "single" is treated as
// wasted spark, so a blank, garbled or future value degrades to today's
// behaviour rather than doubling every RPM reading.
+ //
+ // ONLY spark_mode feeds the RPM math. There is one sense wire and one
+ // clamp, so the pickup sees ONE cylinder's ignition however many the
+ // engine has; cylinder_count is read for the inferred-RPM warning and
+ // the debug line, and is deliberately NOT a divider (it used to be,
+ // which read a V8 on a single plug wire at an eighth of its real crank
+ // speed — see tach_filter.h "ENGINE GEOMETRY").
{
bool wastedSpark = true;
int cylinders = 1;
@@ -969,16 +1057,131 @@ void setup() {
const int n = atoi(buf);
if (n >= tach_filter::kMinCylinders) cylinders = n;
}
- tachRevsPerPulse = tach_filter::revsPerPulse(cylinders, wastedSpark);
- tachMinPulseGapUs = tach_filter::minPulseGapUs(cylinders, wastedSpark);
+ tachCylinderCount = tach_filter::clampCylinderCount(cylinders);
+ tachRevsPerPulse = tach_filter::revsPerPulse(wastedSpark);
+ tachMinPulseGapUs = tach_filter::minPulseGapUs(wastedSpark);
+ // RPM estimator (plan 0009). A track-side A/B knob, not a tuning
+ // dial: "smooth" is the shipped filter, "legacy" reproduces the
+ // pre-0009 one for comparison against existing logs, and "raw"
+ // turns the estimator off entirely so a session shows exactly what
+ // the pickup is delivering. Anything unrecognised means "smooth".
+ if (getSetting("tach_filter", buf, sizeof(buf))) {
+ tachFilterMode = tach_filter::modeFromSetting(buf);
+ }
debug(F("Engine: cyl="));
- debug(cylinders);
+ debug(tachCylinderCount);
+ if (tach_filter::rpmIsInferred(tachCylinderCount)) {
+ // Not a fault — the accepted behaviour of every clamp-on inductive
+ // tach — but it belongs in the boot log, because "RPM looks low
+ // between firings" on a multi-cylinder engine is this line, not a
+ // filter bug.
+ debug(F(" (RPM inferred from 1 cylinder)"));
+ }
debug(F(" spark="));
debug(wastedSpark ? F("wasted") : F("single"));
debug(F(" revsPerPulse="));
debug(tachRevsPerPulse);
debug(F(" minGapUs="));
- debugln((uint32_t)tachMinPulseGapUs);
+ debug((uint32_t)tachMinPulseGapUs);
+ debug(F(" filter="));
+ debugln(tach_filter::modeName(tachFilterMode));
+ }
+ // NeoPixel strip (plan 0006). Both clamp back to the compiled-in
+ // default on a missing or nonsense value, per the house idiom.
+ //
+ // That idiom needs setting_parse::parseIntSetting, not atoi(): atoi
+ // answers 0 for "" and for "garbage", and for every setting below
+ // EXCEPT target_rpm and temp1_alert_c, 0 is inside the accepted range.
+ // led_brightness 0 disables the LEDs and never raises the 5 V boost
+ // rail, so a blank value read as a deliberate "off" and looked exactly
+ // like dead hardware. parseIntSetting rejects a non-integer outright,
+ // the range check then fails, and the compiled-in default stands.
+ int parsedSetting = 0;
+ if (getSetting("led_brightness", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int b = parsedSetting;
+ if (b >= 0 && b <= 255) settingLedBrightness = (uint8_t)b;
+ }
+ // The shift/warning point. `rev_limit` before plan 0013 — devices
+ // upgrading get the old value migrated into the new key by
+ // ensureDefaultSettings(), so this only ever reads target_rpm.
+ if (getSetting("target_rpm", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int r = parsedSetting;
+ // Floor keeps a garbled value from parking the scale at zero;
+ // ceiling matches the tach filter's ~20k true-RPM limit.
+ if (r >= 1000 && r <= 20000) settingTargetRpm = r;
+ }
+ if (getSetting("overrev_limit", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int r = parsedSetting;
+ // 0 (the default) disables the whole-chain overrev flash; any
+ // other value clamps to the same band as target_rpm.
+ if (r == 0) settingOverrevLimit = 0;
+ else if (r >= 1000 && r <= 20000) settingOverrevLimit = r;
+ }
+#if BIRDSEYE_ENABLE_SENSOREGG
+ // SensorEgg builds only (plan 0013). ensureDefaultSettings() writes
+ // the key on the same condition, so on a stock image the key is not
+ // in the file and the compiled-in default stands unused — the `egt`
+ // status mode renders dark there regardless.
+ if (getSetting("temp1_alert_c", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int t = parsedSetting;
+ // Celsius. Floor above any plausible ambient so a garbled value
+ // can't latch the alert at power-on; ceiling past any real EGT.
+ if (t >= 50 && t <= 1200) settingTemp1AlertC = t;
+ }
+#endif
+ // Local time (plan 0010). The band is the pure unit's, not a literal
+ // here, so ±14 h has one home. Out of band keeps the 0 default —
+ // i.e. UTC — which is exactly the pre-0010 behaviour.
+ if (getSetting("utc_offset_min", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int o = parsedSetting;
+ if (local_time::isValidOffsetMinutes(o)) settingUtcOffsetMin = (int16_t)o;
+ }
+ if (getSetting("led_brightness_night", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int b = parsedSetting;
+ if (b >= 0 && b <= 255) settingLedBrightnessNight = (uint8_t)b;
+ }
+ if (getSetting("led_day_start_hour", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int h = parsedSetting;
+ if (h >= 0 && h <= 23) settingLedDayStartHour = (uint8_t)h;
+ }
+ if (getSetting("led_night_start_hour", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int h = parsedSetting;
+ if (h >= 0 && h <= 23) settingLedNightStartHour = (uint8_t)h;
+ }
+ // Plan 0013: the speed the LED bar scales against when a session has
+ // no tachometer, in mph.
+ if (getSetting("target_speed_mph", buf, sizeof(buf)) &&
+ setting_parse::parseIntSetting(buf, &parsedSetting)) {
+ const int v = parsedSetting;
+ // Floor 5, not 0: renderScale()'s span guard blanks the bar on a
+ // zero ceiling, which looks exactly like dead hardware — the same
+ // failure mode parseIntSetting exists to prevent for led_brightness.
+ if (v >= 5 && v <= 250) settingTargetSpeedMph = v;
+ }
+ // Status-LED assignment (plan 0013). STRICT parse on purpose: an
+ // unknown or blank mode keeps the compiled-in default rather than
+ // silently darkening a status LED or picking a different one. A mode
+ // this build cannot render (`egt` with no SensorEgg support) still
+ // parses and still round-trips to the companion app — only the
+ // rendering is gated.
+ {
+ led_status::Mode parsedMode;
+ if (getSetting("led_status_left", buf, sizeof(buf)) &&
+ led_status::parseMode(buf, &parsedMode)) {
+ settingLedStatusLeft = parsedMode;
+ }
+ if (getSetting("led_status_right", buf, sizeof(buf)) &&
+ led_status::parseMode(buf, &parsedMode)) {
+ settingLedStatusRight = parsedMode;
+ }
}
crossingThresholdMeters = settingLapDetectionDistance;
debug(F("Settings loaded: lap_dist="));
@@ -993,6 +1196,23 @@ void setup() {
debugln(settingDeviceName);
}
+ // NeoPixel strip (plan 0006). MUST run before anything that can enable
+ // the SoftDevice (SENSOREGG_SETUP below calls bleCoreEnsureInit on the
+ // beta channel): the one-time UICR NFC->GPIO write needs direct NVMC
+ // access, which is illegal once the SoftDevice is up. Also before
+ // wdtSetup() so the one-time self-reset can't race the watchdog. Needs
+ // SETTINGS_SETUP (led_brightness). Since 4.1.0 this runs on EVERY
+ // channel — BIRDSEYE_ENABLE_NEOPIXEL defaults to 1 — so the first boot
+ // of any 4.1.0+ image is the one that spends the NFC pads and resets
+ // once. See project.h.
+ NEOPIXEL_SETUP();
+
+ // Loop profiling (plan 0011, beta only). MUST run after NEOPIXEL_SETUP:
+ // the profiling pin is one of the NFC pads, and that call is what
+ // converts them to GPIO — and may self-reset the chip doing it. On a
+ // flag-off build this is an empty function.
+ PROFILING_SETUP();
+
// Camera auto-record: load the persisted Insta360 serial + init the FSM
CAMERA_SETUP();
@@ -1193,6 +1413,62 @@ bool activeTimerSectorsConfigured() {
return false;
}
+// Sector accessors for the LED purple-sector monitor (plan 0006).
+// Sprint-first like every sibling; WaypointLapTimer (Lap Anything) has
+// no sectors, so those sessions return 0 and the monitor stays reset.
+int activeTimerCurrentSector() {
+ if (sprintTimer != nullptr) return sprintTimer->getCurrentSector();
+ DovesLapTimer* dlt = getActiveTimerDLT();
+ if (dlt) return dlt->getCurrentSector();
+ return 0;
+}
+
+unsigned long activeTimerLapSectorTime(int sector) {
+ if (sprintTimer != nullptr) {
+ if (sector == 1) return sprintTimer->getCurrentLapSector1Time();
+ if (sector == 2) return sprintTimer->getCurrentLapSector2Time();
+ if (sector == 3) return sprintTimer->getCurrentLapSector3Time();
+ return 0;
+ }
+ DovesLapTimer* dlt = getActiveTimerDLT();
+ if (dlt) {
+ if (sector == 1) return dlt->getCurrentLapSector1Time();
+ if (sector == 2) return dlt->getCurrentLapSector2Time();
+ if (sector == 3) return dlt->getCurrentLapSector3Time();
+ }
+ return 0;
+}
+
+unsigned long activeTimerBestSectorTime(int sector) {
+ if (sprintTimer != nullptr) {
+ if (sector == 1) return sprintTimer->getBestSector1Time();
+ if (sector == 2) return sprintTimer->getBestSector2Time();
+ if (sector == 3) return sprintTimer->getBestSector3Time();
+ return 0;
+ }
+ DovesLapTimer* dlt = getActiveTimerDLT();
+ if (dlt) {
+ if (sector == 1) return dlt->getBestSector1Time();
+ if (sector == 2) return dlt->getBestSector2Time();
+ if (sector == 3) return dlt->getBestSector3Time();
+ }
+ return 0;
+}
+
+/**
+ * @brief The session's engine has proven itself and then died (plan
+ * 0007): a stall/DNF posture, not a lap in progress. RACE_ENTRY_TACH is
+ * exactly "the tach proved itself this session" — manual/speed sessions
+ * promote to it at >500 RPM (idle_policy) — so no-tach devices (RPM
+ * always 0, cause never TACH) can never trip this. Clears by itself the
+ * moment the engine restarts (tach reports nonzero within one loop).
+ * Read by the LED strip (bar off) and the pace page (STOPPED).
+ */
+bool raceEngineStopped() {
+ return raceActive && raceEntryCause == RACE_ENTRY_TACH &&
+ tachLastReported == 0;
+}
+
/**
* @brief Build the sprint session from the just-parsed track file: pick the
* newest course by date_created (autocross venues re-lay the course every
@@ -2223,6 +2499,10 @@ static void softResumeFromCharging() {
// the POC is compiled out — BLE/camera stay lazy either way).
SENSOREGG_WAKE();
+ // Boost rail back up + strip re-init (no-op when compiled out or
+ // brightness 0).
+ NEOPIXEL_WAKE();
+
DISPLAY_WAKE();
menuIdleTimerRunning = false;
if (!sdSetupSuccess && sdCardUnformatted) {
@@ -2276,6 +2556,17 @@ void enterShutdown() {
pinMode(PIN_LSM6DS3TR_C_POWER, OUTPUT);
digitalWrite(PIN_LSM6DS3TR_C_POWER, HIGH);
}
+
+ // LED strip blanked, then its 5 V boost rail off (EN driven LOW —
+ // retained through System OFF, and the charging loop below never
+ // re-enables it, so the strip is dark while charging too).
+ NEOPIXEL_SLEEP();
+ // Park the profiling pin LOW. The loop scope guard's destructor is what
+ // normally lowers it, and this path never returns to it — without this
+ // the pin would sit HIGH for the whole power-down. No-op off the beta
+ // channel. (Note it does NOT drop the 5 V rail: on a profiling build
+ // NEOPIXEL_SLEEP no longer owns EN — see project.h.)
+ PROFILING_SLEEP();
wdtPet();
// VBUS exception: never System OFF while a cable is present. Powering
@@ -2307,13 +2598,20 @@ void loop() {
wdtPet();
#endif
+ // Whole-iteration timing + the once-a-second rollup (plan 0011, beta
+ // only). A scope guard rather than a call at the bottom because both
+ // parked branches below return early and enterShutdown() never returns
+ // at all — a profiler that goes quiet exactly when the firmware parks
+ // would be measuring the wrong thing. Compiles to nothing off beta.
+ PROFILE_LOOP_SCOPE();
+
#ifdef HAS_DEBUG
unsigned long loopStart = millis();
#endif
// When BLE is active, skip GPS/tach/lap processing for better throughput
if (bleActive) {
- BLUETOOTH_LOOP();
+ PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP());
// Keep battery voltage fresh for BLE BATT command and display
if (millis() - lastBatteryCheck > batteryUpdateInterval) {
@@ -2321,11 +2619,12 @@ void loop() {
lastBatteryVoltage = getBatteryVoltage();
}
- // Minimal button check for exit
- readButtons();
+ // Minimal button check for exit. Leaving transfer mode reboots (same
+ // as the phone-disconnect auto-reboot and the USB exit) so changed
+ // settings take effect and no session state leaks.
+ PROFILE_SECTION(loop_profile::kButtons, readButtons());
if (btn2->pressed) {
- BLE_STOP();
- switchToDisplayPage(PAGE_MAIN_MENU);
+ bleExitTransferMode(); // does not return (NVIC_SystemReset)
}
resetButtons();
@@ -2333,9 +2632,13 @@ void loop() {
unsigned long displayInterval = bleTransferInProgress ? 5000 : (1000 / displayUpdateRateHz);
if (millis() - displayLastUpdate > displayInterval) {
displayLastUpdate = millis();
- displayPage_bluetooth();
+ PROFILE_SECTION(loop_profile::kDisplay, displayPage_bluetooth());
}
+ // Keep the LED frame ticking so the strip blanks (composition sees
+ // the parked state) instead of freezing mid-pattern.
+ PROFILE_SECTION(loop_profile::kLed, NEOPIXEL_LOOP());
+
return; // Skip GPS, tach, lap checks while BLE is active
}
@@ -2354,7 +2657,7 @@ void loop() {
}
// Minimal button check for the on-device Exit (Select).
- readButtons();
+ PROFILE_SECTION(loop_profile::kButtons, readButtons());
if (btn2->pressed) {
USB_MSC_DISABLE(); // does not return (NVIC_SystemReset)
}
@@ -2363,24 +2666,36 @@ void loop() {
// Refresh the status page at the normal rate.
if (millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) {
displayLastUpdate = millis();
- displayPage_usb_storage();
+ PROFILE_SECTION(loop_profile::kDisplay, displayPage_usb_storage());
}
+ // Same as the BLE branch: blank the strip rather than freeze it.
+ PROFILE_SECTION(loop_profile::kLed, NEOPIXEL_LOOP());
+
return; // host owns the card — skip GPS/tach/lap/SD entirely
}
- GPS_LOOP();
- TACH_LOOP();
- ACCEL_LOOP();
- BLUETOOTH_LOOP();
- SENSOREGG_LOOP(); // drain SensorEgg scan buffer (Temp1 fresh for logging)
-
- trackDetectionLoop();
- checkForNewLapData();
- checkAutoIdle();
- autoRaceModeCheck();
- updateGpsLockHold();
- CAMERA_LOOP(); // step the Insta360 auto-record FSM (GPS/tach fresh above)
+ // Each subsystem call is bracketed for the loop profiler (plan 0011).
+ // PROFILE_SECTION expands to the bare call off the beta channel, so
+ // this is the same sequence it has always been.
+ PROFILE_SECTION(loop_profile::kGps, GPS_LOOP());
+ PROFILE_SECTION(loop_profile::kTach, TACH_LOOP());
+ PROFILE_SECTION(loop_profile::kAccel, ACCEL_LOOP());
+ PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP());
+ // drain SensorEgg scan buffer (Temp1 fresh for logging)
+ PROFILE_SECTION(loop_profile::kEgg, SENSOREGG_LOOP());
+
+ PROFILE_SECTION(loop_profile::kTrack, trackDetectionLoop());
+ PROFILE_SECTION(loop_profile::kLap, checkForNewLapData());
+ // One section for the three session-state checks: individually they are
+ // a handful of comparisons, and thirteen slots on a 128x64 page is
+ // already the budget.
+ PROFILE_SECTION(loop_profile::kIdle, checkAutoIdle(); autoRaceModeCheck();
+ updateGpsLockHold());
+ // step the Insta360 auto-record FSM (GPS/tach fresh above)
+ PROFILE_SECTION(loop_profile::kCamera, CAMERA_LOOP());
+ // LED strip frame (RPM/pace/purple fresh above)
+ PROFILE_SECTION(loop_profile::kLed, NEOPIXEL_LOOP());
// Camera auto-stopped recording (30 s engine-off): end + save the race
// session and return to the menu — the camera stays connected in WATCHING,
@@ -2399,8 +2714,12 @@ void loop() {
}
}
- // Button hold detection for shutdown/reboot combos
- updateButtonHoldState();
+ // Button hold detection for shutdown/reboot combos. Bracketed into the
+ // same section as readButtons(): it runs the identical multi-sample
+ // debounce on all three pins, so leaving it out would park a
+ // hold-dependent cost (up to ~1 ms per HELD button) in OTH, where it
+ // reads as unexplained overhead rather than as button sampling.
+ PROFILE_SECTION(loop_profile::kButtons, updateButtonHoldState());
// Long-press left+right (5s) on main menu -> shutdown
if (currentPage == PAGE_MAIN_MENU &&
@@ -2473,11 +2792,15 @@ void loop() {
calculateGPSFrameRate();
- readButtons();
- gpsStatusPageLoop(); // boot status page: consume presses, hold/auto-close
- sdFormatPageLoop(); // boot format-confirm page: hold Select 3s to format
- courseCreatorLoop(); // course creator: feed GPS into an averaging hold
- displayLoop();
+ PROFILE_SECTION(loop_profile::kButtons, readButtons());
+ // The boot-page state machines share the display section: they are the
+ // same family of work (page logic), and folding them freed the one grid
+ // slot the LOOP PROFILE page needed for SLP — see loop_profile.h.
+ // gpsStatusPageLoop: consume presses, hold/auto-close
+ // sdFormatPageLoop: hold Select 3s to format
+ // courseCreatorLoop: feed GPS into an averaging hold
+ PROFILE_SECTION(loop_profile::kDisplay, gpsStatusPageLoop();
+ sdFormatPageLoop(); courseCreatorLoop(); displayLoop());
resetButtons();
if (tachLastReported > topTachReported) {
diff --git a/BirdsEye/ble_stream.cpp b/BirdsEye/ble_stream.cpp
new file mode 100644
index 0000000..1ebff20
--- /dev/null
+++ b/BirdsEye/ble_stream.cpp
@@ -0,0 +1,62 @@
+#include "ble_stream.h"
+
+namespace ble_stream {
+
+uint16_t chunkSize(uint16_t negotiatedMtu) {
+ // Three bytes of ATT header (opcode + handle) come off the MTU before the
+ // payload. An MTU that never negotiated is the 23-byte default, which still
+ // leaves 20 usable bytes — slow, but a working transfer beats a zero-length
+ // notify loop that never advances.
+ uint16_t payload = (negotiatedMtu > 3) ? (uint16_t)(negotiatedMtu - 3) : 20;
+ if (payload > kMaxNotifyLen) payload = kMaxNotifyLen;
+ if (payload == 0) payload = 20;
+ return payload;
+}
+
+uint32_t rateBytesPerSec(uint32_t bytes, uint32_t elapsedMs) {
+ if (elapsedMs == 0) return 0;
+ // 64-bit intermediate: a 3 MB transfer times 1000 overflows uint32 well
+ // before the file does.
+ return (uint32_t)(((uint64_t)bytes * 1000u) / elapsedMs);
+}
+
+ReadAhead::ReadAhead(uint32_t capacity)
+ : cap_(capacity), head_(0), filled_(0) {}
+
+void ReadAhead::reset() {
+ head_ = 0;
+ filled_ = 0;
+}
+
+Move ReadAhead::compact() {
+ Move m = {head_, pending()};
+ // Nothing to move when the buffer is drained, or when the tail already
+ // starts at offset 0 — but the indices still have to collapse so the free
+ // space below is measured from the tail's new end.
+ head_ = 0;
+ filled_ = m.length;
+ if (m.srcOffset == 0) m.length = 0;
+ return m;
+}
+
+void ReadAhead::commitFill(uint32_t bytesRead) {
+ const uint32_t space = fillSpace();
+ if (bytesRead > space) bytesRead = space;
+ filled_ += bytesRead;
+}
+
+uint16_t ReadAhead::nextSlice(uint16_t chunk, uint32_t* offsetOut) const {
+ const uint32_t avail = pending();
+ if (avail == 0 || chunk == 0) return 0;
+ if (offsetOut) *offsetOut = head_;
+ // A tail shorter than a chunk is the end of the file: needsRefill() would
+ // have topped it up otherwise.
+ return (avail < chunk) ? (uint16_t)avail : chunk;
+}
+
+void ReadAhead::consume(uint16_t n) {
+ const uint32_t avail = pending();
+ head_ += (n > avail) ? avail : n;
+}
+
+} // namespace ble_stream
diff --git a/BirdsEye/ble_stream.h b/BirdsEye/ble_stream.h
new file mode 100644
index 0000000..d576517
--- /dev/null
+++ b/BirdsEye/ble_stream.h
@@ -0,0 +1,117 @@
+#ifndef BLE_STREAM_H
+#define BLE_STREAM_H
+
+// BLE file-transfer stream bookkeeping (pure logic, no Arduino headers).
+//
+// The firmware owns the bytes, the file and the radio; this unit owns the
+// index arithmetic that decides how much to read, where to read it, and which
+// slice of RAM the next notification carries. See
+// docs/plans/0008-ble-download-throughput.md.
+//
+// Why a read-ahead exists at all: notifying straight out of SdFat put a disk
+// read in the radio's critical path and, because a chunk is not a sector,
+// spent one single-block SD command per 512 bytes. One large aligned read
+// into RAM turns that into a multi-block transfer and lets the notifications
+// stream from memory.
+
+#include
+
+namespace ble_stream {
+
+// fileDataChar.setMaxLen() — the largest payload the data characteristic can
+// carry regardless of how generous the negotiated MTU is.
+inline constexpr uint16_t kMaxNotifyLen = 244;
+
+// Read-ahead capacity. Eight 512-byte sectors: big enough for SdFat to issue
+// one multi-block read, small enough that the refill stall (~4.3 ms at 8 MHz)
+// stays well inside a connection interval and is covered by the bytes already
+// queued in the SoftDevice.
+inline constexpr uint32_t kReadAheadSize = 4096;
+
+// Wall-clock budget for one burst of notifications before the main loop gets
+// a turn. Bounded by time rather than packet count: a fixed count is a very
+// different amount of time on a fast link than on a slow one, and the only
+// thing the bound protects is loop responsiveness (exit button, watchdog).
+inline constexpr uint32_t kBurstBudgetMs = 20;
+
+// Payload for one notification at the negotiated ATT MTU (3 bytes of ATT
+// header), clamped to what the characteristic can send. Never returns 0 — an
+// unnegotiated link still moves 20 bytes at a time.
+uint16_t chunkSize(uint16_t negotiatedMtu);
+
+// Transfer rate in bytes/sec. Returns 0 for an empty window rather than
+// dividing by zero, so a caller can print it unconditionally.
+uint32_t rateBytesPerSec(uint32_t bytes, uint32_t elapsedMs);
+
+// The memmove a compacting refill asks the caller to perform: move `length`
+// bytes from `srcOffset` to offset 0 of the buffer.
+struct Move {
+ uint32_t srcOffset;
+ uint32_t length;
+};
+
+// Linear read-ahead buffer over a caller-owned byte array.
+//
+// Refills are COMPACTING: the unsent tail is moved to the front before the
+// next read, so every notification except the file's last one carries a full
+// chunk. Without that, each buffer boundary would emit a runt packet
+// (4096 mod 244 = 190).
+//
+// A refill is three steps so the caller can do the memmove and the SD read
+// itself:
+// ble_stream::Move m = ra.compact();
+// if (m.length) memmove(buf, buf + m.srcOffset, m.length);
+// n = file.read(buf + ra.fillOffset(), ra.fillSpace());
+// ra.commitFill(n);
+class ReadAhead {
+ public:
+ explicit ReadAhead(uint32_t capacity);
+
+ void reset();
+
+ uint32_t capacity() const { return cap_; }
+ // Unsent bytes currently held.
+ uint32_t pending() const { return filled_ - head_; }
+ // Offset of the next unsent byte.
+ uint32_t head() const { return head_; }
+
+ // True when the buffer can no longer serve a full chunk. A short tail is
+ // refilled rather than sent, so the stream stays at full packet size until
+ // the file itself runs out.
+ bool needsRefill(uint16_t chunk) const { return pending() < chunk; }
+
+ // Step 1: slide the unsent tail to offset 0. Returns the memmove the caller
+ // must perform; `length` is 0 when the buffer is empty or the tail is
+ // already at the front, in which case there is nothing to move.
+ Move compact();
+
+ // Steps 2a/2b: where a refill read should land, and how many bytes to ask
+ // for. Only meaningful immediately after compact().
+ uint32_t fillOffset() const { return filled_; }
+ uint32_t fillSpace() const { return cap_ - filled_; }
+
+ // Step 3: record what the read actually returned. Clamped to the free
+ // space, so a misbehaving read can never push filled_ past capacity.
+ void commitFill(uint32_t bytesRead);
+
+ // The next notification slice. Writes the buffer offset to *offsetOut and
+ // returns its length, or 0 when nothing is buffered. A tail shorter than
+ // `chunk` is returned in full — that is the end of the file, since
+ // needsRefill() would otherwise have topped it up.
+ uint16_t nextSlice(uint16_t chunk, uint32_t* offsetOut) const;
+
+ // Advance past bytes that were successfully sent. A failed notify simply
+ // does not call this, so the bytes are re-sent from RAM on the next pass —
+ // there is no file position to rewind and no way to punch a hole in a
+ // transfer that still reports DONE.
+ void consume(uint16_t n);
+
+ private:
+ uint32_t cap_;
+ uint32_t head_;
+ uint32_t filled_;
+};
+
+} // namespace ble_stream
+
+#endif // BLE_STREAM_H
diff --git a/BirdsEye/bluetooth.h b/BirdsEye/bluetooth.h
index d6372fd..e686d94 100644
--- a/BirdsEye/bluetooth.h
+++ b/BirdsEye/bluetooth.h
@@ -61,6 +61,13 @@ void BLE_SETUP();
// transfer file, release SD access, and drop bleOwner back to NONE.
void BLE_STOP();
+// Manual exit from the Bluetooth transfer page: BLE_STOP() then reboot
+// (NVIC_SystemReset), so a manual exit applies changed settings and clears
+// session state exactly like the phone-disconnect auto-reboot and the USB
+// mass-storage exit. Does not return on hardware; the SIM stub returns
+// after stopping the radio.
+void bleExitTransferMode();
+
// Force the Bluefruit connection LED off (autoConnLed disarm + park the
// pin high). Shared by BLE_STOP() and the shutdown quiesce.
void bleConnLedOff();
@@ -73,6 +80,33 @@ void bleConnLedOff();
void bleShutdownQuiesce();
// Service deferred commands from the BLE callback task: settings
-// commands, track upload/delete, MTU negotiation tail-read, and the
+// commands, track upload/delete, post-connect link tuning, and the
// burst-send chunk pipeline for any active file transfer.
void BLUETOOTH_LOOP();
+
+// --- Transfer diagnostics (read by the Bluetooth page) ---------------------
+// These exist so a download-speed regression is visible on the device instead
+// of being inferred from a progress bar. See
+// docs/plans/0008-ble-download-throughput.md.
+
+// Live transfer rate in bytes/sec, 0 when nothing is streaming.
+uint32_t bleTransferRateBps();
+
+// Negotiated link-layer PDU in bytes. 27 means Data Length Extension never
+// happened, which fragments every notification into ten packets — by far the
+// largest throughput tax on this link.
+uint16_t bleLinkDataLength();
+
+// Payload carried by one notification at the negotiated ATT MTU.
+uint16_t bleLinkChunkSize();
+
+// Connection interval in 1.25 ms units, live from the connection object
+// (0 = no peer). The central owns this number — the device only requests —
+// and 24 units (30 ms) instead of 12 (15 ms) is a silent 2x on every
+// download, so it belongs on the page (plan 0012).
+uint16_t bleLinkIntervalUnits();
+
+// Radio PHY, live from the connection object: 1 = 1M, 2 = 2M, 4 = Coded,
+// 0 = no peer. A link that ignored the 2M request pays double airtime per
+// packet (plan 0012).
+uint8_t bleLinkPhy();
diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino
index 92bc728..7a4bbcc 100644
--- a/BirdsEye/bluetooth.ino
+++ b/BirdsEye/bluetooth.ino
@@ -4,9 +4,52 @@
///////////////////////////////////////////
#include "bluetooth.h"
+#include "ble_stream.h"
#include "camera_ble.h"
#include "filename_validator.h"
#include "firmware_ota.h"
+// For SENSOREGG_SLEEP() — BLE_SETUP() quiesces the egg scanner so the
+// transfer link never shares the radio with a scan window (plan 0012).
+#include "sensoregg.h"
+// For SETTINGS_JSON_CAPACITY and getSetting/setSetting. This module used
+// them via Arduino's concatenation of BirdsEye.ino's includes; naming the
+// dependency follows camera_ble.ino and keeps the SLIST buffer sizes tied
+// to the settings module that owns them.
+#include "settings.h"
+
+// Target connection interval in 1.25 ms units: 12 = 15 ms, the fastest an
+// Apple central is permitted to accept from an accessory. See bleTuneLink().
+#define BLE_TARGET_INTERVAL_UNITS 12
+// Any negotiated link-layer PDU at or above this counts as "DLE took". The
+// un-extended default is 27; a successful extension lands at 251 (or whatever
+// the peer allows), so the exact value matters less than clearing the floor.
+#define BLE_DATA_LENGTH_MIN_EXTENDED 100
+
+// Two-stage post-connect link tuning: 0 = idle, 1 = read + correct at
+// +500 ms, 2 = final readback at +1500 ms. Written by the connect callback,
+// consumed by the main loop; the stages themselves only ever run on the loop.
+static volatile uint8_t bleLinkTuneStage = 0;
+static volatile uint32_t bleLinkTuneStartMs = 0;
+static volatile uint16_t bleLinkTuneConnHandle = 0;
+// Negotiated link-layer PDU (bytes). 27 means DLE never happened, which is
+// the single biggest download-throughput tax there is — surfaced on the
+// transfer page so it is visible instead of inferred.
+static volatile uint16_t bleLinkDataLen = 27;
+
+// File-transfer read-ahead. Notifying straight out of SdFat put a disk read
+// in the radio's critical path and cost one single-block SD command per
+// 512 bytes (a chunk is not a sector). One aligned 4 KB read is a single
+// multi-block transfer, and the notifications then stream out of RAM. The
+// index arithmetic lives in the host-tested ble_stream unit; the bytes and
+// the file live here. See docs/plans/0008-ble-download-throughput.md.
+static uint8_t bleStreamBuf[ble_stream::kReadAheadSize];
+static ble_stream::ReadAhead bleStream(ble_stream::kReadAheadSize);
+
+// Live transfer rate, recomputed as chunks go out so the device can show
+// KB/s instead of only a percentage — the whole reason this regression took
+// a session to notice.
+static uint32_t bleTransferStartMs = 0;
+static uint32_t bleTransferRate = 0;
// Deferred settings command buffer (BLE callback -> main loop)
static volatile bool settingsCmdPending = false;
@@ -101,19 +144,90 @@ void bleConnectCallback(uint16_t conn_handle) {
// Request 2M PHY for double raw throughput (BLE 5.0, both sides must support)
connection->requestPHY(BLE_GAP_PHY_2MBPS);
- // Request Data Length Extension (max PDU size, reduces L2CAP overhead)
+ // Request Data Length Extension (251-byte link-layer PDU). Without it every
+ // 244-byte notification fragments into ten 27-byte packets, each paying its
+ // own header/CRC/inter-frame space — a 3-4x tax on exactly this transfer.
+ //
+ // This ask is best-effort and NOT trusted: the SoftDevice runs one
+ // link-layer control procedure at a time, so firing it immediately behind
+ // the PHY update above can come straight back as NRF_ERROR_BUSY, which
+ // Bluefruit swallows into a false return. bleTuneLink() below verifies the
+ // result once the link has settled and re-asks if it did not take.
connection->requestDataLengthUpdate();
- // Defer MTU read to main loop instead of blocking here with delay(500)
- bleWaitingForMTU = true;
- bleMTURequestTime = millis();
- bleMTUConnHandle = conn_handle;
+ // Link tuning (MTU / DLE / interval readback and retry) is deferred to the
+ // main loop rather than blocking this callback with a delay(500).
+ bleLinkTuneStartMs = millis();
+ bleLinkTuneConnHandle = conn_handle;
+ bleLinkTuneStage = 1;
+ bleLinkDataLen = 27; // pre-DLE default; corrected at stage 1
debug(F("BLE: Connection interval: "));
debug(connection->getConnectionInterval() * 1.25);
debugln(F("ms"));
}
+// Re-check what the link actually negotiated and fix what did not take.
+// Called from BLUETOOTH_LOOP() on a two-stage timer after connect: stage 1
+// (+500 ms) reads the settled parameters and issues corrections, stage 2
+// (+1500 ms) records the final numbers for the display. Both stages run on
+// the main loop, so the SoftDevice calls here never race a callback.
+//
+// Takes the connection HANDLE, not a BLEConnection* — a Bluefruit type in a
+// parameter list would need visible where Arduino inserts its
+// generated prototypes, and silently degrades to `int` when it is not (see
+// the auto-prototype note in CLAUDE.md's conventions). Returns false when the
+// peer is gone.
+static bool bleTuneLink(uint16_t conn_handle) {
+ BLEConnection* connection = Bluefruit.Connection(conn_handle);
+ // Bluefruit keeps the connection object around after a drop, so check the
+ // link is actually up before tuning it — otherwise a peer that leaves
+ // between stages gets requests sent into a dead handle.
+ if (!connection || !connection->connected()) return false;
+
+ bleNegotiatedMtu = connection->getMtu();
+ bleLinkDataLen = connection->getDataLength();
+ uint16_t intervalUnits = connection->getConnectionInterval();
+
+ debug(F("BLE: MTU "));
+ debug(bleNegotiatedMtu);
+ debug(F(", data length "));
+ debug(bleLinkDataLen);
+ debug(F(", PHY "));
+ debug(connection->getPHY());
+ debug(F(", interval "));
+ debug(intervalUnits * 1.25);
+ debugln(F("ms"));
+
+ if (bleLinkTuneStage != 1) return true; // stage 2 is readback only
+
+ // DLE did not take (still the 27-byte default). The likeliest cause is the
+ // connect-time ask colliding with the PHY update, so ask again now that
+ // nothing else is in flight.
+ if (bleLinkDataLen < BLE_DATA_LENGTH_MIN_EXTENDED) {
+ debugln(F("BLE: data length still default — re-requesting DLE"));
+ connection->requestDataLengthUpdate();
+ }
+
+ // Connection interval. The advertised preference (setConnInterval(6, 12) =
+ // 7.5-15 ms) is deliberately aggressive and desktop/Android centrals honour
+ // it — those are already fast and must not be slowed down, so the
+ // preference itself stays put. iOS is the problem: Apple's accessory
+ // guidelines require a requested Interval Min of at least 15 ms, so a
+ // 7.5 ms preference is rejected outright and the link stays on whatever
+ // iOS chose at connect (commonly 30 ms) — half the connection events, half
+ // the throughput. Only when we find ourselves slower than the target do we
+ // make a second, Apple-compliant ask that the central is allowed to accept.
+ if (intervalUnits > BLE_TARGET_INTERVAL_UNITS) {
+ debug(F("BLE: interval slower than target — requesting "));
+ debug(BLE_TARGET_INTERVAL_UNITS * 1.25);
+ debugln(F("ms"));
+ connection->requestConnectionParameter(BLE_TARGET_INTERVAL_UNITS);
+ }
+
+ return true;
+}
+
void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) {
// Camera link — route to the camera module and return. This makes the
// transfer teardown below (and with it the deferred auto-reboot in
@@ -132,6 +246,8 @@ void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) {
debugln(F("BLE: Disconnected!"));
bleConnected = false;
bleNegotiatedMtu = 23; // Reset to default
+ bleLinkTuneStage = 0; // abandon any pending link tuning for this peer
+ bleLinkDataLen = 27; // pre-DLE default
// Pure in-RAM flag resets are safe from this callback (Bluefruit) task.
bleTransferInProgress = false;
@@ -403,6 +519,11 @@ void bleStartFileTransfer(const char* filename) {
bleFileSize = bleCurrentFile.size();
bleBytesTransferred = 0;
+ // Drop anything the previous transfer left buffered — a stale tail would
+ // be prepended to this file.
+ bleStream.reset();
+ bleTransferStartMs = millis();
+ bleTransferRate = 0;
bleTransferInProgress = true;
debug(F("BLE: File size: "));
@@ -723,6 +844,16 @@ void BLE_SETUP() {
// in BLE_STOP() (and by the auto-reboot on phone disconnect).
sdSetTransferSpeed(true);
+ // Quiesce the SensorEgg scanner for the whole transfer session (plan
+ // 0012). The scanner is race-gated and the transfer page is only
+ // reachable from the menu (race over, scan already down), so this is
+ // belt-and-suspenders — but the 44% scan duty was measured throttling
+ // downloads to ~33 KB/s by denying connection-event extension, so the
+ // transfer link gets an explicit guarantee, not an inference. No wake
+ // path needed: every exit from transfer mode reboots. No-op on
+ // flag-off builds.
+ SENSOREGG_SLEEP();
+
debugln(F("BLE: Starting transfer mode..."));
bleCoreEnsureInit();
@@ -806,6 +937,23 @@ void BLE_STOP() {
debugln(F("BLE: Bluetooth stopped"));
}
+// Manual exit from the Bluetooth transfer page (the on-device Exit button).
+// Leaving transfer mode ALWAYS reboots, matching the phone-disconnect
+// auto-reboot and the USB mass-storage exit: a reboot is what guarantees
+// settings changed over BLE take effect and that no radio/advert/SD state
+// leaks from the transfer session into the next driving session. Before
+// this, only a peer disconnect rebooted — a manual exit dropped back to the
+// menu on the old settings. BLE_STOP() first so the teardown (file close,
+// SD release, OTA abort, advert stop) runs cleanly on the main loop before
+// the reset. Does not return on hardware; the SIM stub stops the radio and
+// returns so the sim's menu walk can continue.
+void bleExitTransferMode() {
+ BLE_STOP();
+ debugln(F("BLE: Transfer mode exited — rebooting..."));
+ delay(100); // let debug output flush (mirrors the disconnect auto-reboot)
+ NVIC_SystemReset();
+}
+
// Force the Bluefruit connection LED off and keep it off. Bluefruit drives
// LED_CONN (the XIAO's blue LED, active-low) whenever _led_conn is enabled —
// which is the library DEFAULT, so camera-owned advertising/links blink it
@@ -910,7 +1058,13 @@ void processSettingsCommand() {
return;
}
- char fileBuf[512];
+ // The SECOND parser of /SETTINGS.json (settings.ino has the other).
+ // Both are sized by SETTINGS_JSON_CAPACITY so they can never drift
+ // again — see the comment on it in settings.h for what happened when
+ // they did. static, not stack: 2 KB in one frame is more than the loop
+ // task's budget wants, and it matches the house idiom in
+ // sd_functions.ino ("keeps JSON_BUFFER_SIZE off the stack").
+ static char fileBuf[SETTINGS_JSON_CAPACITY];
int bytesRead = settingsFile.read(fileBuf, sizeof(fileBuf) - 1);
settingsFile.close();
releaseSDAccess(SD_ACCESS_TRACK_PARSE);
@@ -926,7 +1080,8 @@ void processSettingsCommand() {
}
fileBuf[bytesRead] = '\0';
- StaticJsonDocument<512> doc;
+ static StaticJsonDocument doc;
+ doc.clear();
DeserializationError err = deserializeJson(doc, fileBuf);
if (err != DeserializationError::Ok) {
debug(F("BLE: SLIST - JSON parse error: "));
@@ -1189,51 +1344,124 @@ void BLUETOOTH_LOOP() {
// apply sequence).
FW_OTA_LOOP();
- // Deferred MTU negotiation - read result 500ms after request
- if (bleWaitingForMTU && millis() - bleMTURequestTime >= 500) {
- bleWaitingForMTU = false;
- BLEConnection* connection = Bluefruit.Connection(bleMTUConnHandle);
- if (connection) {
- bleNegotiatedMtu = connection->getMtu();
- debug(F("BLE: Negotiated MTU: "));
- debugln(bleNegotiatedMtu);
+ // Deferred link tuning — see bleTuneLink(). Stage 1 reads what actually
+ // negotiated and corrects it, stage 2 records the settled result.
+ if (bleLinkTuneStage > 0) {
+ uint32_t due = (bleLinkTuneStage == 1) ? 500 : 1500;
+ if (millis() - bleLinkTuneStartMs >= due) {
+ uint8_t stage = bleLinkTuneStage;
+ // Abandon on a peer that went away mid-tune, so a dropped connection
+ // can't leave the stage timer re-firing every loop iteration.
+ bleLinkTuneStage = bleTuneLink(bleLinkTuneConnHandle)
+ ? ((stage == 1) ? 2 : 0)
+ : 0;
}
}
if (bleTransferInProgress && bleCurrentFile && Bluefruit.connected()) {
- // Use actual negotiated MTU
- uint16_t maxChunk = bleNegotiatedMtu - 3;
- uint8_t buffer[524];
- size_t chunkSize = min(maxChunk, (uint16_t)244);
+ const uint16_t chunk = ble_stream::chunkSize(bleNegotiatedMtu);
+ const uint32_t burstStart = millis();
- // Burst send: read + notify multiple chunks per loop iteration.
+ // Burst send: keep notifying until the wall-clock budget is spent.
// notify() blocks via semaphore when the SoftDevice TX queue is full,
- // providing natural flow control. This keeps the pipeline fed instead
- // of sending 1 lonely chunk then wasting time on button checks.
- for (int burst = 0; burst < 10 && bleTransferInProgress; burst++) {
- size_t bytesRead = bleCurrentFile.read(buffer, chunkSize);
-
- if (bytesRead > 0) {
- if (!fileDataChar.notify(buffer, bytesRead)) {
- // Failed notify (HVN pool starved >100 ms, or disconnect). The
- // read already advanced the file position, so REWIND before
- // bailing — dropping the chunk and reading the next one on the
- // following iteration silently punched a hole in the delivered
- // file while the transfer carried on to a clean-looking DONE.
- bleCurrentFile.seekCur(-(int32_t)bytesRead);
- break;
+ // which is the flow control — a blocked notify means the radio is
+ // saturated, which is exactly where we want to be. The budget is in
+ // milliseconds rather than packets because a fixed packet count is a
+ // very different amount of time on a fast link than on a slow one, and
+ // the only thing it protects is loop responsiveness (exit button, WDT).
+ while (bleTransferInProgress &&
+ (millis() - burstStart) < ble_stream::kBurstBudgetMs) {
+ bool readFailed = false;
+
+ if (bleStream.needsRefill(chunk)) {
+ // Compacting refill: the unsent tail slides to the front so every
+ // notify but the file's last one carries a full chunk (without it,
+ // 4096 mod 244 = 192 bytes of runt packet at every buffer boundary).
+ ble_stream::Move m = bleStream.compact();
+ if (m.length) {
+ memmove(bleStreamBuf, bleStreamBuf + m.srcOffset, m.length);
}
- bleBytesTransferred += bytesRead;
- } else {
- // Transfer complete
+ int bytesRead = bleCurrentFile.read(bleStreamBuf + bleStream.fillOffset(),
+ bleStream.fillSpace());
+ if (bytesRead > 0) {
+ bleStream.commitFill((uint32_t)bytesRead);
+ } else if (bytesRead < 0) {
+ // SdFat read error, as distinct from end of file. Both used to fall
+ // into the same "no bytes" branch and report DONE, handing the app a
+ // truncated session file with a clean status on it.
+ readFailed = true;
+ }
+ }
+
+ if (readFailed) {
+ bleCurrentFile.close();
+ bleTransferInProgress = false;
+ releaseSDAccess(SD_ACCESS_BLE_TRANSFER);
+
+ debugln(F("BLE: SD read failed mid-transfer"));
+ fileStatusChar.notify((uint8_t*)"ERROR", 5);
+ break;
+ }
+
+ uint32_t offset = 0;
+ uint16_t slice = bleStream.nextSlice(chunk, &offset);
+
+ if (slice == 0) {
+ // Buffer drained and the file had nothing left to give — done.
bleCurrentFile.close();
bleTransferInProgress = false;
releaseSDAccess(SD_ACCESS_BLE_TRANSFER);
- debugln(F("BLE: Transfer complete!"));
+ // Recompute before printing: the update at the bottom of the block
+ // has not run for this final burst yet.
+ bleTransferRate = ble_stream::rateBytesPerSec(
+ bleBytesTransferred, millis() - bleTransferStartMs);
+ debug(F("BLE: Transfer complete! "));
+ debug(bleTransferRate / 1024);
+ debugln(F(" KB/s"));
fileStatusChar.notify((uint8_t*)"DONE", 4);
break;
}
+
+ if (!fileDataChar.notify(bleStreamBuf + offset, slice)) {
+ // Failed notify (HVN pool starved >100 ms, or disconnect). Nothing to
+ // undo: the bytes are still in RAM and the buffer head has not moved,
+ // so the identical slice goes out on the next pass. The old
+ // read-straight-from-SdFat path had to seekCur() backwards here, and
+ // getting that wrong punched a silent hole in a file that still
+ // reported DONE.
+ break;
+ }
+
+ bleStream.consume(slice);
+ bleBytesTransferred += slice;
}
+
+ bleTransferRate = ble_stream::rateBytesPerSec(bleBytesTransferred,
+ millis() - bleTransferStartMs);
}
}
+
+uint32_t bleTransferRateBps() { return bleTransferRate; }
+uint16_t bleLinkDataLength() { return bleLinkDataLen; }
+uint16_t bleLinkChunkSize() { return ble_stream::chunkSize(bleNegotiatedMtu); }
+
+// Live connection interval / PHY for the transfer page's second diagnostic
+// line (plan 0012). These read the BLEConnection object's cached values —
+// plain RAM updated by GAP events, safe from the display path on the main
+// loop — rather than a snapshot from bleTuneLink(), so a central that
+// renegotiates mid-transfer shows its real numbers. 0 when no peer is
+// connected.
+uint16_t bleLinkIntervalUnits() {
+ if (!bleInitialized || !Bluefruit.connected()) return 0;
+ BLEConnection* connection = Bluefruit.Connection(Bluefruit.connHandle());
+ return (connection && connection->connected())
+ ? connection->getConnectionInterval()
+ : 0;
+}
+
+uint8_t bleLinkPhy() {
+ if (!bleInitialized || !Bluefruit.connected()) return 0;
+ BLEConnection* connection = Bluefruit.Connection(Bluefruit.connHandle());
+ return (connection && connection->connected()) ? connection->getPHY() : 0;
+}
diff --git a/BirdsEye/course_creator.cpp b/BirdsEye/course_creator.cpp
index 1710587..5eaa3d8 100644
--- a/BirdsEye/course_creator.cpp
+++ b/BirdsEye/course_creator.cpp
@@ -58,8 +58,13 @@ void begin(State& s, bool trackDetected) {
uint8_t rowCount(const State& s) {
switch (s.screen) {
- case Screen::kTrackPrompt: return 2; // Here, New Track
- case Screen::kTypeSelect: return 2; // Circuit, Sprint
+ // Both entry screens are two choices plus a Cancel (Here/New Track,
+ // Circuit/Sprint). Every screen deeper in the flow already had one, and
+ // without it these two steps between the main menu and the line menu
+ // were a one-way door — a mis-pressed "Create" could only be escaped by
+ // walking forward into a screen that had one.
+ case Screen::kTrackPrompt:
+ case Screen::kTypeSelect: return 3;
case Screen::kLineMenu: return lineRowsFor(s.kind) + 2; // + Save, Cancel
case Screen::kLineDetail: return 4; // A, B, Save, Back
case Screen::kPointCapture: return 2; // Capture, Back
@@ -76,11 +81,15 @@ RowRef rowAt(const State& s, uint8_t index) {
RowRef ref;
switch (s.screen) {
case Screen::kTrackPrompt:
- ref.row = (index == 0) ? Row::kTrackHere : Row::kTrackNew;
+ if (index == 0) ref.row = Row::kTrackHere;
+ else if (index == 1) ref.row = Row::kTrackNew;
+ else ref.row = Row::kCancel;
return ref;
case Screen::kTypeSelect:
- ref.row = (index == 0) ? Row::kTypeCircuit : Row::kTypeSprint;
+ if (index == 0) ref.row = Row::kTypeCircuit;
+ else if (index == 1) ref.row = Row::kTypeSprint;
+ else ref.row = Row::kCancel;
return ref;
case Screen::kLineMenu: {
diff --git a/BirdsEye/course_creator.h b/BirdsEye/course_creator.h
index 3b4ec71..2203ceb 100644
--- a/BirdsEye/course_creator.h
+++ b/BirdsEye/course_creator.h
@@ -18,7 +18,9 @@
// LINE MENU one row per timing line, plus Save / Cancel
// LINE DETAIL Point A / Point B / Save / Back for one line
// POINT "Save current pos" — a timed averaging hold
-// preceded by the track prompt (here / new) and the type picker.
+// preceded by the track prompt (here / new) and the type picker. Every
+// screen, those two included, ends in a Cancel/Back row: entering the
+// creator by mistake must never be a one-way door.
//
// This unit owns the MODEL — which rows exist, what is captured, whether
// the course may be saved, and the point-averaging math. Navigation
@@ -127,8 +129,8 @@ enum class CaptureResult : uint8_t {
};
enum class Screen : uint8_t {
- kTrackPrompt, // "Are you at X?" Here / New Track
- kTypeSelect, // Circuit / Sprint
+ kTrackPrompt, // "Are you at X?" Here / New Track / Cancel
+ kTypeSelect, // Circuit / Sprint / Cancel
kLineMenu, // one row per line + Save + Cancel
kLineDetail, // Point A / Point B / Save / Back
kPointCapture, // Save current pos / Back
diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h
index d870720..8906215 100644
--- a/BirdsEye/display_pages.h
+++ b/BirdsEye/display_pages.h
@@ -58,6 +58,12 @@ void displayPage_optimal_lap();
void displayPage_gps_lap_list();
void displayPage_gps_debug();
+#if BIRDSEYE_ENABLE_PROFILING
+// Loop CPU profile (plan 0011, beta only) — first page of the race
+// rotation on a profiling build. See the definition for the layout.
+void displayPage_profile();
+#endif
+
// Stop-logging confirmation.
void displayPage_stop_logging();
void displayPage_stop_logging_confirm();
diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino
index 1230500..7249a7c 100644
--- a/BirdsEye/display_pages.ino
+++ b/BirdsEye/display_pages.ino
@@ -180,17 +180,49 @@ void displayPage_bluetooth() {
}
display.setTextSize(1);
- display.println();
if (bleTransferInProgress) {
+ // The spacer row the else-branch keeps is spent on the second
+ // diagnostic line below — with it the page is exactly 8 rows.
display.print(F("Transfer: "));
- display.print((bleBytesTransferred * 100) / bleFileSize);
+ // bleFileSize is 0 for a zero-byte file — print 100% rather than divide
+ // by zero.
+ display.print(bleFileSize ? ((bleBytesTransferred * 100) / bleFileSize) : 100);
display.println(F("%"));
+
+ // The diagnostic line. A download that has gone slow used to show only a
+ // creeping percentage, which says nothing about WHY — so this names the
+ // three things that decide the rate: the SD clock actually in force (the
+ // 8 MHz transfer bump falls back to 2 MHz silently), the negotiated
+ // link-layer PDU (27 = Data Length Extension never happened, the biggest
+ // tax there is), and the ATT payload per notification.
+ display.print(bleTransferRateBps() / 1024);
+ display.print(F("KB/s "));
+ display.print(sdActiveSpiHz() / 1000000UL);
+ display.print(F("M "));
+ display.print(bleLinkDataLength());
+ display.print(F(" "));
+ display.println(bleLinkChunkSize());
+
+ // Second diagnostic line (plan 0012): the two levers the line above
+ // cannot show. The connection interval is the CENTRAL's choice — the
+ // device only requests, and 30 ms instead of 15 ms is a silent 2x on
+ // every download. The PHY doubles per-packet airtime if the 2M request
+ // was ignored. A slow transfer with a healthy first line is one of
+ // these two, or radio contention.
+ display.print(bleLinkIntervalUnits() * 1.25, 1);
+ display.print(F("ms "));
+ const uint8_t phy = bleLinkPhy();
+ display.println(phy == 2 ? F("2M")
+ : phy == 1 ? F("1M")
+ : phy == 4 ? F("Coded")
+ : F("?"));
} else {
display.println();
+ display.println();
+ display.println();
}
- display.println();
display.setTextSize(1);
display.println(F("->Exit"));
@@ -209,6 +241,12 @@ void displayPage_transfer_menu() {
display.println(F("Bluetooth"));
display.print(menuSelectionIndex == 1 ? "->" : " ");
display.println(F("USB"));
+ // Back matters more here than on most menus: both transfer modes leave by
+ // rebooting, and this page is not the main menu so the idle-shutdown timer
+ // never runs on it. Without this row, opening Transfer by mistake could
+ // only be undone with the (unlabelled) reboot combo.
+ display.print(menuSelectionIndex == 2 ? "->" : " ");
+ display.println(F("Back"));
safeDisplayUpdate();
}
@@ -397,13 +435,58 @@ void displayPage_camera_serial_entry() {
safeDisplayUpdate();
}
+/**
+ * @brief Draw one row of the session browser.
+ *
+ * Every row occupies exactly two panel lines — a filename wrapped onto a
+ * second line when it exceeds the panel width, or a blank filler — so the
+ * window arithmetic below can count rows instead of lines.
+ *
+ * `index == numReplayFiles` is the trailing Back row (see
+ * replayItemCount()): browsing the sessions was otherwise a one-way door,
+ * with no way out but opening a session and walking its exit page.
+ */
+static void replayDrawEntry(int index, bool selected) {
+ display.print(selected ? F("->") : F(" "));
+
+ if (index >= numReplayFiles) {
+ display.println(F("Back"));
+ display.println();
+ return;
+ }
+
+ const int fileNameLen = strlen(replayFiles[index]);
+ char displayName[20];
+
+ strncpy(displayName, replayFiles[index], 19);
+ displayName[19] = '\0';
+ display.println(displayName);
+
+ if (fileNameLen > 19) {
+ display.print(F(" ")); // Indent to align with the first line
+ strncpy(displayName, replayFiles[index] + 19, 19);
+ displayName[19] = '\0';
+ display.println(displayName);
+ } else {
+ display.println(); // Blank line if no wrap needed
+ }
+}
+
void displayPage_replay_file_select() {
resetDisplay();
+ const int itemCount = replayItemCount();
+
display.print(F("Select Session: "));
- display.print(menuSelectionIndex + 1);
- display.print(F("/"));
- display.println(numReplayFiles);
+ // The Back row is not a session, so it gets no "n/total" — counting it
+ // would report one more session than the card holds.
+ if (menuSelectionIndex < numReplayFiles) {
+ display.print(menuSelectionIndex + 1);
+ display.print(F("/"));
+ display.println(numReplayFiles);
+ } else {
+ display.println();
+ }
display.println();
display.setTextSize(1);
@@ -414,86 +497,20 @@ void displayPage_replay_file_select() {
display.println();
display.println(F("Press any key"));
display.println(F("to go back"));
- } else if (numReplayFiles < 3) {
- // Small menu - show all files
- for (int i = 0; i < numReplayFiles; i++) {
- if (menuSelectionIndex == i) {
- display.print(F("->"));
- } else {
- display.print(F(" "));
- }
- // Split long filenames across two lines
- int fileNameLen = strlen(replayFiles[i]);
- char displayName[20];
-
- // First line: first 19 characters
- strncpy(displayName, replayFiles[i], 19);
- displayName[19] = '\0';
- display.println(displayName);
-
- // Second line: next 19 characters if filename is longer
- if (fileNameLen > 19) {
- display.print(F(" ")); // Indent to align with first line
- strncpy(displayName, replayFiles[i] + 19, 19);
- displayName[19] = '\0';
- display.println(displayName);
- } else {
- display.println(); // Blank line if no wrap needed
- }
+ } else if (itemCount <= 3) {
+ // Small menu — show everything, Back row included.
+ for (int i = 0; i < itemCount; i++) {
+ replayDrawEntry(i, menuSelectionIndex == i);
}
} else {
- // Scrolling menu
- int indexA = menuSelectionIndex == numReplayFiles - 1 ? 0 : menuSelectionIndex + 1;
- int indexB = menuSelectionIndex;
- int indexC = menuSelectionIndex == 0 ? numReplayFiles - 1 : menuSelectionIndex - 1;
-
- char displayName[20];
- int fileNameLen;
-
- // First item
- display.print(F(" "));
- fileNameLen = strlen(replayFiles[indexA]);
- strncpy(displayName, replayFiles[indexA], 19);
- displayName[19] = '\0';
- display.println(displayName);
- if (fileNameLen > 19) {
- display.print(F(" "));
- strncpy(displayName, replayFiles[indexA] + 19, 19);
- displayName[19] = '\0';
- display.println(displayName);
- } else {
- display.println();
- }
+ // Scrolling menu: next / selected / previous, wrapping over the whole
+ // item list so the Back row scrolls into view like any other row.
+ const int next = menuSelectionIndex == itemCount - 1 ? 0 : menuSelectionIndex + 1;
+ const int prev = menuSelectionIndex == 0 ? itemCount - 1 : menuSelectionIndex - 1;
- // Second item (selected)
- display.print(F("->"));
- fileNameLen = strlen(replayFiles[indexB]);
- strncpy(displayName, replayFiles[indexB], 19);
- displayName[19] = '\0';
- display.println(displayName);
- if (fileNameLen > 19) {
- display.print(F(" "));
- strncpy(displayName, replayFiles[indexB] + 19, 19);
- displayName[19] = '\0';
- display.println(displayName);
- } else {
- display.println();
- }
-
- // Third item
- display.print(F(" "));
- fileNameLen = strlen(replayFiles[indexC]);
- strncpy(displayName, replayFiles[indexC], 19);
- displayName[19] = '\0';
- display.println(displayName);
- if (fileNameLen > 19) {
- display.print(F(" "));
- strncpy(displayName, replayFiles[indexC] + 19, 19);
- displayName[19] = '\0';
- display.println(displayName);
- } else {
- display.println();
- }
+ replayDrawEntry(next, false);
+ replayDrawEntry(menuSelectionIndex, true);
+ replayDrawEntry(prev, false);
}
safeDisplayUpdate();
@@ -714,9 +731,13 @@ void displayPage_gps_pace() {
int paceLaps = activeTimerLaps();
float paceDiff = activeTimerPaceDifference();
bool paceRaceStarted = activeTimerRaceStarted();
+ // Engine died mid-session (plan 0007): the timer keeps running, but a
+ // live-counting pace next to a dead engine is a lie — say STOPPED.
+ // Also gates the notably-faster flash animation below.
+ bool engineStopped = raceEngineStopped();
// animation
- if (paceLaps >= 1 && paceDiff < (-1)) {
+ if (!engineStopped && paceLaps >= 1 && paceDiff < (-1)) {
if (paceFlashStatus) {
paceFlashStatus = false;
display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE);
@@ -735,7 +756,14 @@ void displayPage_gps_pace() {
// main page into
display.setTextColor(DISPLAY_TEXT_WHITE);
const int lineHeight = 21;
- if (sprintModeIsActive() && !activeTimerRunActive()) {
+ if (engineStopped) {
+ // 7 chars, NOT 8 with a leading space: size 3 is an 18 px advance, so
+ // " STOPPED" needs 144 px on a 128 px panel and the trailing D was
+ // clipped on every render. 7 x 18 = 126 fits, and x=1 centres it.
+ display.setCursor(1, lineHeight);
+ display.setTextSize(3);
+ display.print(F("STOPPED"));
+ } else if (sprintModeIsActive() && !activeTimerRunActive()) {
// Sprint mode, between runs — no live pace to compare (see lap page).
display.setCursor(0, lineHeight);
display.setTextSize(2);
@@ -758,7 +786,7 @@ void displayPage_gps_pace() {
display.println();
display.setTextSize(1);
- if (paceLaps >= 1 && paceDiff < (-1)) {
+ if (!engineStopped && paceLaps >= 1 && paceDiff < (-1)) {
if (paceFlashStatus) {
display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE);
display.print(F(" "));
@@ -809,7 +837,11 @@ void displayPage_gps_best_lap() {
void displayPage_tachometer() {
resetDisplay();
- if (tachLastReported > 9999) {
+ // OVER REV header means ACTUAL overrev (plan 0007): it trips only at
+ // the PROBLEM limit, and only when that limit is enabled — the
+ // target_rpm warning flag is a status LED's job, not the header's.
+ // (Was hardcoded >9999, which a 7600-limiter engine could never reach.)
+ if (settingOverrevLimit > 0 && tachLastReported >= settingOverrevLimit) {
display.println(F("Engine RPM *OVER REV*"));
} else {
display.println(F(" Engine RPM"));
@@ -842,6 +874,19 @@ void displayPage_tachometer() {
// Same "logging died" flag as the speed page — this is the tach
// session's landing page, so the signal has to exist here too.
display.print(F(" ** NOT LOGGING **"));
+ } else if (runningPageStart <= GPS_DEBUG) {
+ // Debug pages on: spend the subtext line on the tach diagnostic
+ // instead of centring the max. Which estimator is running (S/L/R —
+ // the `tach_filter` setting) and how many inter-pulse periods the
+ // outlier gate has thrown away. A reject count that climbs with RPM
+ // is the pickup, not the filter, and that distinction is the whole
+ // reason this line exists. 19 of the 21 columns at text size 1.
+ display.print(F("max:"));
+ display.print(topTachReported);
+ display.print(F(" "));
+ display.print(tach_filter::modeTag(tachFilterMode));
+ display.print(F(" rj:"));
+ display.print(tachRejectedPeriods());
} else {
display.print(F(" max: "));
display.print(topTachReported);
@@ -1121,6 +1166,115 @@ void displayPage_gps_debug() {
safeDisplayUpdate();
}
+#if BIRDSEYE_ENABLE_PROFILING
+/**
+ * @brief LOOP PROFILE — where a main-loop iteration's time goes.
+ *
+ * First page of the race rotation on a profiling build (plan 0011).
+ * Eight rows, all of them data:
+ *
+ * 14481Hz 61us mx42 loop rate, mean iteration, worst iteration
+ * (mean in us below 1 ms, else ms; mx in ms)
+ * GPS 41.2 TCH 0.4 every section's share of the last second,
+ * ... as a percentage, two per row
+ * DSP 14.9 OTH 4.1 OTH = loop() time no section bracketed
+ * SLP 11.3 SLP = wall time not inside loop() at all
+ *
+ * All fourteen slots are shares of the same wall-clock second, so they
+ * sum to ~100 (integer truncation loses a few tenths). If they do not,
+ * something is wrong with the measurement, not with the firmware.
+ *
+ * SLP is the headroom number: scheduler dispatch, other FreeRTOS tasks,
+ * and any time the CPU spent asleep. A large SLP means the loop rate is
+ * NOT the thing limiting this firmware.
+ *
+ * Two markers can lead the first row. '*' means the DWT cycle counter
+ * would not run and the numbers came from micros() instead — at 1 us
+ * resolution most of these sections quantise to zero, so treat the small
+ * ones as noise until that is fixed. '!' means the profiling pin was
+ * refused (NFC pads never converted), so the scope is dark even though
+ * these numbers are good. There is no title row: the rate line plus the
+ * two-column grid is unmistakable, and the eighth row is worth more than
+ * a caption.
+ *
+ * Shares are of WALL TIME, not of the iteration, so they are directly
+ * comparable with what a scope reads off the profiling pin.
+ */
+void displayPage_profile() {
+ resetDisplay();
+
+ const loop_profile::Report& r = profilingReport();
+ if (!r.valid) {
+ display.println(F("LOOP PROFILE"));
+ display.println();
+ display.println(F("sampling..."));
+ safeDisplayUpdate();
+ return;
+ }
+
+ // The mean is rendered in WHOLE MICROSECONDS below a millisecond and
+ // in milliseconds above it. The first hardware run came back reading
+ // `999Hz av0.0` — a loop rate pinned at what was then a 999 clamp and
+ // a mean quantised to nothing, because both fields had been sized
+ // from the "~250 Hz / 4 ms iteration" figure this project had carried
+ // in its docs for years. The real loop turned out to be an order of
+ // magnitude faster than that, which is precisely the sort of thing
+ // this page exists to find — so the clamps must not be the thing that
+ // hides it.
+ //
+ // The row still cannot outgrow the 21-character line, because rate
+ // and mean are reciprocal: a five-digit rate forces a sub-millisecond
+ // (<=4-char) mean, and a mean big enough to need "99.9ms" forces a
+ // three-digit rate. Text wrap is off, so an overflow would silently
+ // truncate rather than wrap — worth the coupling argument.
+ const uint32_t rate = (r.loopRateHz > 99999) ? 99999 : r.loopRateHz;
+ const uint32_t maxMs = (r.loopMaxUs > 9999000) ? 9999 : (r.loopMaxUs / 1000);
+
+ char mean[12];
+ if (r.loopMeanUs < 1000) {
+ snprintf(mean, sizeof(mean), "%luus", (unsigned long)r.loopMeanUs);
+ } else {
+ const uint32_t tenthMs =
+ (r.loopMeanUs > 99900) ? 999 : (r.loopMeanUs / 100);
+ snprintf(mean, sizeof(mean), "%lu.%lums", (unsigned long)(tenthMs / 10),
+ (unsigned long)(tenthMs % 10));
+ }
+
+ // Sized well past the 21-column line so the compiler can prove no
+ // truncation: 1 marker + 5 rate + "Hz " + 6 mean + " mx" + 4 max + NUL.
+ char line[32];
+ snprintf(line, sizeof(line), "%s%luHz %s mx%lu",
+ profilingPinLive() ? "" : "!", (unsigned long)rate, mean,
+ (unsigned long)maxMs);
+ // '*' prefix = degraded timebase (see the doc comment); '!' = the
+ // profiling pin was refused, so the scope is dark even though these
+ // numbers are good. Both can apply; the timebase one wins the column
+ // because it is the one that makes the numbers untrustworthy.
+ if (profilingTimebaseTag()[0] != 'D') {
+ display.print(F("*"));
+ }
+ display.println(line);
+
+ // Seven rows of two slots covers all twelve sections plus OTH and SLP
+ // with nothing left over — adding a section means finding it a row.
+ char slot[12];
+ for (uint8_t row = 0; row < 7; row++) {
+ for (uint8_t col = 0; col < 2; col++) {
+ const uint8_t idx = (uint8_t)(row * 2 + col);
+ if (idx >= loop_profile::kReportSlots) break;
+ if (col == 1) display.print(F(" "));
+ const uint16_t pm = r.permille[idx];
+ snprintf(slot, sizeof(slot), "%s %2lu.%lu", loop_profile::sectionTag(idx),
+ (unsigned long)(pm / 10), (unsigned long)(pm % 10));
+ display.print(slot);
+ }
+ display.println();
+ }
+
+ safeDisplayUpdate();
+}
+#endif // BIRDSEYE_ENABLE_PROFILING
+
void displayPage_internal_fault() {
resetDisplay();
display.setCursor(0, 0);
@@ -1304,6 +1458,8 @@ void displayPage_course_track() {
display.println(F("Yes - add course"));
display.print(menuSelectionIndex == 1 ? F("->") : F(" "));
display.println(F("No - new track"));
+ display.print(menuSelectionIndex == 2 ? F("->") : F(" "));
+ display.println(F("Cancel"));
safeDisplayUpdate();
}
@@ -1311,21 +1467,29 @@ void displayPage_course_track() {
void displayPage_course_type() {
resetDisplay();
+ // No blank line under the title: three size-2 rows (16 px each) plus the
+ // header and the hint line fill the panel exactly, and "Cancel" has to
+ // stay on-screen to be worth having.
display.setTextSize(1);
display.println(F(" COURSE TYPE"));
- display.println();
display.setTextSize(2);
display.print(menuSelectionIndex == 0 ? F("->") : F(" "));
display.println(F("Circuit"));
display.print(menuSelectionIndex == 1 ? F("->") : F(" "));
display.println(F("Sprint"));
+ display.print(menuSelectionIndex == 2 ? F("->") : F(" "));
+ display.println(F("Cancel"));
- // The difference that matters when you are about to walk it.
+ // The difference that matters when you are about to walk it. Blank on the
+ // Cancel row — describing a course type the cursor is not on reads as a
+ // description OF Cancel.
display.setTextSize(1);
- display.println();
- display.println(menuSelectionIndex == 0 ? F("one start/finish line")
- : F("start + finish lines"));
+ if (menuSelectionIndex == 0) {
+ display.println(F("one start/finish line"));
+ } else if (menuSelectionIndex == 1) {
+ display.println(F("start + finish lines"));
+ }
safeDisplayUpdate();
}
diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino
index 8c98e2a..08c635a 100644
--- a/BirdsEye/display_ui.ino
+++ b/BirdsEye/display_ui.ino
@@ -432,7 +432,11 @@ void handleMenuPageSelection() {
}
forceDisplayRefresh();
} else if (currentPage == PAGE_TRANSFER_MENU) {
- if (menuSelectionIndex == 0) {
+ if (menuSelectionIndex == 2) {
+ // Back — the only non-rebooting way off this page.
+ debugln(F("Transfer: Back selected"));
+ switchToDisplayPage(PAGE_MAIN_MENU);
+ } else if (menuSelectionIndex == 0) {
// Bluetooth — same flow as before
debugln(F("Transfer: Bluetooth selected"));
// Transfer takes the single radio slot — kick the camera off it first
@@ -468,8 +472,8 @@ void handleMenuPageSelection() {
debugln(F("USB Storage: Exit selected"));
USB_MSC_DISABLE(); // does not return (NVIC_SystemReset)
} else if (currentPage == PAGE_REPLAY_FILE_SELECT) {
- if (numReplayFiles == 0) {
- // No files - go back
+ if (numReplayFiles == 0 || menuSelectionIndex >= numReplayFiles) {
+ // No files, or the trailing Back row — leave the browser.
switchToDisplayPage(PAGE_MAIN_MENU);
} else {
selectedReplayFile = menuSelectionIndex;
@@ -496,9 +500,15 @@ void handleMenuPageSelection() {
switchToDisplayPage(PAGE_MAIN_MENU);
}
} else if (currentPage == PAGE_BLUETOOTH) {
- // Exit button pressed - go back to main menu and disable bluetooth
+ // Exit button pressed — leaving transfer mode reboots the device (same
+ // as the phone-disconnect auto-reboot and the USB exit). On hardware
+ // this handler is normally unreachable anyway: bleActive parks loop()
+ // in its own branch, whose Exit check calls the same function. In the
+ // SIM the stub radio never sets bleActive, so THIS is the live path —
+ // the stub bleExitTransferMode() returns after stopping, and the page
+ // switch below keeps the sim's menu walk (golden fixtures) working.
debugln(F("Bluetooth: Exit selected"));
- BLE_STOP();
+ bleExitTransferMode(); // hardware: does not return (NVIC_SystemReset)
switchToDisplayPage(PAGE_MAIN_MENU);
} else if (currentPage == PAGE_COURSE_PRUNE) {
courseCreatorConfirmPrune(menuSelectionIndex == 1);
@@ -700,6 +710,10 @@ void displayLoop() {
displayPage_stop_logging_confirm();
} else if (currentPage == GPS_DEBUG) {
displayPage_gps_debug();
+#if BIRDSEYE_ENABLE_PROFILING
+ } else if (currentPage == GPS_PROFILE) {
+ displayPage_profile();
+#endif
} else if (currentPage == PAGE_INTERNAL_FAULT) {
displayPage_internal_fault();
} else if (currentPage == PAGE_INTERNAL_WARNING) {
@@ -740,7 +754,7 @@ void displayLoop() {
} else if (currentPage == PAGE_BLUETOOTH) {
menuLimit = 1; // Only "Exit" option
} else if (currentPage == PAGE_TRANSFER_MENU) {
- menuLimit = 2; // Bluetooth, USB
+ menuLimit = 3; // Bluetooth, USB, Back
} else if (currentPage == PAGE_USB_STORAGE) {
menuLimit = 1; // Only "Exit" option
} else if (currentPage == PAGE_PAIR_CAMERA) {
@@ -754,7 +768,7 @@ void displayLoop() {
) {
menuLimit = 2;
} else if (currentPage == PAGE_REPLAY_FILE_SELECT) {
- menuLimit = numReplayFiles > 0 ? numReplayFiles : 1;
+ menuLimit = replayItemCount(); // files + Back
}
}
@@ -776,10 +790,15 @@ void displayLoop() {
// (Pair / Test) render this way — without the camera pages here, their
// first "down" press wrapped straight to the last item (e.g. Unpair /
// Power Off) (#7).
+ //
+ // The transfer menu belongs to this group too and always did — with two
+ // items the direction was unobservable (either button wrapped to the
+ // other row), so it was never noticed. Its third row makes it visible.
bool reverseDirection = (currentPage == PAGE_MAIN_MENU ||
currentPage == PAGE_PAIR_CAMERA ||
currentPage == PAGE_CAMERA_TEST ||
currentPage == PAGE_COURSE_PRUNE ||
+ currentPage == PAGE_TRANSFER_MENU ||
courseCreatorActive());
// BUTTON UP (or DOWN for reversed menus)
@@ -886,8 +905,11 @@ void displayLoop() {
switchToDisplayPage(PAGE_INTERNAL_WARNING);
}
} else {
+ // Cancel returns to the camera page, the same place OK lands.
+ // Dropping to the main menu instead threw the user two levels out
+ // for a mistyped character.
debugln(F("Serial Entry: cancel"));
- switchToDisplayPage(PAGE_MAIN_MENU);
+ switchToDisplayPage(PAGE_PAIR_CAMERA);
}
}
} else if (!buttonsDisabled){
diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino
index 3b0139c..d085c60 100644
--- a/BirdsEye/gps_functions.ino
+++ b/BirdsEye/gps_functions.ino
@@ -540,6 +540,7 @@ void GPS_LOOP() {
dtostrf(accelY, 1, 3, accelYStr);
dtostrf(accelZ, 1, 3, accelZStr);
+#if BIRDSEYE_ENABLE_SENSOREGG
// SensorEgg wireless EGT (Temp1) + cold junction (Junction1) +
// aux intake-air temp (Temp2, v2 eggs), degC. Stale link or
// egg-reported invalid -> literal "nan" so a dropout is a
@@ -551,6 +552,14 @@ void GPS_LOOP() {
// columns previously survived that only because dtostrf(NaN)
// emits "nan" and isNumericString rejects it into the same
// fallback - luck, not design.)
+ //
+ // SENSOREGG BUILDS ONLY since plan 0013. Until then these three
+ // columns were written on every channel, as the literal "nan"
+ // on a stock image, so that the log shape never forked. That
+ // rule is deliberately retired: three dead columns on every row
+ // of every stock log paid for nothing. Readers key the data
+ // section off its own CSV header line, where all three are
+ // optional, so both shapes parse.
char temp1Str[12], junc1Str[12], temp2Str[12];
const float snapEgtC = sensoreggEgtC();
const float snapJuncC = sensoreggJunctionC();
@@ -579,6 +588,7 @@ void GPS_LOOP() {
strcpy(temp2Str, "nan");
}
}
+#endif // BIRDSEYE_ENABLE_SENSOREGG
// dtostrf() can produce garbage (empty, too-long, non-numeric) on
// some BSPs when given NaN/Inf even though the sample passed
@@ -602,11 +612,21 @@ void GPS_LOOP() {
gps_time::u64ToDecimalString(getGpsUnixTimestampMillis(),
timestampStr, sizeof(timestampStr));
+ // The two row shapes. Keep them adjacent: they and the CSV
+ // header below are the only three places the column list
+ // exists, and they have to agree.
+#if BIRDSEYE_ENABLE_SENSOREGG
snprintf(csvLine, sizeof(csvLine), "%s,%d,%s,%s,%s,%s,%s,%s,%s,%d,%s,%s,%s,%s,%s,%s",
timestampStr, snapSats, hdopStr, latStr, lngStr,
speedStr, altStr, headingStr, hAccStr,
tachLastReported, accelXStr, accelYStr, accelZStr,
temp1Str, junc1Str, temp2Str);
+#else
+ snprintf(csvLine, sizeof(csvLine), "%s,%d,%s,%s,%s,%s,%s,%s,%s,%d,%s,%s,%s",
+ timestampStr, snapSats, hdopStr, latStr, lngStr,
+ speedStr, altStr, headingStr, hAccStr,
+ tachLastReported, accelXStr, accelYStr, accelZStr);
+#endif
size_t written = dataFile.println(csvLine);
if (written == 0) {
@@ -690,8 +710,15 @@ void GPS_LOOP() {
dataFile.close();
releaseSDAccess(SD_ACCESS_LOGGING);
} else {
- // Cursor is now at exactly DOVEX_HEADER_SIZE
+ // Cursor is now at exactly DOVEX_HEADER_SIZE.
+ // The trailing Temp1/Junction1/Temp2 columns exist only on a
+ // SensorEgg build (plan 0013) — must match the two row
+ // shapes in the write path above.
+#if BIRDSEYE_ENABLE_SENSOREGG
dataFile.println(F("timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z,Temp1,Junction1,Temp2"));
+#else
+ dataFile.println(F("timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z"));
+#endif
debugln(F("CSV header written"));
sdDataLogInitComplete = true;
}
diff --git a/BirdsEye/led_animations.cpp b/BirdsEye/led_animations.cpp
new file mode 100644
index 0000000..487ef34
--- /dev/null
+++ b/BirdsEye/led_animations.cpp
@@ -0,0 +1,168 @@
+#include "led_animations.h"
+
+namespace led_animations {
+
+using led_frame::Frame;
+using led_frame::kPixelCount;
+using led_frame::Rgb;
+
+uint32_t mix(uint32_t x) {
+ // xorshift-multiply avalanche (fmix32 shape) with a golden-ratio
+ // pre-xor so 0 doesn't map to 0. Cheap, stateless, and spreads
+ // consecutive inputs across the word.
+ x ^= 0x9E3779B9U;
+ x ^= x >> 16;
+ x *= 0x7FEB352DU;
+ x ^= x >> 15;
+ x *= 0x846CA68BU;
+ x ^= x >> 16;
+ return x;
+}
+
+Rgb hueToRgb(uint8_t hue) {
+ // 3-segment wheel: 0..84 r->g, 85..169 g->b, 170..255 b->r.
+ if (hue < 85) {
+ uint8_t const ramp = (uint8_t)(hue * 3);
+ return Rgb{(uint8_t)(255 - ramp), ramp, 0};
+ }
+ if (hue < 170) {
+ uint8_t const ramp = (uint8_t)((hue - 85) * 3);
+ return Rgb{0, (uint8_t)(255 - ramp), ramp};
+ }
+ uint8_t const ramp = (uint8_t)((hue - 170) * 3);
+ return Rgb{ramp, 0, (uint8_t)(255 - ramp)};
+}
+
+// Layer white sparkle glints over the frame for one time slot. chance is
+// per-pixel out of 256; the glint's brightness envelope is a triangle
+// over the slot so pops swell and die instead of blinking.
+static void addSparkles(Frame& f, uint32_t tMs, uint32_t seed,
+ uint8_t chance) {
+ uint32_t const slot = tMs / kSparkleSlotMs;
+ uint32_t const phase = tMs % kSparkleSlotMs;
+ // Triangle envelope 0..255..0 across the slot.
+ uint32_t const half = kSparkleSlotMs / 2;
+ uint32_t const env = phase < half ? (phase * 255) / half
+ : ((kSparkleSlotMs - phase) * 255) / half;
+ for (int i = 0; i < kPixelCount; i++) {
+ uint32_t const h = mix(seed ^ (slot * (uint32_t)kPixelCount + (uint32_t)i));
+ if ((h & 0xFFU) < chance) {
+ // Peak brightness varies per glint (128..255).
+ uint8_t const peak = (uint8_t)(128 + ((h >> 8) & 0x7FU));
+ uint8_t const level = (uint8_t)((env * peak) / 255);
+ f.px[i] = led_frame::add(f.px[i], led_frame::scale(led_frame::kWhite, level));
+ }
+ }
+}
+
+bool renderBoot(uint32_t tMs, uint32_t seed, Frame& out) {
+ led_frame::clear(out);
+ if (tMs >= kBootDurationMs) {
+ return false;
+ }
+
+ // Global fade level: full through the comet phase, ramping to 0 at
+ // kBootFadeEndMs, then only sparkles remain.
+ uint32_t fade = 255;
+ if (tMs >= kBootFadeEndMs) {
+ fade = 0;
+ } else if (tMs >= kBootCometMs) {
+ fade = ((kBootFadeEndMs - tMs) * 255) / (kBootFadeEndMs - kBootCometMs);
+ }
+
+ if (fade > 0) {
+ // Comet head position in 1/256ths of a pixel around the 11-px ring.
+ // While the comet phase runs it advances kBootCometRevs revolutions;
+ // during the fade it keeps drifting at the same rate (looks better
+ // than freezing) — position simply keeps accumulating.
+ uint32_t const headPos256 =
+ (tMs * (uint32_t)(kBootCometRevs * kPixelCount) * 256U) / kBootCometMs;
+ uint32_t const headPx = (headPos256 / 256U) % (uint32_t)kPixelCount;
+ // Hue advances a full wheel per revolution.
+ uint8_t const headHue = (uint8_t)(headPos256 / kPixelCount);
+ for (int back = 0; back < kBootCometTail; back++) {
+ int const px = (int)((headPx + (uint32_t)(kPixelCount - back)) %
+ (uint32_t)kPixelCount);
+ // Quadratic tail falloff: bright head, fast decay.
+ uint32_t const lin = (uint32_t)(kBootCometTail - back) * 255U /
+ (uint32_t)kBootCometTail;
+ uint8_t const level = (uint8_t)((lin * lin) / 255U);
+ Rgb const c = hueToRgb((uint8_t)(headHue + (uint8_t)(back * 12)));
+ out.px[px] = led_frame::add(
+ out.px[px],
+ led_frame::scale(led_frame::scale(c, level), (uint8_t)fade));
+ }
+ }
+
+ if (tMs >= kBootSparkleStartMs) {
+ addSparkles(out, tMs, seed, 40);
+ }
+ return true;
+}
+
+namespace {
+
+// The shared purple core. Both celebrations are the same animation with
+// different timings and a different number of wave passes, so there is
+// exactly one implementation of the wave/hold/fade and one place a
+// visual tweak has to land.
+bool renderPurpleSpec(uint32_t tMs, uint32_t seed, Frame& out,
+ uint32_t durationMs, uint32_t waveMs, int waves,
+ uint32_t fadeStartMs) {
+ led_frame::clear(out);
+ if (tMs >= durationMs) {
+ return false;
+ }
+
+ // Chain-absolute center of the whole 11-px run (strip centerline).
+ const int center = led_frame::kStripFirst + led_frame::kStripCenter;
+ const uint32_t wavePhaseEnd = waveMs * (uint32_t)waves;
+
+ if (tMs < wavePhaseEnd) {
+ // Expanding wave: radius sweeps 0..full-chain over each pass; the
+ // wavefront pixel gets a white kick so the edge reads as motion. A
+ // second pass simply restarts the sweep (tMs % waveMs), which is why
+ // a single-pass spec is bit-identical to the original code.
+ const uint32_t phase = tMs % waveMs;
+ const int maxRadius = kPixelCount - 1 - center + 1; // reaches px 10
+ int const radius = (int)((phase * (uint32_t)(maxRadius + 1)) / waveMs);
+ for (int i = 0; i < kPixelCount; i++) {
+ int const d = i > center ? i - center : center - i;
+ if (d < radius) {
+ out.px[i] = led_frame::kPurple;
+ } else if (d == radius) {
+ out.px[i] = led_frame::add(led_frame::kPurple,
+ led_frame::scale(led_frame::kWhite, 96));
+ }
+ }
+ return true;
+ }
+
+ // Hold solid, then fade.
+ uint32_t level = 255;
+ if (tMs >= fadeStartMs) {
+ level = ((durationMs - tMs) * 255) / (durationMs - fadeStartMs);
+ }
+ for (int i = 0; i < kPixelCount; i++) {
+ out.px[i] = led_frame::scale(led_frame::kPurple, (uint8_t)level);
+ }
+ if (tMs < fadeStartMs) {
+ addSparkles(out, tMs, seed, 48);
+ }
+ return true;
+}
+
+} // namespace
+
+bool renderPurple(uint32_t tMs, uint32_t seed, Frame& out) {
+ return renderPurpleSpec(tMs, seed, out, kPurpleDurationMs, kPurpleWaveMs,
+ 1, kPurpleFadeStartMs);
+}
+
+bool renderPurpleLap(uint32_t tMs, uint32_t seed, Frame& out) {
+ return renderPurpleSpec(tMs, seed, out, kPurpleLapDurationMs,
+ kPurpleLapWaveMs, kPurpleLapWaves,
+ kPurpleLapFadeStartMs);
+}
+
+} // namespace led_animations
diff --git a/BirdsEye/led_animations.h b/BirdsEye/led_animations.h
new file mode 100644
index 0000000..14930a1
--- /dev/null
+++ b/BirdsEye/led_animations.h
@@ -0,0 +1,76 @@
+#pragma once
+
+#include
+
+#include "led_frame.h"
+
+///////////////////////////////////////////
+// LED GLOBAL ANIMATIONS
+// The boot flourish and the purple-sector celebration, rendered as PURE
+// functions of (time since start, seed): no rand(), no millis(), no
+// retained state. Sparkle placement hashes (seed, time slot, pixel), so
+// a given (tMs, seed) pair always produces the identical frame — host
+// tests golden-lock exact output and the glue stays trivially
+// restartable (re-arm = new start stamp + fresh seed).
+//
+// Boot (2600 ms): a hue comet circles the 11 px as a ring (2 revolutions
+// with a trailing fade), the whole thing fades out, and white sparkle
+// glints overlap the tail end.
+// Purple (1600 ms): a purple wave expands from the strip center outward
+// across all 11 px (status LEDs included — it's a celebration), holds
+// solid with sparkles, then fades.
+//
+// Pure logic — no Arduino headers — so it is exercised by host tests.
+///////////////////////////////////////////
+
+namespace led_animations {
+
+constexpr uint32_t kBootDurationMs = 2600;
+// Boot phases: comet circles until kBootCometMs, global fade completes
+// at kBootFadeEndMs, sparkles run kBootSparkleStartMs..end.
+constexpr uint32_t kBootCometMs = 1500;
+constexpr uint32_t kBootFadeEndMs = 2200;
+constexpr uint32_t kBootSparkleStartMs = 900;
+constexpr int kBootCometRevs = 2;
+constexpr int kBootCometTail = 5; // pixels of trailing fade
+
+constexpr uint32_t kPurpleDurationMs = 1600;
+constexpr uint32_t kPurpleWaveMs = 400; // center-out expansion
+constexpr uint32_t kPurpleFadeStartMs = 1200;
+
+// A session-best LAP is the bigger moment, so it gets its own longer
+// version (plan 0013): TWO center-out wave passes and 2600 ms total —
+// the same length as the boot flourish, already proven not to overstay.
+// Two stages, one look: a driver can tell a purple sector from a purple
+// lap without counting pixels.
+constexpr uint32_t kPurpleLapDurationMs = 2600;
+constexpr uint32_t kPurpleLapWaveMs = 400;
+constexpr int kPurpleLapWaves = 2;
+constexpr uint32_t kPurpleLapFadeStartMs = 2100;
+
+// Sparkle time-slot length: each (slot, pixel) pair rolls the hash once,
+// so glints pop and die at a readable rate instead of shimmering at the
+// frame rate.
+constexpr uint32_t kSparkleSlotMs = 80;
+
+// Deterministic integer mixer (xorshift-style avalanche). Exposed for
+// tests; also handy anywhere else that needs cheap stateless hashing.
+uint32_t mix(uint32_t x);
+
+// 0..255 color wheel (r->g->b->r), full saturation.
+led_frame::Rgb hueToRgb(uint8_t hue);
+
+// Render the frame at tMs since the animation started. Returns false
+// once tMs >= duration (frame is cleared) — the caller drops back to
+// normal composition.
+bool renderBoot(uint32_t tMs, uint32_t seed, led_frame::Frame& out);
+bool renderPurple(uint32_t tMs, uint32_t seed, led_frame::Frame& out);
+
+// The session-best-LAP celebration. Same wave/hold/fade core as
+// renderPurple() (they share one static renderer), just longer and with
+// a second wave pass. renderPurple()'s output is deliberately unchanged
+// by this addition — its golden frames still pin the shipped sector
+// animation byte for byte.
+bool renderPurpleLap(uint32_t tMs, uint32_t seed, led_frame::Frame& out);
+
+} // namespace led_animations
diff --git a/BirdsEye/led_frame.cpp b/BirdsEye/led_frame.cpp
new file mode 100644
index 0000000..fda161f
--- /dev/null
+++ b/BirdsEye/led_frame.cpp
@@ -0,0 +1,41 @@
+#include "led_frame.h"
+
+namespace led_frame {
+
+int physicalIndex(int logical) {
+ return kChainReversed ? (kPixelCount - 1 - logical) : logical;
+}
+
+void clear(Frame& f) {
+ for (int i = 0; i < kPixelCount; i++) {
+ f.px[i] = kOff;
+ }
+}
+
+static uint8_t scaleChannel(uint8_t v, uint8_t level) {
+ // Round-to-nearest v*level/255. v<=255 so the result is <= level.
+ return (uint8_t)(((uint16_t)v * level + 127) / 255);
+}
+
+Rgb scale(Rgb c, uint8_t level) {
+ return Rgb{scaleChannel(c.r, level), scaleChannel(c.g, level),
+ scaleChannel(c.b, level)};
+}
+
+void applyCap(Frame& f, uint8_t cap) {
+ for (int i = 0; i < kPixelCount; i++) {
+ f.px[i] = scale(f.px[i], cap);
+ }
+}
+
+static uint8_t addChannel(uint8_t a, uint8_t b) {
+ uint16_t const s = (uint16_t)a + b;
+ return s > 255 ? 255 : (uint8_t)s;
+}
+
+Rgb add(Rgb a, Rgb b) {
+ return Rgb{addChannel(a.r, b.r), addChannel(a.g, b.g),
+ addChannel(a.b, b.b)};
+}
+
+} // namespace led_frame
diff --git a/BirdsEye/led_frame.h b/BirdsEye/led_frame.h
new file mode 100644
index 0000000..4ecd4a0
--- /dev/null
+++ b/BirdsEye/led_frame.h
@@ -0,0 +1,85 @@
+#pragma once
+
+#include
+
+///////////////////////////////////////////
+// LED FRAME MODEL
+// The NeoPixel subsystem's pixel layout and the ONE brightness rule.
+// The physical chain is 11 WS2812 pixels: pixel 0 and pixel 10 are the
+// two status indicators, pixels 1..9 are the strip with a centerline
+// pixel in the middle. Modes and animations author colors in full
+// 0..255 and never think about brightness; applyCap() is the single
+// choke point that enforces the global cap — after it runs, no channel
+// of any pixel exceeds the cap, ever. (Deliberately NOT
+// Adafruit_NeoPixel::setBrightness(), which rewrites the pixel buffer
+// lossily and would spread the invariant across call sites.)
+//
+// Pure logic — no Arduino headers — so it is exercised by host tests.
+///////////////////////////////////////////
+
+namespace led_frame {
+
+// LOGICAL chain layout, always left-to-right as the driver sees it:
+// logical px 0 is the LEFT status LED, 10 the RIGHT one, 1..9 the strip
+// (addressed strip-relative 0..8 by the mode renderers; kStripCenter is
+// strip-relative). Every renderer and animation authors in this space.
+constexpr int kPixelCount = 11;
+constexpr int kStatusLeft = 0; // logical left status LED
+constexpr int kStatusRight = 10; // logical right status LED
+constexpr int kStripFirst = 1; // strip = logical px 1..9
+constexpr int kStripCount = 9;
+constexpr int kStripCenter = 4; // strip-relative centerline (logical px 5)
+
+// The hardware is wired data-in at the PHYSICAL RIGHT end: chain pixel
+// 0 (first on the wire) is the rightmost LED, so the whole chain —
+// status LEDs included — is mirrored relative to logical space.
+// physicalIndex() does that mapping once, at push time; nothing that
+// renders ever thinks about it. Set false if a future build wires
+// data-in on the left.
+constexpr bool kChainReversed = true;
+
+// Logical (left-to-right) index -> position on the physical wire.
+int physicalIndex(int logical);
+
+struct Rgb {
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+};
+
+// One full frame of the chain, chain-ordered (status, strip, status).
+struct Frame {
+ Rgb px[kPixelCount];
+};
+
+constexpr Rgb kOff{0, 0, 0};
+constexpr Rgb kRed{255, 0, 0};
+constexpr Rgb kGreen{0, 255, 0};
+constexpr Rgb kWhite{255, 255, 255};
+// Purple with a blue lean — pure 50/50 red+blue reads pink on WS2812.
+constexpr Rgb kPurple{160, 0, 255};
+constexpr Rgb kBlue{0, 0, 255};
+// Orange needs the green held well down or it reads yellow.
+constexpr Rgb kOrange{255, 64, 0};
+// Yellow sits between kOrange and a true 255/255/0, which on a WS2812
+// reads as a washed-out white rather than yellow. 150 green is the
+// point where it is unmistakably yellow next to kOrange and kGreen.
+constexpr Rgb kYellow{255, 150, 0};
+
+// All pixels off.
+void clear(Frame& f);
+
+// Scale a color by level/255, round-to-nearest integer math. level 255
+// is identity, 0 is off.
+Rgb scale(Rgb c, uint8_t level);
+
+// THE global brightness invariant. Scales every channel of every pixel
+// by cap/255. Post-condition: every channel <= cap (input channels are
+// <= 255, so the proportional scale can never exceed the cap). Called
+// exactly once per frame, at push time.
+void applyCap(Frame& f, uint8_t cap);
+
+// Saturating add, for layering sparkle highlights over a base frame.
+Rgb add(Rgb a, Rgb b);
+
+} // namespace led_frame
diff --git a/BirdsEye/led_modes.cpp b/BirdsEye/led_modes.cpp
new file mode 100644
index 0000000..8bf77ef
--- /dev/null
+++ b/BirdsEye/led_modes.cpp
@@ -0,0 +1,130 @@
+#include "led_modes.h"
+
+#include
+
+namespace led_modes {
+
+using led_frame::kStripCenter;
+using led_frame::kStripCount;
+using led_frame::Rgb;
+
+PacePip pacePip(float paceMsPerM) {
+ float const mag = paceMsPerM < 0 ? -paceMsPerM : paceMsPerM;
+ if (mag <= kPaceDeadbandMsPerM) {
+ return PacePip{kStripCenter, led_frame::kWhite};
+ }
+ // Steps away from center: ceil(mag / stepSize), clamped to the 4
+ // pixels available per side. Ceil (not round) so the pip leaves the
+ // center the moment the deadband is exceeded.
+ const float stepSize = kPaceFullScaleMsPerM / (float)kStripCenter;
+ int steps = (int)(mag / stepSize);
+ if ((float)steps * stepSize < mag) {
+ steps++;
+ }
+ if (steps < 1) {
+ steps = 1;
+ }
+ if (steps > kStripCenter) {
+ steps = kStripCenter;
+ }
+ // Positive pace = slower than best = LEFT of center, red.
+ if (paceMsPerM > 0) {
+ return PacePip{kStripCenter - steps, led_frame::kRed};
+ }
+ return PacePip{kStripCenter + steps, led_frame::kGreen};
+}
+
+void renderPace(float paceMsPerM, Rgb out[kStripCount]) {
+ for (int i = 0; i < kStripCount; i++) {
+ out[i] = led_frame::kOff;
+ }
+ out[kStripCenter] = led_frame::scale(led_frame::kWhite, kPaceCenterLevel);
+ PacePip const pip = pacePip(paceMsPerM);
+ out[pip.stripIndex] = pip.color;
+}
+
+void renderScale(float value, const ScaleSpec& spec, Rgb out[kStripCount]) {
+ float const span = spec.max - spec.min;
+ float frac = span > 0 ? (value - spec.min) / span : 0.0f;
+ if (frac < 0) {
+ frac = 0;
+ }
+ if (frac > 1) {
+ frac = 1;
+ }
+ int const lit = (int)lroundf(frac * (float)kStripCount);
+ // A pixel is "past" the red fraction when its fill position crosses
+ // it: with redFrac 0.5 on 9 px that is indices 5..8 — red past the
+ // halfway mark, the center pixel itself still low-color.
+ int const redFrom = (int)lroundf(spec.redFrac * (float)kStripCount);
+ for (int i = 0; i < kStripCount; i++) {
+ if (i >= lit) {
+ out[i] = led_frame::kOff;
+ } else {
+ out[i] = i >= redFrom ? spec.highColor : spec.lowColor;
+ }
+ }
+}
+
+led_frame::Rgb evalStatus(const StatusAction& a, StatusState& s, float value,
+ bool valid, uint32_t nowMs) {
+ if (a.source == Source::kNone) {
+ s.active = false;
+ return led_frame::kOff;
+ }
+ if (!valid) {
+ // Release the latch (never latch stale data) and show the action's
+ // no-signal color — solid, so it can't be misread as an alert flash.
+ s.active = false;
+ return a.invalidColor;
+ }
+ // A caller can hand us clearBelow ABOVE threshold — overrev_limit and
+ // target_rpm clamp independently, so overrev_limit <= target_rpm *
+ // kRevClearFrac makes the overrev action's release point sit above its
+ // own trip point. Left alone, a value in that inverted band sets the
+ // latch on one frame and clears it on the next: a 15 Hz strobe of the
+ // whole chain instead of the intended 100 ms flash. A release point
+ // above the trip point is never meaningful, so collapse it — the action
+ // degrades to a plain threshold with no hysteresis, which is right.
+ const float clearBelow =
+ a.clearBelow > a.threshold ? a.threshold : a.clearBelow;
+ if (!s.active && value >= a.threshold) {
+ s.active = true;
+ } else if (s.active && value < clearBelow) {
+ s.active = false;
+ }
+ if (!s.active) {
+ return led_frame::kOff;
+ }
+ return flashOn(nowMs, a.flashHalfPeriodMs) ? a.color : led_frame::kOff;
+}
+
+bool flashOn(uint32_t nowMs, uint16_t halfPeriodMs) {
+ if (halfPeriodMs == 0) {
+ return true; // no flash configured: solid
+ }
+ return ((nowMs / halfPeriodMs) & 1U) == 0;
+}
+
+void renderSearchPip(uint32_t tMs, Rgb out[kStripCount]) {
+ for (int i = 0; i < kStripCount; i++) {
+ out[i] = led_frame::kOff;
+ }
+ // Triangle wave over the round trip: 0..8..0 across
+ // kSearchBouncePeriodMs. The sweep spans (kStripCount - 1) steps each
+ // way so both end pixels are reached (and briefly held, since the
+ // integer position dwells one slot at each extreme).
+ uint32_t const phase = tMs % kSearchBouncePeriodMs;
+ uint32_t const halfPeriod = kSearchBouncePeriodMs / 2;
+ uint32_t const span = (uint32_t)(kStripCount - 1);
+ uint32_t pos;
+ if (phase < halfPeriod) {
+ pos = (phase * span + halfPeriod / 2) / halfPeriod;
+ } else {
+ uint32_t const back = phase - halfPeriod;
+ pos = span - (back * span + halfPeriod / 2) / halfPeriod;
+ }
+ out[pos] = led_frame::kGreen;
+}
+
+} // namespace led_modes
diff --git a/BirdsEye/led_modes.h b/BirdsEye/led_modes.h
new file mode 100644
index 0000000..9d05b3d
--- /dev/null
+++ b/BirdsEye/led_modes.h
@@ -0,0 +1,163 @@
+#pragma once
+
+#include
+
+#include "led_frame.h"
+
+///////////////////////////////////////////
+// LED STRIP MODES + STATUS LED ACTIONS
+// The decision math behind what the 9-px strip and the two status LEDs
+// show. Three pieces:
+//
+// - Pace pip: a single pixel that walks left/right of the centerline by
+// the live pace delta (activeTimerPaceDifference(), ms per METER,
+// positive = slower than best). Slower = LEFT of center in red,
+// faster = RIGHT in green, on-pace = dim white centerline only.
+// - Scale: a generic left-fill bar (ScaleSpec) — RPM against the rev
+// limit today, temperatures later. Lit pixels past a fill fraction
+// render the high color ("red past the halfway mark").
+// - Status actions: each status LED is driven by a StatusAction POD —
+// source + threshold + hysteresis + flash rate. This table IS the
+// phase-2 assignability hook: user settings will parse into the same
+// PODs; evalStatus() never changes.
+//
+// Pure logic — no Arduino headers — so it is exercised by host tests.
+///////////////////////////////////////////
+
+namespace led_modes {
+
+// ---- Pace pip ----------------------------------------------------------
+
+// Pace delta (ms/m) that pins the pip to the end pixel. 4 steps per
+// side, so one pixel step = 0.25 ms/m. On a ~1.2 km kart lap 1.0 ms/m
+// is ~1.2 s/lap — a pin-the-needle delta. The OLED pace page treats
+// -1.0 as "notably faster", consistent with this.
+constexpr float kPaceFullScaleMsPerM = 1.0f;
+
+// Half a pixel step: inside this the driver is "on pace" and only the
+// centerline shows.
+constexpr float kPaceDeadbandMsPerM = 0.125f;
+
+// Centerline brightness (pre-cap): visible as a reference mark without
+// competing with the pip.
+constexpr uint8_t kPaceCenterLevel = 64;
+
+struct PacePip {
+ int stripIndex; // 0..8 strip-relative; kStripCenter = on pace
+ led_frame::Rgb color;
+};
+
+// Map a pace delta to pip position + color. Positive (slower) walks LEFT
+// from center in red; negative (faster) walks RIGHT in green; |pace| <=
+// deadband parks on the center in white. Clamps at the end pixels.
+PacePip pacePip(float paceMsPerM);
+
+// Render the full 9-px strip: dim white centerline + the pip.
+void renderPace(float paceMsPerM, led_frame::Rgb out[led_frame::kStripCount]);
+
+// ---- Generic scale (RPM now, temps later) ------------------------------
+
+struct ScaleSpec {
+ float min; // fill starts here (0 lit)
+ float max; // full bar
+ // Lit pixels at/past this FILL FRACTION of the bar render highColor
+ // instead of lowColor.
+ float redFrac;
+ led_frame::Rgb lowColor;
+ led_frame::Rgb highColor;
+};
+
+// User spec for the RPM bar: "starts green, towards a defined revlimit
+// starts turning red past the halfway mark".
+constexpr float kRpmRedFrac = 0.5f;
+
+// The SPEED bar (plan 0013) has NO red band. The RPM bar's red half
+// means "approaching the limiter — back off"; there is no equivalent
+// hazard in approaching your target speed, and painting the same nine
+// pixels red for the thing you were aiming at inverts their meaning.
+// renderScale()'s redFrom becomes kStripCount, which no lit index ever
+// reaches, so the bar is green all the way up. The "you got there"
+// signal is a full bar, plus the `speed` status-LED mode.
+constexpr float kSpeedRedFrac = 1.0f;
+
+// Left-fill: lit count = round(fraction * 9), value clamped into
+// [min, max]. Unlit pixels are off.
+void renderScale(float value, const ScaleSpec& spec,
+ led_frame::Rgb out[led_frame::kStripCount]);
+
+// ---- Status LED actions ------------------------------------------------
+
+// What feeds an action. Phase 2 grows this enum as sources appear; the
+// sketch glue owns mapping Source -> live value + validity.
+enum class Source : uint8_t {
+ kNone = 0,
+ kRpm,
+ kEgtC,
+ kSpeedMph,
+};
+
+// One status LED's assignment. POD on purpose: the phase-2 settings
+// parser fills these from SETTINGS.json; nothing else changes.
+struct StatusAction {
+ Source source;
+ float threshold; // fires at/above
+ float clearBelow; // releases below (hysteresis; must be < threshold)
+ led_frame::Rgb color;
+ uint16_t flashHalfPeriodMs; // half-period of the on/off flash
+ // Shown (solid) while the source is invalid/NaN — kOff for a source
+ // whose absence means nothing (RPM reads 0, never NaN), a color for
+ // one whose absence IS information (temp probe dropout -> blue).
+ led_frame::Rgb invalidColor;
+};
+
+// Per-LED latch state, owned by the caller.
+struct StatusState {
+ bool active = false;
+};
+
+// EGT alert default: ~1200 F, a typical 2T kart EGT ceiling — the
+// compiled-in default behind the `temp1_alert_c` setting. The clear
+// point sits kEgtClearDeltaC below whatever threshold is configured;
+// hysteresis keeps the flasher from chattering on sensor noise.
+constexpr float kEgtAlertC = 650.0f;
+constexpr float kEgtClearDeltaC = 20.0f;
+
+// Rev flasher clears at this fraction of the threshold — deep enough
+// that filter jitter at the limiter doesn't strobe the latch.
+constexpr float kRevClearFrac = 0.97f;
+
+constexpr uint16_t kRevFlashHalfPeriodMs = 100; // urgent
+constexpr uint16_t kEgtFlashHalfPeriodMs = 250; // noticeable, calmer
+
+// Speed target (plan 0013). GPS speed noise is roughly absolute in mph,
+// not proportional, so the release point is a fixed delta below the
+// threshold rather than kRevClearFrac's percentage. Reaching a target
+// is good news, not an alarm, so it flashes at the calmer EGT rate.
+constexpr float kSpeedClearDeltaMph = 2.0f;
+constexpr uint16_t kSpeedFlashHalfPeriodMs = 250;
+
+// Is a flash in its ON phase right now? THE single definition of flash
+// phase — evalStatus() uses it, and so does any mode that flashes
+// without a threshold latch (led_status' camera mode). A half-period of
+// 0 means solid: a rate of zero is a configuration, not a divide-by-zero.
+bool flashOn(uint32_t nowMs, uint16_t halfPeriodMs);
+
+// Evaluate one action. valid=false (stale/NaN input — the glue decides,
+// isNanF-guarded) releases the latch and shows the action's
+// invalidColor (solid): a stale source must never keep an alert
+// flashing (house rule: never latch stale data), but its absence can
+// still be shown. Flash phase derives from nowMs so all timing is
+// host-testable.
+led_frame::Rgb evalStatus(const StatusAction& a, StatusState& s, float value,
+ bool valid, uint32_t nowMs);
+
+// ---- GPS-search pip ------------------------------------------------------
+
+// Full round-trip time of the bouncing pip (end -> end -> back).
+constexpr uint32_t kSearchBouncePeriodMs = 1600;
+
+// One green pixel bouncing 0<->8 as a triangle wave of elapsed time —
+// the race strip's "GPS not locked yet" state. Pure function of tMs.
+void renderSearchPip(uint32_t tMs, led_frame::Rgb out[led_frame::kStripCount]);
+
+} // namespace led_modes
diff --git a/BirdsEye/led_status.cpp b/BirdsEye/led_status.cpp
new file mode 100644
index 0000000..9ff443c
--- /dev/null
+++ b/BirdsEye/led_status.cpp
@@ -0,0 +1,207 @@
+#include "led_status.h"
+
+#include
+
+namespace led_status {
+
+namespace {
+
+// Indexed by Mode. Kept in enum order so modeName() is a lookup and
+// parseMode() is a walk — one table, no chance of the two disagreeing.
+const char* const kModeNames[] = {
+ "off", "rpm", "speed", "gps", "camera", "lap", "sector", "egt",
+};
+static_assert(sizeof(kModeNames) / sizeof(kModeNames[0]) ==
+ static_cast(Mode::kCount),
+ "kModeNames must have one entry per Mode");
+
+// ASCII lower — no locale, no (which would drag a locale table
+// into a firmware image for eight lowercase tokens).
+char lower(char c) {
+ return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
+}
+
+bool equalsIgnoreCase(const char* a, const char* b) {
+ while (*a != '\0' && *b != '\0') {
+ if (lower(*a) != lower(*b)) {
+ return false;
+ }
+ a++;
+ b++;
+ }
+ return *a == '\0' && *b == '\0'; // both ended: no prefix matches
+}
+
+// The two threshold modes and the temp mode share this shape.
+led_frame::Rgb evalThreshold(led_modes::Source source, float threshold,
+ float clearBelow, led_frame::Rgb color,
+ uint16_t halfPeriodMs,
+ led_frame::Rgb invalidColor, float value,
+ bool valid, led_modes::StatusState& latch,
+ uint32_t nowMs) {
+ const led_modes::StatusAction action = {source, threshold,
+ clearBelow, color,
+ halfPeriodMs, invalidColor};
+ return led_modes::evalStatus(action, latch, value, valid, nowMs);
+}
+
+led_frame::Rgb verdictColor(Verdict v) {
+ switch (v) {
+ case Verdict::kBetter:
+ return led_frame::kGreen;
+ case Verdict::kWorse:
+ return led_frame::kRed;
+ case Verdict::kBest:
+ return led_frame::kPurple;
+ case Verdict::kNone:
+ default:
+ // Nothing to compare against yet. Dark is the honest answer — a
+ // colour here would be a guess the driver would act on.
+ return led_frame::kOff;
+ }
+}
+
+} // namespace
+
+bool parseMode(const char* s, Mode* out) {
+ if (s == nullptr || out == nullptr || s[0] == '\0') {
+ return false;
+ }
+ for (size_t i = 0; i < static_cast(Mode::kCount); i++) {
+ if (equalsIgnoreCase(s, kModeNames[i])) {
+ *out = static_cast(i);
+ return true;
+ }
+ }
+ return false;
+}
+
+const char* modeName(Mode m) {
+ const size_t i = static_cast(m);
+ if (i >= static_cast(Mode::kCount)) {
+ return kModeNames[0];
+ }
+ return kModeNames[i];
+}
+
+bool activeOutsideRace(Mode m) {
+ return m == Mode::kGps || m == Mode::kCamera;
+}
+
+void reset(State& s) {
+ s.threshold.active = false;
+}
+
+GpsLevel gpsLevel(int sats, bool fix, bool timeValid) {
+ if (fix) {
+ return timeValid ? GpsLevel::kLocked : GpsLevel::kFix;
+ }
+ return sats > 0 ? GpsLevel::kSats : GpsLevel::kNoSats;
+}
+
+CamLevel cameraLevel(bool paired, bool sessionActive, bool linkUp,
+ bool subscribed, bool recording) {
+ if (!paired) {
+ return CamLevel::kUnpaired;
+ }
+ // Recording is checked before the link ladder: if the camera is
+ // rolling, that is the fact worth showing regardless of whether our
+ // subscription happens to be up at this instant.
+ if (recording) {
+ return CamLevel::kRecording;
+ }
+ if (linkUp) {
+ return subscribed ? CamLevel::kLinkReady : CamLevel::kLinkPartial;
+ }
+ // Paired but no link. Only worth flagging once a session is running —
+ // a parked, powered-off camera is not a problem.
+ return sessionActive ? CamLevel::kIdle : CamLevel::kUnpaired;
+}
+
+led_frame::Rgb evalMode(Mode m, const Inputs& in, State& s, uint32_t nowMs) {
+ switch (m) {
+ case Mode::kRpm:
+ // Byte-for-byte the pre-0013 left status LED.
+ return evalThreshold(led_modes::Source::kRpm, in.targetRpm,
+ in.targetRpm * led_modes::kRevClearFrac,
+ led_frame::kRed, led_modes::kRevFlashHalfPeriodMs,
+ led_frame::kOff, in.rpm, true, s.threshold, nowMs);
+
+ case Mode::kSpeed:
+ // No speed fix -> off (and the latch released). The strip's search
+ // pip already says "no GPS"; a second signal for the same fact is
+ // noise, and invalidColor would make it a solid lit pixel.
+ return evalThreshold(led_modes::Source::kSpeedMph, in.targetSpeedMph,
+ in.targetSpeedMph - led_modes::kSpeedClearDeltaMph,
+ led_frame::kRed,
+ led_modes::kSpeedFlashHalfPeriodMs, led_frame::kOff,
+ in.speedMph, in.speedValid, s.threshold, nowMs);
+
+ case Mode::kEgt:
+ if (!in.eggSupported) {
+ // No egg support compiled in: there is no probe, so there is no
+ // dropout to report. Dark, not blue. (Belt and braces — the glue
+ // also leaves egtValid false — so the host test can prove the
+ // gate independently of the NaN path.)
+ s.threshold.active = false;
+ return led_frame::kOff;
+ }
+ return evalThreshold(led_modes::Source::kEgtC, in.egtAlertC,
+ in.egtAlertC - led_modes::kEgtClearDeltaC,
+ led_frame::kRed, led_modes::kEgtFlashHalfPeriodMs,
+ led_frame::kBlue, in.egtC, in.egtValid, s.threshold,
+ nowMs);
+
+ case Mode::kGps:
+ s.threshold.active = false;
+ switch (gpsLevel(in.gpsSats, in.gpsFix, in.gpsTimeValid)) {
+ case GpsLevel::kNoSats:
+ return led_frame::kRed;
+ case GpsLevel::kSats:
+ return led_frame::kYellow;
+ case GpsLevel::kFix:
+ return led_frame::kBlue;
+ case GpsLevel::kLocked:
+ return led_frame::kGreen;
+ }
+ return led_frame::kOff;
+
+ case Mode::kCamera:
+ s.threshold.active = false;
+ switch (cameraLevel(in.cameraPaired, in.sessionActive, in.cameraLinkUp,
+ in.cameraSubscribed, in.cameraRecording)) {
+ case CamLevel::kUnpaired:
+ return led_frame::kOff;
+ case CamLevel::kIdle:
+ return led_frame::kYellow;
+ case CamLevel::kLinkPartial:
+ // Connected but not subscribed to ce82: our button frames go
+ // nowhere, so we cannot start or stop it. Flashing says "not
+ // ready" without claiming the camera is missing.
+ return led_modes::flashOn(nowMs, kCameraFlashHalfPeriodMs)
+ ? led_frame::kBlue
+ : led_frame::kOff;
+ case CamLevel::kLinkReady:
+ return led_frame::kBlue;
+ case CamLevel::kRecording:
+ return led_frame::kRed;
+ }
+ return led_frame::kOff;
+
+ case Mode::kLap:
+ s.threshold.active = false;
+ return verdictColor(in.lapVerdict);
+
+ case Mode::kSector:
+ s.threshold.active = false;
+ return verdictColor(in.sectorVerdict);
+
+ case Mode::kOff:
+ case Mode::kCount:
+ default:
+ s.threshold.active = false;
+ return led_frame::kOff;
+ }
+}
+
+} // namespace led_status
diff --git a/BirdsEye/led_status.h b/BirdsEye/led_status.h
new file mode 100644
index 0000000..994bed9
--- /dev/null
+++ b/BirdsEye/led_status.h
@@ -0,0 +1,151 @@
+#pragma once
+
+#include
+
+#include "led_frame.h"
+#include "led_modes.h"
+#include "sector_purple.h"
+
+///////////////////////////////////////////
+// ASSIGNABLE STATUS-LED MODES
+// The two status pixels (led_frame::kStatusLeft / kStatusRight) each
+// render whatever the user assigned them from the companion app. This
+// is the "phase 2" led_modes.h has always pointed at: the evaluation
+// machinery does not change, only where the assignment comes from.
+//
+// Before plan 0013 both pixels were hardcoded — left the rev flasher,
+// right the SensorEgg Temp1 tri-state. The right one was the bug: on a
+// build without BIRDSEYE_ENABLE_SENSOREGG the accessor is permanently
+// NaN, so the pixel showed a solid blue "no probe signal" for every
+// second of every race on every stock logger. Hence Inputs.eggSupported
+// below, and test case "egt on an unsupported build is OFF, never blue".
+//
+// THRESHOLD MODES DELEGATE. kRpm / kSpeed / kEgt build a
+// led_modes::StatusAction on the stack and hand it to
+// led_modes::evalStatus() — hysteresis, latch-release-on-invalid and
+// flash phase stay in one place, with the regression tests they already
+// have (including the inverted-hysteresis strobe guard). This unit owns
+// the MODE table; led_modes keeps owning threshold evaluation.
+//
+// Pure logic — no Arduino headers — so it is exercised by host tests.
+// All timing arrives as nowMs; nothing here calls millis().
+///////////////////////////////////////////
+
+namespace led_status {
+
+// What a status LED is assigned to show. Persisted as the short
+// lowercase token in modeName() — these strings are stored in
+// /SETTINGS.json, which has a hard 1023-byte ceiling, so keep them short.
+enum class Mode : uint8_t {
+ kOff = 0, // "off"
+ kRpm, // "rpm" flash at/above target_rpm (the old rev flasher)
+ kSpeed, // "speed" flash at/above target_speed_mph
+ kGps, // "gps" sats / fix / time-lock quality
+ kCamera, // "camera" paired / linked / subscribed / recording
+ kLap, // "lap" last lap vs the lap before it
+ kSector, // "sector" last closed sector vs the same sector last lap
+ kEgt, // "egt" Temp1 tri-state (SensorEgg builds only)
+ kCount,
+};
+
+// STRICT parse — deliberately unlike tach_filter::modeFromSetting(),
+// which degrades every unknown value to a safe default. Here a typo
+// must not silently pick a different mode or dark a status LED, so
+// false means "keep the compiled-in default", the same contract
+// setting_parse::parseIntSetting() has. Case-insensitive exact match;
+// rejects nullptr, "", " ", "rp", "rpmm", "Off ".
+bool parseMode(const char* s, Mode* out);
+
+// Canonical token for a mode. Never null; out-of-range answers "off".
+// modeName(m) always re-parses to m — the setting round-trips through
+// the companion app's SGET/SLIST unchanged, even for a mode this build
+// cannot render.
+const char* modeName(Mode m);
+
+// True for modes that mean something with no session running, so the
+// glue can keep them lit on the main menu. GPS lock and camera
+// readiness are exactly what you want to see from the paddock; a pace
+// or rev indicator on a parked device is just glow.
+bool activeOutsideRace(Mode m);
+
+// "Cannot command the camera" nag: slow enough to read as deliberate,
+// fast enough to distinguish from the steady blue next to it.
+constexpr uint16_t kCameraFlashHalfPeriodMs = 400;
+
+// GPS quality ladder, worst to best.
+//
+// NOTE ON kSats: gpsData.satellites is NAV-PVT's numSV, which counts
+// satellites USED IN THE NAV SOLUTION, not satellites in view. NAV-SAT
+// (the in-view figure) is switched off in race mode, so numSV is all
+// there is. The practical effect is that the yellow band is briefer
+// than "sats but no fix" sounds — numSV climbs off zero as satellites
+// join the solution, shortly before the fix lands.
+enum class GpsLevel : uint8_t { kNoSats = 0, kSats, kFix, kLocked };
+
+// Camera readiness ladder. kLinkPartial is connected-but-not-subscribed
+// to ce82: the camera is there but our button frames go nowhere, so it
+// flashes rather than sitting steady.
+enum class CamLevel : uint8_t {
+ kUnpaired = 0, kIdle, kLinkPartial, kLinkReady, kRecording,
+};
+
+using Verdict = sector_purple::Verdict;
+
+// One frame's snapshot of every source any mode can read. The glue
+// builds this ONCE per frame and evaluates it twice, one per pixel.
+struct Inputs {
+ // -- threshold sources --
+ float rpm = 0.0f; // tachLastReported; 0 when stopped, never NaN
+ float targetRpm = 0.0f; // settingTargetRpm
+ float speedMph = 0.0f; // gps_speed_mph (mph, NOT gpsData.speed)
+ bool speedValid = false; // gpsInitialized && gpsData.fix
+ float targetSpeedMph = 0.0f; // settingTargetSpeedMph
+ float egtC = 0.0f; // sensoreggEgtC()
+ bool egtValid = false; // !isNanF(egtC) — the glue does the isNanF
+ float egtAlertC = 0.0f; // settingTemp1AlertC
+ // THE build gate. False on a BIRDSEYE_ENABLE_SENSOREGG=0 image, where
+ // kEgt renders OFF rather than the solid-blue "no probe signal" — on
+ // such a build there is no probe to lose, so blue would be a lie.
+ bool eggSupported = false;
+
+ // -- gps --
+ int gpsSats = 0;
+ bool gpsFix = false;
+ bool gpsTimeValid = false;
+
+ // -- camera --
+ bool cameraPaired = false;
+ bool sessionActive = false; // raceActive
+ bool cameraLinkUp = false;
+ bool cameraSubscribed = false;
+ bool cameraRecording = false;
+
+ // -- held verdicts from the sector_purple monitor --
+ Verdict lapVerdict = Verdict::kNone;
+ Verdict sectorVerdict = Verdict::kNone;
+};
+
+// Per-LED latch. ONLY the threshold modes latch; gps/camera/lap/sector
+// are pure functions of Inputs (their "hold" lives in the timing
+// monitor, not here). A struct rather than a bare StatusState so a
+// future mode can add state without touching every call site.
+struct State {
+ led_modes::StatusState threshold;
+};
+
+// Release the latch — called when a session ends, so a stale alert
+// can't flash the instant the next one starts.
+void reset(State& s);
+
+// Classify the raw GPS/camera booleans into their ladders. Free
+// functions so the glue stays a straight translation and the ladder
+// rules are host-tested independently of the colour mapping.
+GpsLevel gpsLevel(int sats, bool fix, bool timeValid);
+CamLevel cameraLevel(bool paired, bool sessionActive, bool linkUp,
+ bool subscribed, bool recording);
+
+// Evaluate one status LED. Authors colours in full 0-255;
+// led_frame::applyCap() still owns brightness at push time.
+led_frame::Rgb evalMode(Mode m, const Inputs& in, State& s, uint32_t nowMs);
+
+} // namespace led_status
diff --git a/BirdsEye/local_time.cpp b/BirdsEye/local_time.cpp
new file mode 100644
index 0000000..0b610d6
--- /dev/null
+++ b/BirdsEye/local_time.cpp
@@ -0,0 +1,91 @@
+#include "local_time.h"
+
+#include "gps_time.h" // isLeapYear — one leap rule for the whole firmware
+
+namespace local_time {
+
+bool isValidOffsetMinutes(int32_t offsetMin) {
+ return offsetMin >= -(int32_t)kOffsetMinLimit &&
+ offsetMin <= (int32_t)kOffsetMinLimit;
+}
+
+uint8_t daysInMonth(uint16_t year, uint8_t month) {
+ static const uint8_t kDays[] = {31, 28, 31, 30, 31, 30,
+ 31, 31, 30, 31, 30, 31};
+ if (month < 1 || month > 12) return 0;
+ if (month == 2 && gps_time::isLeapYear(year)) return 29;
+ return kDays[month - 1];
+}
+
+uint16_t minuteOfDay(const DateTime& dt) {
+ return (uint16_t)((uint16_t)dt.hour * 60 + dt.minute);
+}
+
+// Step the calendar one day back/forward, carrying month and year.
+// Split out because applyOffset needs both directions and getting the
+// month-length lookup right at a year boundary is the whole job.
+static void stepDay(DateTime& dt, int8_t delta) {
+ if (delta < 0) {
+ if (dt.day > 1) {
+ dt.day--;
+ return;
+ }
+ if (dt.month > 1) {
+ dt.month--;
+ } else {
+ dt.month = 12;
+ dt.year--;
+ }
+ const uint8_t len = daysInMonth(dt.year, dt.month);
+ dt.day = len ? len : 1;
+ return;
+ }
+ const uint8_t len = daysInMonth(dt.year, dt.month);
+ if (len && dt.day < len) {
+ dt.day++;
+ return;
+ }
+ dt.day = 1;
+ if (dt.month < 12) {
+ dt.month++;
+ } else {
+ dt.month = 1;
+ dt.year++;
+ }
+}
+
+DateTime applyOffset(const DateTime& utc, int16_t offsetMin) {
+ DateTime out = utc;
+ // A corrupt/out-of-band offset must not be able to walk the date.
+ if (!isValidOffsetMinutes(offsetMin)) return out;
+
+ // The band is ±840 min, strictly under a day, so this can only carry
+ // by one — but the loop costs nothing and keeps the function honest
+ // if the band is ever widened.
+ int32_t mins = (int32_t)minuteOfDay(utc) + offsetMin;
+ while (mins < 0) {
+ mins += kMinutesPerDay;
+ stepDay(out, -1);
+ }
+ while (mins >= (int32_t)kMinutesPerDay) {
+ mins -= kMinutesPerDay;
+ stepDay(out, +1);
+ }
+
+ out.hour = (uint8_t)(mins / 60);
+ out.minute = (uint8_t)(mins % 60);
+ return out;
+}
+
+bool isNight(uint16_t minuteOfDay, uint16_t dayStartMin,
+ uint16_t nightStartMin) {
+ if (dayStartMin == nightStartMin) return false; // empty window = disabled
+ if (nightStartMin < dayStartMin) {
+ // Window sits inside one calendar day (an inverted setup, but valid).
+ return minuteOfDay >= nightStartMin && minuteOfDay < dayStartMin;
+ }
+ // The ordinary case: the window wraps midnight.
+ return minuteOfDay >= nightStartMin || minuteOfDay < dayStartMin;
+}
+
+} // namespace local_time
diff --git a/BirdsEye/local_time.h b/BirdsEye/local_time.h
new file mode 100644
index 0000000..26ca962
--- /dev/null
+++ b/BirdsEye/local_time.h
@@ -0,0 +1,75 @@
+#pragma once
+
+///////////////////////////////////////////
+// LOCAL TIME — a fixed UTC offset, nothing more
+//
+// The GPS delivers UTC and that is what the device LOGS: DOVEX row
+// timestamps are Unix epoch milliseconds (UTC by definition), the
+// header datetime and every generated filename stay UTC, and the
+// webapp does its own presentation-side conversion. NOTHING in this
+// unit may be wired into the logging pipeline (plan 0010).
+//
+// What it exists for is the handful of decisions that must happen at a
+// LOCAL wall-clock time — today exactly one: the NeoPixel day/night
+// brightness swap, where "7am" has to mean the driver's 7am, not
+// 7am in Greenwich.
+//
+// DELIBERATELY NO DST (plan 0010). A bare offset means the boundary
+// walks an hour twice a year; for a "dim the strip after dark" gate
+// that is noise, and the alternative (US/EU rule tables, or worse
+// tzdata) buys precision nobody asked for. If a future feature needs
+// true civil time this is where the rules go — the DateTime carries a
+// 4-digit year precisely so that stays possible.
+///////////////////////////////////////////
+
+#include
+
+namespace local_time {
+
+// UTC offsets in the real world run from -12:00 (Baker Island) to
+// +14:00 (Kiribati). Minutes, not hours: India is +330, Newfoundland
+// -210, Chatham Islands +765.
+constexpr int16_t kOffsetMinLimit = 840; // ±14 h
+
+constexpr uint16_t kMinutesPerDay = 1440;
+
+// A wall-clock instant with a FOUR-DIGIT year. The sketch's global
+// gpsData.year is 2-digit for compatibility with the existing filename
+// and header formats — callers add 2000 on the way in. Nothing here
+// may assume the 2000s.
+struct DateTime {
+ uint16_t year; // 4-digit, e.g. 2026
+ uint8_t month; // 1-12
+ uint8_t day; // 1-31
+ uint8_t hour; // 0-23
+ uint8_t minute; // 0-59
+};
+
+// True if `offsetMin` is inside ±kOffsetMinLimit. Settings clamp with
+// this rather than an inline literal so the bound has one home.
+bool isValidOffsetMinutes(int32_t offsetMin);
+
+// Length of `month` (1-12) in `year`. Returns 0 for an out-of-range
+// month so a garbled date can't index off the end of the table.
+uint8_t daysInMonth(uint16_t year, uint8_t month);
+
+// `utc` shifted by `offsetMin`, rolling the date correctly in both
+// directions across month, year and leap-day boundaries. An offset
+// outside the valid band is treated as 0 — a corrupt setting must not
+// be able to move the calendar.
+DateTime applyOffset(const DateTime& utc, int16_t offsetMin);
+
+// Minutes elapsed since local midnight, 0-1439.
+uint16_t minuteOfDay(const DateTime& dt);
+
+// Is `minuteOfDay` inside the night window [nightStartMin, dayStartMin)?
+//
+// The window is defined modulo the day, so the ordinary case
+// (night 19:00 -> day 07:00) wraps midnight and needs no special
+// casing at the call site. Equal bounds mean the window is EMPTY —
+// never night — which is how a user turns the whole day/night swap off
+// without a separate enable flag.
+bool isNight(uint16_t minuteOfDay, uint16_t dayStartMin,
+ uint16_t nightStartMin);
+
+} // namespace local_time
diff --git a/BirdsEye/loop_profile.cpp b/BirdsEye/loop_profile.cpp
new file mode 100644
index 0000000..4353e4a
--- /dev/null
+++ b/BirdsEye/loop_profile.cpp
@@ -0,0 +1,122 @@
+#include "loop_profile.h"
+
+namespace loop_profile {
+namespace {
+
+// Saturating add. See the header: a pegged counter is an honest "we
+// stopped being able to count"; a wrapped one reads as idle.
+inline uint32_t addSat(uint32_t a, uint32_t b) {
+ return (a > UINT32_MAX - b) ? UINT32_MAX : (uint32_t)(a + b);
+}
+
+const char* const kTags[kSectionCount] = {"GPS", "TCH", "ACC", "BLE",
+ "EGG", "TRK", "LAP", "IDL",
+ "CAM", "LED", "BTN", "DSP"};
+
+} // namespace
+
+const char* sectionTag(uint8_t section) {
+ if (section == kOther) return "OTH";
+ if (section == kSleep) return "SLP";
+ if (section >= kSectionCount) return "???";
+ return kTags[section];
+}
+
+uint16_t permilleOf(uint32_t part, uint32_t whole) {
+ if (whole == 0) return 0;
+ const uint64_t p = ((uint64_t)part * 1000ULL) / (uint64_t)whole;
+ return (p > 1000ULL) ? (uint16_t)1000 : (uint16_t)p;
+}
+
+void reset(State& s, uint32_t nowTicks, uint32_t nowMs) {
+ for (uint8_t i = 0; i < kSectionCount; i++) {
+ s.sec[i].totalTicks = 0;
+ s.sec[i].maxTicks = 0;
+ s.sec[i].calls = 0;
+ }
+ s.loops = 0;
+ s.loopTotalTicks = 0;
+ s.loopMaxTicks = 0;
+ s.windowStartTicks = nowTicks;
+ s.windowStartMs = nowMs;
+}
+
+void addSection(State& s, uint8_t section, uint32_t elapsedTicks) {
+ if (section >= kSectionCount) return;
+ SectionStat& st = s.sec[section];
+ st.totalTicks = addSat(st.totalTicks, elapsedTicks);
+ st.calls = addSat(st.calls, 1);
+ if (elapsedTicks > st.maxTicks) st.maxTicks = elapsedTicks;
+}
+
+void addLoop(State& s, uint32_t elapsedTicks) {
+ s.loopTotalTicks = addSat(s.loopTotalTicks, elapsedTicks);
+ s.loops = addSat(s.loops, 1);
+ if (elapsedTicks > s.loopMaxTicks) s.loopMaxTicks = elapsedTicks;
+}
+
+bool rollup(State& s, uint32_t nowTicks, uint32_t nowMs, uint32_t windowMs,
+ uint32_t ticksPerUs, Report& out) {
+ // WALL clock closes the window. Using the duration clock here was the
+ // original bug: the DWT cycle counter stops when the core halts, so a
+ // "one second" window was one second of CPU-awake time and every rate
+ // and share downstream was inflated by the sleep factor.
+ const uint32_t elapsedMs = nowMs - s.windowStartMs; // wrap-safe
+ if (elapsedMs < windowMs) return false;
+ if (ticksPerUs == 0) ticksPerUs = 1; // mis-probed timebase: keep ratios
+
+ out.valid = true;
+ // Cap before the *1000: a window longer than ~71 minutes would
+ // overflow, and a bogus huge window must not divide by a wrapped
+ // denominator. Nothing legitimate comes near it.
+ out.windowUs =
+ (elapsedMs > 4000000u) ? 4000000000u : (uint32_t)(elapsedMs * 1000u);
+ out.awakeUs = ticksSince(s.windowStartTicks, nowTicks) / ticksPerUs;
+ out.saturated = (s.loopTotalTicks == UINT32_MAX);
+
+ // Shares are of WALL time, which is what a scope on the profiling pin
+ // measures too. Totals are divided down to microseconds first: the
+ // sub-microsecond precision that ticks buy matters per CALL, not on an
+ // accumulated total, and whole microseconds keep every denominator
+ // inside 32 bits.
+ uint32_t sectionSumUs = 0;
+ for (uint8_t i = 0; i < kSectionCount; i++) {
+ const SectionStat& st = s.sec[i];
+ const uint32_t us = st.totalTicks / ticksPerUs;
+ out.permille[i] = permilleOf(us, out.windowUs);
+ out.maxUs[i] = st.maxTicks / ticksPerUs;
+ sectionSumUs = addSat(sectionSumUs, us);
+ if (st.totalTicks == UINT32_MAX) out.saturated = true;
+ }
+
+ const uint32_t loopUs = s.loopTotalTicks / ticksPerUs;
+ out.busyPermille = permilleOf(loopUs, out.windowUs);
+
+ // Loop time that no section bracketed: the glue between the calls
+ // (session-end checks, idle timers, the shutdown combos). Clamped at
+ // zero because the whole-iteration span and the section spans come
+ // from separate reads and can disagree by a tick.
+ const uint32_t otherUs = (loopUs > sectionSumUs) ? (loopUs - sectionSumUs) : 0;
+ out.permille[kOther] = permilleOf(otherUs, out.windowUs);
+
+ // Everything else in the window: the core's loop dispatch, other
+ // FreeRTOS tasks, and any time the CPU spent asleep. Derived from
+ // busy rather than from a tick subtraction precisely BECAUSE the tick
+ // counter is the thing that may have stopped.
+ out.permille[kSleep] =
+ (out.busyPermille >= 1000) ? 0 : (uint16_t)(1000 - out.busyPermille);
+
+ out.loops = s.loops;
+ out.loopMaxUs = s.loopMaxTicks / ticksPerUs;
+ out.loopMeanUs =
+ (s.loops > 0) ? ((s.loopTotalTicks / s.loops) / ticksPerUs) : 0;
+ // Iterations per WALL second.
+ out.loopRateHz =
+ (uint32_t)(((uint64_t)s.loops * 1000ULL + (uint64_t)(elapsedMs / 2)) /
+ (uint64_t)elapsedMs);
+
+ reset(s, nowTicks, nowMs);
+ return true;
+}
+
+} // namespace loop_profile
diff --git a/BirdsEye/loop_profile.h b/BirdsEye/loop_profile.h
new file mode 100644
index 0000000..66e783d
--- /dev/null
+++ b/BirdsEye/loop_profile.h
@@ -0,0 +1,160 @@
+#pragma once
+
+#include
+
+///////////////////////////////////////////
+// MAIN-LOOP CPU PROFILE (pure logic, host-tested)
+//
+// Accounting for where one main-loop iteration's time goes. The glue
+// (profiling.ino) brackets each subsystem call in loop() and hands the
+// elapsed span here; every window (1 s) this rolls the accumulators up
+// into a Report the debug page renders and the profiling pin is timed
+// against.
+//
+// WHY THIS EXISTS: the firmware is a superloop with no idle task, so
+// "CPU usage" is not a duty cycle — the CPU is busy 100% of the time by
+// construction. The number that actually answers "how much CPU are we
+// using" is therefore the SHAPE of an iteration: how long it takes, how
+// that time divides between subsystems, and how bad the worst iteration
+// is. That is what feeds the nRF52840-vs-nRF5340 decision (plan 0011).
+//
+// Design notes:
+// - TWO CLOCKS, and mixing them up was the first real bug in this unit.
+// Durations are measured in TICKS from whatever counter profiling.ino
+// probed (the DWT cycle counter at 64 ticks/us, or micros()), because
+// a sub-microsecond section must not be quantised to 0 before it is
+// summed. But the WINDOW is measured in milliseconds off millis(),
+// and every share is computed against that wall time.
+//
+// The DWT counter counts CPU CYCLES, not time: if the core ever halts
+// (WFE/WFI in the FreeRTOS idle task, sd_app_evt_wait) it stops while
+// the world keeps going. Using it to close the window meant the
+// "one second" window was really one second of CPU-awake time, so 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 first
+// hardware run reported a rate pinned at the display clamp; this is
+// why. (The micros() fallback never had the bug — it is a real clock.)
+//
+// The upside of fixing it properly: wall time minus loop() execution
+// time is now a MEASURED number (kSleep), so the question "does this
+// firmware have any CPU headroom at all" gets an answer instead of an
+// assumption.
+// - Shares are permille (tenths of a percent), integer math throughout.
+// A float here would be fine on this M4F, but the exactness of the
+// "everything adds to 1000" property is worth more than the syntax.
+// - Accumulators SATURATE rather than wrap. At 64 MHz a uint32 of ticks
+// is only ~67 s, and a rollup can be arbitrarily late if the loop
+// stalls (SD garbage collection can block 100 ms-2 s). A saturated
+// window reads as "pegged", which is true; a wrapped one reads as
+// near-zero, which is a lie.
+// - The window's unaccounted remainder is reported (kOther) instead of
+// being hidden. It is the honesty check on the instrumentation: if
+// OTH is large, time is going somewhere loop() is not bracketing.
+// - Every reported slot is a share of the same wall-clock window, so
+// the sections plus kOther plus kSleep sum to ~1000 permille (integer
+// truncation loses a few). That is checkable on the display page at a
+// glance, and it is the property that makes a wrong reading obvious.
+///////////////////////////////////////////
+
+namespace loop_profile {
+
+// The instrumented spans of one loop() iteration, in call order. Adding
+// one means adding its tag to sectionTag() and bracketing it in loop().
+enum Section : uint8_t {
+ kGps = 0, // GPS_LOOP: UBX drain, PVT callbacks, DOVEX row write
+ kTach, // TACH_LOOP: ring drain + Kalman
+ kAccel, // ACCEL_LOOP: LSM6DS3 I2C reads
+ kBle, // BLUETOOTH_LOOP: deferred SD commands, transfer streaming
+ kEgg, // SENSOREGG_LOOP: scan buffer drain + parse
+ kTrack, // trackDetectionLoop: haversine manifest scan / JSON parse
+ kLap, // checkForNewLapData
+ kIdle, // checkAutoIdle + autoRaceModeCheck + updateGpsLockHold
+ kCamera, // CAMERA_LOOP: Insta360 FSM step + GPS overlay stream
+ kLed, // NEOPIXEL_LOOP: compose + cap + show
+ kButtons, // readButtons + updateButtonHoldState (multi-sample
+ // debounce; near-free unless a button is actually held)
+ kDisplay, // displayLoop: page render + I2C framebuffer push, plus
+ // the boot-page state machines (gpsStatusPageLoop,
+ // sdFormatPageLoop, courseCreatorLoop) — same family of
+ // work, and folding them freed the grid slot kSleep needed
+ kSectionCount
+};
+
+// Derived report slots. Neither is a Section — nothing accumulates into
+// them; rollup() computes both.
+// kOther: loop() time no section bracketed.
+// kSleep: wall time not inside loop() at all — scheduler dispatch,
+// other FreeRTOS tasks, and CPU sleep. THE headroom number.
+constexpr uint8_t kOther = kSectionCount;
+constexpr uint8_t kSleep = kSectionCount + 1;
+constexpr uint8_t kReportSlots = kSectionCount + 2;
+
+// Three-character tag for the debug page's two-column grid. Returns
+// "???" out of range so a mismatched enum shows up on screen instead
+// of reading past the table.
+const char* sectionTag(uint8_t section);
+
+struct SectionStat {
+ uint32_t totalTicks; // saturating
+ uint32_t maxTicks; // worst single call this window
+ uint32_t calls; // saturating
+};
+
+// Live accumulators. Zero-initialised is a valid empty window; the glue
+// still calls reset() at setup so both window anchors are set.
+struct State {
+ SectionStat sec[kSectionCount];
+ uint32_t loops; // completed iterations this window
+ uint32_t loopTotalTicks; // sum of whole-iteration spans
+ uint32_t loopMaxTicks; // worst iteration this window
+ uint32_t windowStartTicks; // duration clock (may stop when asleep)
+ uint32_t windowStartMs; // wall clock — this is what closes a window
+};
+
+// One window's rolled-up answer. Every permille is a share of the
+// WALL-CLOCK window, so the sections plus kOther plus kSleep sum to
+// ~1000 (integer truncation loses a few).
+struct Report {
+ bool valid; // false until the first rollup lands
+ uint16_t permille[kReportSlots]; // per section, plus kOther and kSleep
+ uint32_t maxUs[kSectionCount]; // worst single call, microseconds
+ uint16_t busyPermille; // wall time spent executing loop()
+ uint32_t loops; // iterations in the window
+ uint32_t loopRateHz; // rounded iterations per WALL second
+ uint32_t loopMeanUs; // mean iteration execution time
+ uint32_t loopMaxUs; // worst iteration
+ uint32_t windowUs; // wall time the window covered
+ uint32_t awakeUs; // duration-clock time in the same
+ // window; well under windowUs means
+ // the CPU was asleep (or the tick
+ // counter stopped) for the balance
+ bool saturated; // an accumulator pegged (see header)
+};
+
+// Wrap-safe tick difference. uint32 subtraction is already modular; this
+// names the intent (and is where a narrower timebase would be handled).
+inline uint32_t ticksSince(uint32_t from, uint32_t to) { return to - from; }
+
+// part/whole as tenths of a percent, saturated at 1000 and safe on a
+// zero whole.
+uint16_t permilleOf(uint32_t part, uint32_t whole);
+
+// Clear the accumulators and anchor a fresh window at both clocks.
+void reset(State& s, uint32_t nowTicks, uint32_t nowMs);
+
+// Fold one bracketed section span in.
+void addSection(State& s, uint8_t section, uint32_t elapsedTicks);
+
+// Fold one whole-iteration span in.
+void addLoop(State& s, uint32_t elapsedTicks);
+
+// If windowMs of WALL time has passed, fill `out`, restart the window
+// and return true; otherwise leave `out` untouched and return false.
+// The window is closed on nowMs specifically so a stopped duration clock
+// (a sleeping CPU) cannot stretch it — see the header. ticksPerUs
+// converts the tick totals to microseconds (0 is treated as 1 so a
+// mis-probed timebase still reports ratios).
+bool rollup(State& s, uint32_t nowTicks, uint32_t nowMs, uint32_t windowMs,
+ uint32_t ticksPerUs, Report& out);
+
+} // namespace loop_profile
diff --git a/BirdsEye/neopixel.h b/BirdsEye/neopixel.h
new file mode 100644
index 0000000..14512b7
--- /dev/null
+++ b/BirdsEye/neopixel.h
@@ -0,0 +1,106 @@
+#pragma once
+
+#include
+
+// Arduino's auto-generated prototypes land near the top of the
+// concatenated TU, and this module's internal npxPushFrame() takes a
+// led_frame::Frame& — the type must therefore be visible from
+// BirdsEye.ino's include block (which pulls this header), or the
+// generated prototype fails to parse. Pure stdint header, safe
+// everywhere. (See CLAUDE.md "Development Conventions" on the
+// auto-prototype include-order trap.)
+//
+// led_status.h is here for the same reason: settingLedStatusLeft/Right
+// below are typed led_status::Mode, so the type has to be visible before
+// Arduino generates prototypes for anything that touches them.
+#include "led_frame.h"
+#include "led_status.h"
+
+///////////////////////////////////////////
+// NEOPIXEL STRIP MODULE (plan 0006)
+// 11 WS2812 pixels on the NFC pads (converted to GPIO): pixel 0 and
+// pixel 10 are status indicators, pixels 1..9 are the strip with a
+// centerline. A 5 V boost converter feeds the strip; its EN pin is
+// driven so the rail is truly off in sleep (GPIO state is retained in
+// System OFF — the "blue LED stays on" precedent).
+//
+// All decision math lives in the host-tested pure units (led_frame,
+// led_modes, led_animations, sector_purple); this module is glue: it
+// snapshots inputs each frame, composes by priority (boot animation >
+// purple animation > parked/menu off > race mode + status actions),
+// applies the ONE global brightness cap, and pushes the frame.
+//
+// Compiled out entirely unless BIRDSEYE_ENABLE_NEOPIXEL (project.h,
+// beta channel) — a flag-off build never writes UICR and never drives
+// these pins. First flag-on boot programs UICR->NFCPINS (one-way) and
+// self-resets once; see NEOPIXEL_SETUP() and plan 0006.
+//
+// Adafruit_NeoPixel's nRF52 show() claims a free PWM instance
+// (EasyDMA, interrupts ON, ~0.4 ms for 11 px) — this sketch uses no
+// tone()/analogWrite(), so PWM0-2 are always free. If all PWMs were
+// ever occupied the library falls back to a bit-bang WITH INTERRUPTS
+// OFF — never let a future PWM user create that path. show() also
+// mallocs/frees a ~560 B pattern buffer per call: same-size alloc/free
+// is fragmentation-benign here, it is NOT a leak.
+///////////////////////////////////////////
+
+// Pins (Arduino numbering; the Seeed XIAO variant maps the NFC pads:
+// 30 = P0.09 / NFC1, 31 = P0.10 / NFC2). The split between the two is
+// arbitrary — swap these to match the actual wiring.
+#define NEOPIXEL_PIN_BOOST_EN 30 // boost EN: HIGH = 5 V rail on, LOW = off
+#define NEOPIXEL_PIN_DATA 31 // WS2812 data in (GRB, 800 kHz)
+
+// BOOST EN IS NOT ALWAYS OURS. A profiling build
+// (BIRDSEYE_ENABLE_PROFILING, plan 0011 — the beta channel) claims pin 30
+// as its scope output, so this module stops driving EN entirely and the
+// regulator runs at its hardware default (pulled up = rail on). The
+// strip still works — only the ability to switch the rail is gone,
+// including the driven-LOW that holds it down through System OFF. See
+// the "BOOST EN OWNERSHIP" block in neopixel.ino and profiling.h.
+
+// Runtime settings, read at boot in BirdsEye.ino's settings block.
+extern uint8_t settingLedBrightness; // global cap 0-255; 0 = LEDs disabled
+extern int settingTargetRpm; // true RPM: scale ceiling + rpm flasher
+// The bar's ceiling on a session with no tachometer, and the `speed`
+// status mode's threshold. mph — the companion app converts for display.
+extern int settingTargetSpeedMph;
+// What each status pixel shows (plan 0013). Both default to what the
+// pre-0013 hardcoded pair did on a SensorEgg build.
+extern led_status::Mode settingLedStatusLeft;
+extern led_status::Mode settingLedStatusRight;
+// Day/night swap (plan 0010). settingUtcOffsetMin is device-wide rather
+// than LED-specific — it is extern'd here because the LED strip is its
+// only consumer today; move it if a second one appears. The two hours
+// are LOCAL wall clock; equal hours disable the swap.
+extern int16_t settingUtcOffsetMin;
+extern uint8_t settingLedBrightnessNight;
+extern uint8_t settingLedDayStartHour;
+extern uint8_t settingLedNightStartHour;
+
+// One-time UICR NFC->GPIO ensure (may self-reset ONCE on the first
+// flag-on boot — must run before the SoftDevice is enabled and before
+// wdtSetup()), then boost rail up, strip init, boot animation armed.
+void NEOPIXEL_SETUP();
+
+// Self-throttled ~30 Hz frame: gather -> compose -> cap -> show. Called
+// from the main loop AND from the BLE/USB parked branches (there it
+// renders all-off — a frozen mid-pattern strip looks crashed).
+void NEOPIXEL_LOOP();
+
+// Shutdown hook (enterShutdown, next to the IMU rail-off): blank the
+// strip while 5 V is still up, then data LOW, then boost EN LOW.
+// Unconditional — driven-LOW is retained through System OFF.
+void NEOPIXEL_SLEEP();
+
+// Charging-loop soft resume: boost EN back up, strip re-initialized.
+void NEOPIXEL_WAKE();
+
+// Fire (or restart) the purple-sector celebration. The module also
+// self-detects via sector_purple; this is the external hook.
+void neopixelNotifyPurpleSector();
+
+// Fire the session-best-LAP celebration — the longer, two-wave version
+// (plan 0013). A purple lap OUTRANKS a purple sector: they land on the
+// same frame whenever the last sector of a purple lap is also purple,
+// and re-arming the shorter sector animation there would stomp it.
+void neopixelNotifyPurpleLap();
diff --git a/BirdsEye/neopixel.ino b/BirdsEye/neopixel.ino
new file mode 100644
index 0000000..69d24cf
--- /dev/null
+++ b/BirdsEye/neopixel.ino
@@ -0,0 +1,559 @@
+#include "neopixel.h"
+#include "project.h"
+
+#if BIRDSEYE_ENABLE_NEOPIXEL
+
+#include
+
+#include "led_animations.h"
+#include "led_frame.h"
+#include "led_modes.h"
+#include "led_status.h"
+#include "local_time.h"
+#include "nan_bits.h"
+#include "camera_ble.h"
+#include "sector_purple.h"
+#include "sensoregg.h"
+#include "tachometer.h"
+
+// 30 Hz: smooth for a moving pip and cheap (~0.4 ms show() ≈ 1% CPU).
+// The display's 3 Hz gate is a different clock — never share it.
+static const uint32_t NPX_FRAME_INTERVAL_MS = 33;
+
+// Boost converter settle before first data after EN rises.
+static const uint32_t NPX_BOOST_SETTLE_MS = 5;
+
+static Adafruit_NeoPixel npxStrip(led_frame::kPixelCount, NEOPIXEL_PIN_DATA,
+ NEO_GRB + NEO_KHZ800);
+
+///////////////////////////////////////////
+// BOOST EN OWNERSHIP
+//
+// In a normal build this module owns NEOPIXEL_PIN_BOOST_EN outright:
+// HIGH raises the 5 V rail, LOW drops it, and the LOW is what actually
+// keeps it down through System OFF (GPIO levels are retained there —
+// the "blue conn LED stays on after sleep" precedent).
+//
+// A PROFILING build (BIRDSEYE_ENABLE_PROFILING, plan 0011) takes that
+// pin away for its scope output, so all three helpers become no-ops and
+// the regulator is left at its hardware default (EN pulled up = rail
+// on). That is a real, accepted cost, spelled out in project.h: the
+// rail can no longer be switched off, including at shutdown. The pin
+// cannot serve both purposes, and profiling builds are bench builds.
+//
+// Everything else in this file is unchanged by the flag — the strip
+// still renders, because its DATA pin is untouched and the rail it
+// needs is up.
+///////////////////////////////////////////
+#if BIRDSEYE_ENABLE_PROFILING
+static inline void npxBoostPinInit() {}
+static inline void npxBoostEnable() {}
+static inline void npxBoostDisable() {}
+#else
+static inline void npxBoostPinInit() {
+ pinMode(NEOPIXEL_PIN_BOOST_EN, OUTPUT);
+ digitalWrite(NEOPIXEL_PIN_BOOST_EN, LOW);
+}
+static inline void npxBoostEnable() {
+ digitalWrite(NEOPIXEL_PIN_BOOST_EN, HIGH);
+}
+static inline void npxBoostDisable() {
+ pinMode(NEOPIXEL_PIN_BOOST_EN, OUTPUT);
+ digitalWrite(NEOPIXEL_PIN_BOOST_EN, LOW);
+}
+#endif
+
+static bool npxReady = false; // strip powered + begun; LOOP is live
+static uint32_t npxLastFrameMs = 0;
+
+// Animation clocks: active while *Active, rendered from (now - start).
+static bool npxBootAnimActive = false;
+static uint32_t npxBootAnimStartMs = 0;
+static uint32_t npxBootAnimSeed = 0;
+static bool npxPurpleActive = false;
+static bool npxPurpleIsLap = false; // which of the two animations is running
+static uint32_t npxPurpleStartMs = 0;
+static uint32_t npxPurpleSeed = 0;
+
+// Lap/sector close-edge monitor + per-status-LED state.
+static sector_purple::State npxPurpleMon;
+static led_status::State npxLeftState;
+static led_status::State npxRightState;
+static led_modes::StatusState npxOverrevState;
+
+// The last verdict each indicator mode reports, HELD until the next
+// close edge — that hold is the whole point: the driver looks down some
+// seconds after the line, not at the instant of it. Cleared when the
+// session ends so the next one starts blank.
+static sector_purple::Verdict npxLapVerdict = sector_purple::Verdict::kNone;
+static sector_purple::Verdict npxSectorVerdict = sector_purple::Verdict::kNone;
+
+// Overrev (plan 0007): past this the engine is BROKEN, not just at its
+// ceiling — the whole 11-px chain flashes red. Latch clears down at the
+// normal rev limit's clear point so a spike leaves a visible flash.
+// threshold/clearBelow filled from settings at NEOPIXEL_SETUP();
+// disabled (source kNone) while overrev_limit is 0.
+static led_modes::StatusAction npxOverrevAction = {
+ led_modes::Source::kNone, 0.0f, 0.0f, led_frame::kRed,
+ led_modes::kRevFlashHalfPeriodMs, led_frame::kOff};
+
+/**
+ * @brief One-time NFC->GPIO conversion. UICR->NFCPINS bit 0 set means
+ * the pads still belong to NFCT; program it to 0 (a 1->0-only word
+ * write, no erase needed) and reset — NFCPINS latches only at reset.
+ * ONE-WAY: undoing this needs a full chip erase (bootloader reflash).
+ * Direct NVMC access, so this MUST run before the SoftDevice is
+ * enabled (NEOPIXEL_SETUP is called before SENSOREGG_SETUP's
+ * bleCoreEnsureInit) and it runs before wdtSetup() arms the watchdog,
+ * so the reset can't race it. Every boot after the first skips this.
+ */
+static void npxEnsureNfcPinsAreGpio() {
+ if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == 0) {
+ return; // already GPIO
+ }
+ NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
+ while (!NRF_NVMC->READY) {}
+ NRF_UICR->NFCPINS = 0xFFFFFFFE; // clear PROTECT, leave the rest erased
+ while (!NRF_NVMC->READY) {}
+ NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+ while (!NRF_NVMC->READY) {}
+ debugln(F("NFC pads converted to GPIO — one-time reset"));
+ delay(10); // let the debug byte drain
+ NVIC_SystemReset();
+}
+
+void NEOPIXEL_SETUP() {
+ npxEnsureNfcPinsAreGpio();
+
+ // Hold both pins in a defined LOW state first — EN low keeps the 5 V
+ // rail off, data low can't back-power an unpowered strip. (On a
+ // profiling build the EN half is a no-op; see the block above.)
+ npxBoostPinInit();
+ pinMode(NEOPIXEL_PIN_DATA, OUTPUT);
+ digitalWrite(NEOPIXEL_PIN_DATA, LOW);
+
+ if (settingLedBrightness == 0) {
+ debugln(F("NeoPixel: brightness 0 — LEDs disabled, boost held off"));
+ return;
+ }
+
+ // Overrev: fire at the problem limit, release only once back under
+ // the TARGET RPM's clear point — the whole band stays latched.
+ // (The per-status-LED thresholds are no longer built here: each
+ // assignable mode derives its own StatusAction from the settings
+ // every frame in led_status::evalMode. This one stays because it
+ // drives the whole chain, not a status pixel.)
+ if (settingOverrevLimit > 0) {
+ npxOverrevAction.source = led_modes::Source::kRpm;
+ npxOverrevAction.threshold = (float)settingOverrevLimit;
+ npxOverrevAction.clearBelow =
+ (float)settingTargetRpm * led_modes::kRevClearFrac;
+ }
+
+ npxBoostEnable();
+ delay(NPX_BOOST_SETTLE_MS);
+ npxStrip.begin();
+ npxStrip.clear();
+ npxStrip.show();
+ npxReady = true;
+
+ sector_purple::reset(npxPurpleMon);
+ npxBootAnimActive = true;
+ npxBootAnimStartMs = millis();
+ npxBootAnimSeed = micros(); // house entropy rule: never analogRead()
+}
+
+void neopixelNotifyPurpleSector() {
+ if (!npxReady) {
+ return;
+ }
+ // A lap celebration already running outranks a sector one — the last
+ // sector of a purple lap is very often purple too, and re-arming the
+ // shorter animation here would cut the bigger moment short.
+ if (npxPurpleActive && npxPurpleIsLap) {
+ return;
+ }
+ npxPurpleActive = true;
+ npxPurpleIsLap = false;
+ npxPurpleStartMs = millis();
+ npxPurpleSeed = micros();
+}
+
+void neopixelNotifyPurpleLap() {
+ if (!npxReady) {
+ return;
+ }
+ npxPurpleActive = true;
+ npxPurpleIsLap = true; // takes over an in-flight sector animation
+ npxPurpleStartMs = millis();
+ npxPurpleSeed = micros();
+}
+
+/**
+ * @brief The cap in force right now: the day value, or the night one
+ * once the LOCAL wall clock is inside the night window (plan 0010).
+ *
+ * Local time is UTC plus a fixed offset — no DST, no tzdata. Deliberate:
+ * this gate exists so the strip stops blinding the driver after dark,
+ * and an hour of seasonal drift on that boundary is beneath its
+ * resolution. Nothing that gets LOGGED goes through here.
+ *
+ * Two rules that matter:
+ * - No clock, no swap. gpsData.timeValid is the same gate log-file
+ * creation waits on and can be ~12.5 min out from a cold start, so
+ * until it lands we render at DAY brightness. Guessing night would
+ * mean a strip that comes up dark and looks broken.
+ * - A night cap of 0 blanks the frame; it does NOT cut the 5 V rail.
+ * Only settingLedBrightness 0 does that (NEOPIXEL_SETUP/WAKE), and
+ * it must stay that way — power-cycling the boost converter at 19:00
+ * mid-session is not a brightness change.
+ */
+static uint8_t npxEffectiveBrightness() {
+ if (settingLedBrightness == 0) return 0; // master disable
+ if (!gpsData.timeValid) return settingLedBrightness;
+
+ const local_time::DateTime utc = {
+ (uint16_t)(2000 + gpsData.year), // GpsData carries a 2-digit year
+ gpsData.month, gpsData.day, gpsData.hour, gpsData.minute};
+ const local_time::DateTime lt =
+ local_time::applyOffset(utc, settingUtcOffsetMin);
+ const bool night =
+ local_time::isNight(local_time::minuteOfDay(lt),
+ (uint16_t)settingLedDayStartHour * 60,
+ (uint16_t)settingLedNightStartHour * 60);
+ return night ? settingLedBrightnessNight : settingLedBrightness;
+}
+
+/**
+ * @brief Push a composed frame: apply THE brightness cap (the single
+ * choke point — after this no channel exceeds the cap in force), map
+ * logical left-to-right onto the physical wire (the chain is wired
+ * data-in on the RIGHT — led_frame::kChainReversed), and show.
+ */
+static void npxPushFrame(led_frame::Frame& frame) {
+ led_frame::applyCap(frame, npxEffectiveBrightness());
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ int const phys = led_frame::physicalIndex(i);
+ npxStrip.setPixelColor((uint16_t)phys, frame.px[i].r, frame.px[i].g,
+ frame.px[i].b);
+ }
+ npxStrip.show();
+}
+
+/**
+ * @brief Snapshot every source the status modes can read, once per
+ * frame. Built once and evaluated twice (one call per pixel) so the two
+ * LEDs can never disagree about the same instant.
+ *
+ * All of these are trivial flag/staleness reads at 30 Hz. Deliberately
+ * NOT gated on "does either LED actually want this" — that optimisation
+ * costs more in branches than it saves, and loop_profile can say if it
+ * ever stops being true.
+ *
+ * cameraConsumeAutoStop() must NEVER appear here: it clears a latch the
+ * main sketch owns, and a renderer that consumes state is a renderer
+ * that steals a session end.
+ */
+static void npxFillStatusInputs(led_status::Inputs& in) {
+ in.rpm = (float)tachLastReported;
+ in.targetRpm = (float)settingTargetRpm;
+ in.speedMph = gps_speed_mph;
+ in.speedValid = gpsInitialized && gpsData.fix;
+ in.targetSpeedMph = (float)settingTargetSpeedMph;
+
+#if BIRDSEYE_ENABLE_SENSOREGG
+ // isNanF, never isnan: -Ofast folds isnan() to false (nan_bits.h).
+ const float egtC = sensoreggEgtC();
+ in.egtC = egtC;
+ in.egtValid = !isNanF(egtC);
+ in.egtAlertC = (float)settingTemp1AlertC;
+ in.eggSupported = true;
+#else
+ // The accessor is not even called on a stock build. Before plan 0013
+ // it was, unconditionally, and its permanent NaN drove the right
+ // status pixel to a solid blue "no probe signal" for the whole of
+ // every race on every shipped logger. With eggSupported false the
+ // `egt` mode renders dark instead — there is no probe here to lose.
+ in.eggSupported = false;
+#endif
+
+ in.gpsSats = gpsData.satellites;
+ in.gpsFix = gpsData.fix;
+ in.gpsTimeValid = gpsData.timeValid;
+
+ in.cameraPaired = cameraIsPaired();
+ in.sessionActive = raceActive;
+ in.cameraLinkUp = cameraRemoteLinkUp();
+ in.cameraSubscribed = cameraCe82Subscribed();
+ // Our own belief OR the camera's own report: the FSM knows it asked
+ // for a recording, the 0x10 display-string observation knows one is
+ // actually running. Either is reason enough to show red.
+ in.cameraRecording = cameraActivelyRecording() || cameraObservedRecording();
+
+ in.lapVerdict = npxLapVerdict;
+ in.sectorVerdict = npxSectorVerdict;
+}
+
+void NEOPIXEL_LOOP() {
+ if (!npxReady) {
+ return;
+ }
+ const uint32_t now = millis();
+ if (now - npxLastFrameMs < NPX_FRAME_INTERVAL_MS) {
+ return;
+ }
+ npxLastFrameMs = now;
+
+ // Lap/sector close-edge monitor: poll-and-diff against the active
+ // timer. One state machine feeds both the purple celebrations and the
+ // held verdicts the lap/sector status modes render.
+ {
+ sector_purple::Sample smp;
+ smp.sectorsConfigured = activeTimerSectorsConfigured();
+ smp.raceStarted = activeTimerRaceStarted();
+ smp.currentSector = activeTimerCurrentSector();
+ smp.laps = activeTimerLaps();
+ smp.lastLapTime = activeTimerLastLapTime();
+ smp.bestLapTime = activeTimerBestLapTime();
+ for (int s = 0; s < 3; s++) {
+ smp.lapSectorTime[s] = activeTimerLapSectorTime(s + 1);
+ smp.bestSectorTime[s] = activeTimerBestSectorTime(s + 1);
+ }
+ const sector_purple::Event ev = sector_purple::update(npxPurpleMon, smp);
+ if (ev.closedSector != 0) {
+ npxSectorVerdict = ev.sectorVerdict;
+ }
+ if (ev.lapClosed) {
+ npxLapVerdict = ev.lapVerdict;
+ }
+ // Lap first: notifyPurpleLap() takes over an in-flight sector
+ // animation, and notifyPurpleSector() declines to stomp a lap one,
+ // so this order is what makes the bigger event win when both fire.
+ if (ev.purpleLap) {
+ neopixelNotifyPurpleLap();
+ }
+ if (ev.purpleSector != 0) {
+ neopixelNotifyPurpleSector();
+ }
+ }
+
+ led_frame::Frame frame;
+ bool rendered = false;
+
+ // Priority 1: boot animation.
+ if (npxBootAnimActive) {
+ rendered = led_animations::renderBoot(now - npxBootAnimStartMs,
+ npxBootAnimSeed, frame);
+ npxBootAnimActive = rendered;
+ }
+ // Priority 2: overrev — the engine is BROKEN. Whole chain flashes
+ // red; outranks the purple celebration, evaluated only in race.
+ if (raceActive) {
+ const led_frame::Rgb overrevColor =
+ led_modes::evalStatus(npxOverrevAction, npxOverrevState,
+ (float)tachLastReported, true, now);
+ if (!rendered && npxOverrevState.active) {
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ frame.px[i] = overrevColor; // flash off-phase = whole chain dark
+ }
+ rendered = true;
+ }
+ } else {
+ npxOverrevState.active = false;
+ }
+ // Priority 3: purple celebration — the longer two-wave version for a
+ // session-best LAP, the original for a session-best SECTOR.
+ if (!rendered && npxPurpleActive) {
+ const uint32_t tMs = now - npxPurpleStartMs;
+ rendered = npxPurpleIsLap
+ ? led_animations::renderPurpleLap(tMs, npxPurpleSeed, frame)
+ : led_animations::renderPurple(tMs, npxPurpleSeed, frame);
+ npxPurpleActive = rendered;
+ }
+ // Priority 4/5: parked -> off; otherwise the bar in race, and the two
+ // assignable status LEDs.
+ if (!rendered) {
+ led_frame::clear(frame);
+ const bool parked = bleActive || usbMscActive;
+ if (!parked && raceActive) {
+ // Strip selection (plan 0007 order, plus the 0013 speed arm):
+ // engine died (proven tach session, RPM 0) -> bar OFF — a pace
+ // pip counting next to a dead engine reads as a glitch;
+ // no GPS lock -> green search pip (same fix+timeValid gate that
+ // allows log-file creation);
+ // pace valid -> pace pip;
+ // tach session -> RPM scale;
+ // else -> SPEED scale.
+ led_frame::Rgb stripPx[led_frame::kStripCount];
+ bool stripOff = raceEngineStopped();
+ if (!stripOff) {
+ if (!gpsData.fix || !gpsData.timeValid) {
+ led_modes::renderSearchPip(now, stripPx);
+ } else {
+ const bool paceValid =
+ activeTimerRaceStarted() && activeTimerLaps() >= 1 &&
+ !(sprintModeIsActive() && !activeTimerRunActive());
+ if (paceValid) {
+ led_modes::renderPace(activeTimerPaceDifference(), stripPx);
+ } else if (raceEntryCause == RACE_ENTRY_TACH) {
+ const led_modes::ScaleSpec rpmSpec = {
+ 0.0f, (float)settingTargetRpm, led_modes::kRpmRedFrac,
+ led_frame::kGreen, led_frame::kRed};
+ led_modes::renderScale((float)tachLastReported, rpmSpec, stripPx);
+ } else {
+ // No tachometer has proven itself this session, so an RPM
+ // scale would be nine dark pixels until the first lap lands
+ // (plan 0013). Scale against the target speed instead —
+ // RACE_ENTRY_TACH is the right gate because MANUAL and SPEED
+ // sessions promote to it the moment the engine clears
+ // 500 rpm (idle_policy::tachProven), so this is exactly "has
+ // never seen an engine" and it never flickers per-frame the
+ // way a live `tachLastReported > 0` test would.
+ //
+ // gps_speed_mph, NOT gpsData.speed — that one is knots. The
+ // search-pip branch above already caught !fix, so the speed
+ // is live by the time we get here.
+ const led_modes::ScaleSpec spdSpec = {
+ 0.0f, (float)settingTargetSpeedMph, led_modes::kSpeedRedFrac,
+ led_frame::kGreen, led_frame::kRed};
+ led_modes::renderScale(gps_speed_mph, spdSpec, stripPx);
+ }
+ }
+ for (int i = 0; i < led_frame::kStripCount; i++) {
+ frame.px[led_frame::kStripFirst + i] = stripPx[i];
+ }
+ }
+ }
+
+ // The two status LEDs. In a session both render whatever the user
+ // assigned; parked on the menu only the modes that mean something
+ // with no session running stay lit (GPS lock and camera readiness —
+ // exactly what you want to see from the paddock). Status LEDs stay
+ // live even when the bar is off: a hot engine cooling after a stall
+ // is precisely when a temp alert matters.
+ if (!parked) {
+ led_status::Inputs in;
+ npxFillStatusInputs(in);
+ const bool showLeft =
+ raceActive || led_status::activeOutsideRace(settingLedStatusLeft);
+ const bool showRight =
+ raceActive || led_status::activeOutsideRace(settingLedStatusRight);
+ // A pixel we are not rendering gets its latch released, not just a
+ // dark colour: leaving one set would let a threshold that tripped
+ // in the last seconds of a session flash the instant the next one
+ // starts, before its own source has said anything.
+ if (showLeft) {
+ frame.px[led_frame::kStatusLeft] =
+ led_status::evalMode(settingLedStatusLeft, in, npxLeftState, now);
+ } else {
+ led_status::reset(npxLeftState);
+ }
+ if (showRight) {
+ frame.px[led_frame::kStatusRight] =
+ led_status::evalMode(settingLedStatusRight, in, npxRightState, now);
+ } else {
+ led_status::reset(npxRightState);
+ }
+ } else {
+ led_status::reset(npxLeftState);
+ led_status::reset(npxRightState);
+ }
+
+ if (!raceActive) {
+ // Out of race: drop the held verdicts so the next session starts
+ // blank rather than showing the last one's last lap.
+ npxLapVerdict = sector_purple::Verdict::kNone;
+ npxSectorVerdict = sector_purple::Verdict::kNone;
+ }
+ }
+
+ npxPushFrame(frame);
+}
+
+void NEOPIXEL_SLEEP() {
+ // Unconditional, npxReady or not: driven-LOW GPIO is retained through
+ // System OFF, so this is what actually turns the 5 V rail off for the
+ // night (the "blue LED stays on after sleep" precedent).
+ if (npxReady) {
+ npxStrip.clear();
+ npxStrip.show();
+ delay(1); // let the last frame latch before the rail drops
+ npxReady = false;
+ }
+ pinMode(NEOPIXEL_PIN_DATA, OUTPUT);
+ digitalWrite(NEOPIXEL_PIN_DATA, LOW);
+ npxBoostDisable();
+}
+
+void NEOPIXEL_WAKE() {
+ if (settingLedBrightness == 0) {
+ return; // disabled: rail stays off
+ }
+ npxBoostEnable();
+ delay(NPX_BOOST_SETTLE_MS);
+ npxStrip.begin();
+ npxStrip.clear();
+ npxStrip.show();
+ npxReady = true;
+}
+
+#else // !BIRDSEYE_ENABLE_NEOPIXEL
+
+///////////////////////////////////////////
+// SUBSYSTEM COMPILED OUT
+//
+// Since 4.1.0 no shipped channel takes this branch — the flag defaults
+// to 1 (see project.h). It is reached only by a build that forces
+// -DBIRDSEYE_ENABLE_NEOPIXEL=0, which is why the already-converted case
+// below matters more than it looks: the boards most likely to run such a
+// build are ones that already ran a flag-on one.
+//
+// No UICR write, no Adafruit_NeoPixel dependency in the image. On a
+// board that has never run a flag-on build the pads stay exactly as the
+// chip shipped, and this file drives nothing at all.
+//
+// ONE exception, and it is a power bug if you remove it: a board that
+// HAS run a flag-on build carries the one-way UICR NFC->GPIO conversion
+// forever, and a later flag-off image (a beta unit updating to a prod
+// release) inherits it. With stubs that truly do nothing, P0.09 — the
+// boost converter's EN — is left in its reset state (input, disconnected)
+// for the whole session AND through System OFF, where a driven-LOW level
+// is the only thing that holds the rail down (the same retention that
+// caused the "blue conn LED stays on after sleep" report, subsystem 10).
+// EN floating on the Adafruit boost module reads as enabled, so "off"
+// keeps the 5 V rail and 11 idle WS2812s alive on a device with no power
+// switch — a flat pack in a day or two.
+//
+// So: drive EN low, but ONLY when the conversion has already happened.
+// PROTECT clear (0) means the pads are already GPIO. On an unconverted
+// board the bit is set, this is skipped, and the promise above holds
+// exactly — we never touch a pad the user didn't opt into.
+///////////////////////////////////////////
+
+static void npxHoldConvertedBoostOff() {
+#if BIRDSEYE_ENABLE_PROFILING
+ // The profiler owns this pin (plan 0011) — leave it alone entirely,
+ // including the retained-LOW trick below. A build with the strip
+ // compiled out AND profiling on drives no LEDs, so the rail it leaves
+ // up is powering nothing but the boost converter's own quiescent
+ // draw; the pin's scope signal is worth more than that.
+ return;
+#else
+ if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) != 0) {
+ return; // pads still NFC — never touched by a flag-off build
+ }
+ pinMode(NEOPIXEL_PIN_BOOST_EN, OUTPUT);
+ digitalWrite(NEOPIXEL_PIN_BOOST_EN, LOW);
+#endif
+}
+
+void NEOPIXEL_SETUP() { npxHoldConvertedBoostOff(); }
+void NEOPIXEL_LOOP() {}
+// Re-assert before System OFF: the level is retained there, and that is
+// the case that costs a battery rather than a few mA of run current.
+void NEOPIXEL_SLEEP() { npxHoldConvertedBoostOff(); }
+void NEOPIXEL_WAKE() {}
+void neopixelNotifyPurpleSector() {}
+void neopixelNotifyPurpleLap() {}
+
+#endif // BIRDSEYE_ENABLE_NEOPIXEL
diff --git a/BirdsEye/profiling.h b/BirdsEye/profiling.h
new file mode 100644
index 0000000..c27b8ca
--- /dev/null
+++ b/BirdsEye/profiling.h
@@ -0,0 +1,210 @@
+#pragma once
+
+#include
+
+#include "loop_profile.h"
+#include "project.h"
+
+///////////////////////////////////////////
+// LOOP PROFILING (plan 0011) — BETA CHANNEL ONLY
+//
+// Answers one question: where does a main-loop iteration's time go, and
+// how much of it is left? That is the input to the nRF52840-vs-nRF5340
+// decision for the commercial board, and to the "what would porting off
+// the Arduino core buy us" question — both need a baseline measured on
+// the hardware that exists, not an estimate.
+//
+// Compiled out entirely unless BIRDSEYE_ENABLE_PROFILING (project.h).
+// The beta workflow passes it; master and release never do.
+//
+// TWO INSTRUMENTS, ONE SUBSYSTEM:
+//
+// 1. THE PIN. Pin 30 — the NeoPixel boost converter's EN line in a
+// normal build — is driven HIGH for the span being profiled and LOW
+// outside it. A scope or logic analyser then reads the loop period
+// off the rising edges and the span's cost off the high time, with
+// no software in the measurement path. PROFILING_PIN_SECTION picks
+// the span: the whole loop body by default, or any single section
+// (-DPROFILING_PIN_SECTION=PROF_SEC_GPS and friends, below).
+//
+// THE PIN COSTS THE 5 V RAIL. A profiling build never drives EN —
+// not at setup, not at sleep — so the boost converter sits at its
+// hardware default (EN pulled up = rail ON) and the firmware can no
+// longer switch it. Consequences, both deliberate:
+// - The strip stays powered through System OFF, so a profiling
+// unit left asleep on a battery drains it. Bench tool; do not
+// ship one to a driver.
+// - If pin 30 is still physically wired to EN on the rig, the
+// toggling chops the rail at loop rate and the LEDs will
+// misbehave. Pull that jumper (or tie EN high) before profiling
+// — the whole premise here is that the regulator is on by
+// default and only *use*, not *testing*, needs firmware control
+// of it.
+//
+// 2. THE ROLLUP. Every section is also timed in software and rolled up
+// once a second into a loop_profile::Report, rendered on the
+// LOOP PROFILE race page (first page of the rotation on a profiling
+// build). That is the instrument that says which subsystem owns the
+// iteration; the pin is the one that says the software is telling
+// the truth.
+//
+// TIMEBASE: the DWT cycle counter (64 ticks/us at 64 MHz) when it will
+// run, micros() when it will not — most sections are well under a
+// microsecond, so micros() alone would quantise half of them to zero.
+// The page shows which one is live; trust sub-microsecond figures only
+// under DWT.
+//
+// DWT IS A DURATION CLOCK, NOT A WALL CLOCK, and the difference is not
+// academic: it counts CPU cycles, so it stops dead whenever the core
+// halts (WFE/WFI in the FreeRTOS idle task, sd_app_evt_wait). It times
+// code that is definitely executing; it cannot tell you how much time
+// passed. The rollup window is therefore closed on millis() and every
+// share is computed against that wall time — see loop_profile.h for the
+// bug this cost us the first time. The leftover, wall time not spent
+// inside loop(), is reported as SLP and is the real headroom number.
+//
+// OVERHEAD — READ THIS BEFORE TRUSTING A SMALL SECTION. A bracket is
+// two counter reads plus a saturating add: order 30 cycles, ~0.5 us,
+// so ~6-8 us per iteration across all 13 sections. That was written off
+// as "under 0.1%" against the ~4 ms iteration this project's docs had
+// long assumed. The first hardware run measured a mean iteration UNDER
+// 100 us, which puts the instrument's own cost at order 10% of what it
+// reports — not negligible, and it depresses the loop rate it measures.
+//
+// It is still deliberately inside the numbers (a bracket lands in the
+// section it brackets, never in OTH), because for a subsystem's SHARE
+// that is the honest accounting: what you read is what the instrumented
+// firmware costs. But two things follow at this loop rate:
+// - a section reading under ~1% is at the noise floor of its own
+// bracket; do not rank those against each other;
+// - the un-instrumented loop is somewhat faster than the rate shown.
+// If the absolute rate ever needs to be exact, measure it with the pin
+// on a build whose section brackets are compiled out (the loop scope
+// alone is two reads) rather than trying to subtract an estimate.
+///////////////////////////////////////////
+
+// Sentinel for PROFILING_PIN_SECTION: mark the whole loop body rather
+// than one section. Chosen so it can never collide with a Section id.
+#define PROFILING_PIN_WHOLE_LOOP 255
+
+// Preprocessor mirror of loop_profile::Section. It has to be macros, not
+// the enum: PROFILING_PIN_SECTION is tested with #if (so the pin edges
+// compile out of the hot path when the pin is marking the whole loop),
+// and the preprocessor cannot evaluate a scoped C++ name. The
+// static_asserts below make the mirror impossible to get wrong.
+#define PROF_SEC_GPS 0
+#define PROF_SEC_TACH 1
+#define PROF_SEC_ACCEL 2
+#define PROF_SEC_BLE 3
+#define PROF_SEC_EGG 4
+#define PROF_SEC_TRACK 5
+#define PROF_SEC_LAP 6
+#define PROF_SEC_IDLE 7
+#define PROF_SEC_CAMERA 8
+#define PROF_SEC_LED 9
+#define PROF_SEC_BUTTONS 10
+#define PROF_SEC_DISPLAY 11
+
+static_assert(PROF_SEC_GPS == loop_profile::kGps, "section mirror drift");
+static_assert(PROF_SEC_TACH == loop_profile::kTach, "section mirror drift");
+static_assert(PROF_SEC_ACCEL == loop_profile::kAccel, "section mirror drift");
+static_assert(PROF_SEC_BLE == loop_profile::kBle, "section mirror drift");
+static_assert(PROF_SEC_EGG == loop_profile::kEgg, "section mirror drift");
+static_assert(PROF_SEC_TRACK == loop_profile::kTrack, "section mirror drift");
+static_assert(PROF_SEC_LAP == loop_profile::kLap, "section mirror drift");
+static_assert(PROF_SEC_IDLE == loop_profile::kIdle, "section mirror drift");
+static_assert(PROF_SEC_CAMERA == loop_profile::kCamera, "section mirror drift");
+static_assert(PROF_SEC_LED == loop_profile::kLed, "section mirror drift");
+static_assert(PROF_SEC_BUTTONS == loop_profile::kButtons,
+ "section mirror drift");
+static_assert(PROF_SEC_DISPLAY == loop_profile::kDisplay,
+ "section mirror drift");
+static_assert(PROFILING_PIN_WHOLE_LOOP > loop_profile::kSectionCount,
+ "the whole-loop sentinel must not collide with a section id");
+
+// Which span the pin marks. Default is the whole iteration: rising-edge
+// period = loop period, and the (tiny) low time is the Arduino core's
+// loop dispatch. Re-point it at a single subsystem with e.g.
+// -DPROFILING_PIN_SECTION=PROF_SEC_GPS.
+#ifndef PROFILING_PIN_SECTION
+ #define PROFILING_PIN_SECTION PROFILING_PIN_WHOLE_LOOP
+#endif
+
+// The pin itself. Defaults to the boost EN pad (see the warning above);
+// override if the rig has it wired elsewhere. NFC pads 30/31 only work
+// as GPIO once the one-way UICR conversion has happened — that is
+// NEOPIXEL_SETUP's job, so on a board that has never run a NeoPixel
+// build the pin is refused at setup and only the rollup runs.
+#ifndef PROFILING_PIN
+ #define PROFILING_PIN 30
+#endif
+
+// Rollup window. One second matches the GPS frame-rate window and is
+// slow enough that the page is readable while driving.
+#ifndef PROFILING_WINDOW_MS
+ #define PROFILING_WINDOW_MS 1000UL
+#endif
+
+#if BIRDSEYE_ENABLE_PROFILING
+
+// Probe the timebase, claim the pin (if the pads are GPIO), anchor the
+// first window. Call AFTER NEOPIXEL_SETUP() — that is what converts the
+// NFC pads and it may self-reset the chip on the way.
+void PROFILING_SETUP();
+
+// Park the pin LOW. Called from enterShutdown() before System OFF: the
+// loop scope guard's destructor never runs on that path, so without
+// this the pin would be latched HIGH for the whole power-down.
+void PROFILING_SLEEP();
+
+// Section brackets. Not re-entrant per section (each keeps one start
+// stamp), which is all loop() needs — nothing here nests.
+void profSectionBegin(uint8_t section);
+void profSectionEnd(uint8_t section);
+
+// Last completed window. `.valid` is false until the first one lands.
+const loop_profile::Report& profilingReport();
+
+// "DWT" or "us" — which timebase produced that report.
+const char* profilingTimebaseTag();
+
+// False when the pin was refused (pads still NFC): the rollup is live
+// but the scope will see nothing.
+bool profilingPinLive();
+
+// Whole-iteration timing + the once-a-second rollup, as a scope guard so
+// every early return out of loop() (BLE parked, USB parked, shutdown)
+// is still counted. Declared at the top of loop() via the macro below.
+class ProfLoopScope {
+ public:
+ ProfLoopScope();
+ ~ProfLoopScope();
+
+ private:
+ uint32_t startTicks_;
+};
+
+#define PROFILE_LOOP_SCOPE() ProfLoopScope profLoopScope_
+#define PROFILE_SECTION(sec, ...) \
+ do { \
+ profSectionBegin(sec); \
+ __VA_ARGS__; \
+ profSectionEnd(sec); \
+ } while (0)
+
+#else // !BIRDSEYE_ENABLE_PROFILING
+
+// Real functions rather than empty macros so the call sites in setup()
+// and enterShutdown() read the same on every channel (the neopixel
+// precedent). The section brackets DO vanish — they are in the hottest
+// path in the firmware and must cost literally nothing when off.
+void PROFILING_SETUP();
+void PROFILING_SLEEP();
+
+#define PROFILE_LOOP_SCOPE() do { } while (0)
+#define PROFILE_SECTION(sec, ...) \
+ do { \
+ __VA_ARGS__; \
+ } while (0)
+
+#endif // BIRDSEYE_ENABLE_PROFILING
diff --git a/BirdsEye/profiling.ino b/BirdsEye/profiling.ino
new file mode 100644
index 0000000..9e1d2ff
--- /dev/null
+++ b/BirdsEye/profiling.ino
@@ -0,0 +1,199 @@
+#include "profiling.h"
+
+#include "project.h"
+
+#if BIRDSEYE_ENABLE_PROFILING
+
+#include "loop_profile.h"
+
+// The nRF52840 core clock. Arduino's platform.txt defines F_CPU; the
+// fallback keeps this file honest if that ever stops being true.
+#ifndef F_CPU
+ #define F_CPU 64000000UL
+#endif
+
+static loop_profile::State profState;
+static loop_profile::Report profLastReport;
+static uint32_t profSectionStart[loop_profile::kSectionCount];
+
+// Timebase. DWT counts CPU cycles (64 per microsecond) and is the only
+// way to see a section that costs a few hundred nanoseconds; micros()
+// is the fallback when a debugger owns DWT or TRCENA will not stick.
+static bool profUseDwt = false;
+static uint32_t profTicksPerUs = 1;
+
+// Cached port + mask for the profiling pin so an edge is a single
+// register store rather than a digitalWrite() pin-map lookup — the pin
+// exists to be a ground truth the software timing can be checked
+// against, so it must not carry meaningful cost of its own.
+static NRF_GPIO_Type* profPinPort = nullptr;
+static uint32_t profPinMask = 0;
+
+static inline uint32_t profNow() {
+ return profUseDwt ? DWT->CYCCNT : (uint32_t)micros();
+}
+
+static inline void profPinSet(bool high) {
+ if (profPinPort == nullptr) return;
+ if (high) {
+ profPinPort->OUTSET = profPinMask;
+ } else {
+ profPinPort->OUTCLR = profPinMask;
+ }
+}
+
+/**
+ * @brief Turn the DWT cycle counter on and prove it is counting.
+ *
+ * TRCENA can be held off by a debug probe, and CYCCNTENA is optional in
+ * the architecture, so "we set the bit" is not evidence. Read the
+ * counter across a short spin and require it to have moved; if it has
+ * not, the caller falls back to micros() rather than reporting a window
+ * full of zeros.
+ */
+static bool profEnableDwt() {
+ CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
+ DWT->CYCCNT = 0;
+ DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
+ const uint32_t before = DWT->CYCCNT;
+ for (volatile int i = 0; i < 64; i++) {
+ }
+ return DWT->CYCCNT != before;
+}
+
+/**
+ * @brief Claim the profiling pin, unless it is an NFC pad that has not
+ * been converted to GPIO.
+ *
+ * The conversion is NEOPIXEL_SETUP's one-way UICR write (subsystem 16)
+ * and this module deliberately does not perform it: spending a board's
+ * NFC pads is a decision that belongs to the LED feature, not to a
+ * bench tool. On an unconverted board the pin is refused and the
+ * software rollup runs alone — profilingPinLive() reports that, and the
+ * page shows it, so a silent scope is never a mystery.
+ */
+static void profClaimPin() {
+#if (PROFILING_PIN == 30) || (PROFILING_PIN == 31)
+ if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) != 0) {
+ debugln(F("Profiling: NFC pads not converted — pin unavailable"));
+ return;
+ }
+#endif
+ pinMode(PROFILING_PIN, OUTPUT);
+ digitalWrite(PROFILING_PIN, LOW);
+
+ const uint32_t psel = g_ADigitalPinMap[PROFILING_PIN];
+ profPinPort = (psel >= 32) ? NRF_P1 : NRF_P0;
+ profPinMask = 1UL << (psel & 31);
+ profPinSet(false);
+}
+
+void PROFILING_SETUP() {
+ profUseDwt = profEnableDwt();
+ profTicksPerUs = profUseDwt ? (uint32_t)(F_CPU / 1000000UL) : 1UL;
+ for (uint8_t i = 0; i < loop_profile::kSectionCount; i++) {
+ profSectionStart[i] = 0;
+ }
+ profLastReport.valid = false;
+
+ profClaimPin();
+ loop_profile::reset(profState, profNow(), millis());
+
+ debug(F("Profiling: timebase "));
+ debug(profUseDwt ? F("DWT") : F("micros"));
+ debug(F(", pin "));
+ debugln(profPinPort != nullptr ? F("live") : F("unavailable"));
+}
+
+void PROFILING_SLEEP() {
+ profPinSet(false);
+}
+
+// The pin edges are #if'd rather than compared at runtime: with the
+// default whole-loop setting the comparison is provably false for every
+// valid section, and a dead branch in the two hottest functions in the
+// firmware is exactly the kind of cost a profiler must not add to what
+// it measures.
+void profSectionBegin(uint8_t section) {
+ if (section >= loop_profile::kSectionCount) return;
+#if PROFILING_PIN_SECTION != PROFILING_PIN_WHOLE_LOOP
+ if (section == (uint8_t)(PROFILING_PIN_SECTION)) profPinSet(true);
+#endif
+ profSectionStart[section] = profNow();
+}
+
+void profSectionEnd(uint8_t section) {
+ if (section >= loop_profile::kSectionCount) return;
+ const uint32_t elapsed =
+ loop_profile::ticksSince(profSectionStart[section], profNow());
+#if PROFILING_PIN_SECTION != PROFILING_PIN_WHOLE_LOOP
+ if (section == (uint8_t)(PROFILING_PIN_SECTION)) profPinSet(false);
+#endif
+ loop_profile::addSection(profState, section, elapsed);
+}
+
+const loop_profile::Report& profilingReport() { return profLastReport; }
+
+const char* profilingTimebaseTag() { return profUseDwt ? "DWT" : "us"; }
+
+bool profilingPinLive() { return profPinPort != nullptr; }
+
+ProfLoopScope::ProfLoopScope() : startTicks_(profNow()) {
+#if PROFILING_PIN_SECTION == PROFILING_PIN_WHOLE_LOOP
+ profPinSet(true);
+#endif
+}
+
+ProfLoopScope::~ProfLoopScope() {
+ const uint32_t now = profNow();
+#if PROFILING_PIN_SECTION == PROFILING_PIN_WHOLE_LOOP
+ profPinSet(false);
+#endif
+ loop_profile::addLoop(profState,
+ loop_profile::ticksSince(startTicks_, now));
+
+ // The rollup lives here rather than in a PROFILING_LOOP() call at the
+ // bottom of loop() for the same reason the loop span does: the BLE
+ // and USB parked branches return early, and a profiler that stops
+ // reporting exactly when the firmware parks is worse than none.
+ // millis(), not the tick counter, decides when the window is over —
+ // see the loop_profile header. The tick counter measures durations;
+ // it is not a clock.
+ if (loop_profile::rollup(profState, now, millis(), PROFILING_WINDOW_MS,
+ profTicksPerUs, profLastReport)) {
+#ifdef HAS_DEBUG
+ debug(F("PROFILE "));
+ debug(profLastReport.loopRateHz);
+ debug(F("Hz mean "));
+ debug(profLastReport.loopMeanUs);
+ debug(F("us max "));
+ debug(profLastReport.loopMaxUs);
+ debug(F("us busy "));
+ debug(profLastReport.busyPermille);
+ debug(F("/1000 awake "));
+ debug(profLastReport.awakeUs);
+ debug(F("/"));
+ debug(profLastReport.windowUs);
+ debug(F("us | "));
+ for (uint8_t i = 0; i < loop_profile::kReportSlots; i++) {
+ debug(loop_profile::sectionTag(i));
+ debug(F(":"));
+ debug(profLastReport.permille[i] / 10);
+ debug(F("."));
+ debug(profLastReport.permille[i] % 10);
+ debug(F(" "));
+ }
+ debugln(F(""));
+#endif
+ }
+}
+
+#else // !BIRDSEYE_ENABLE_PROFILING
+
+// Nothing to do and nothing to hold: unlike the NeoPixel stubs, this
+// module never owns a pin in a flag-off build, so there is no retained
+// GPIO state to re-assert before System OFF.
+void PROFILING_SETUP() {}
+void PROFILING_SLEEP() {}
+
+#endif // BIRDSEYE_ENABLE_PROFILING
diff --git a/BirdsEye/project.h b/BirdsEye/project.h
index 7c4bafb..4d7e4f1 100644
--- a/BirdsEye/project.h
+++ b/BirdsEye/project.h
@@ -1,292 +1,349 @@
-#ifndef _DOVES_PROJECT_H
-#define _DOVES_PROJECT_H
-
-#include
-
-///////////////////////////////////////////
-// DovesDataLogger - Project-Wide Types & Macros
-//
-// This header is included FIRST in BirdsEye.ino so that custom types
-// are available before the Arduino preprocessor generates function
-// prototypes from the other .ino module files.
-///////////////////////////////////////////
-
-///////////////////////////////////////////
-// FIRMWARE VERSION
-//
-// Single source of truth for the *base* firmware version. Exposed over BLE
-// 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.
-//
-// Beta/nightly builds override it from the build flag: the beta workflow
-// passes -DFIRMWARE_VERSION_OVERRIDE=-beta. as a bare token —
-// the stringizer below quotes it, so there is no shell quote-escaping to get
-// wrong. A normal build (prod release, plain IDE, compile-sketch CI) leaves it
-// undefined and uses the literal below.
-///////////////////////////////////////////
-#define _BE_STRINGIFY(x) #x
-#define _BE_TOSTRING(x) _BE_STRINGIFY(x)
-#ifdef FIRMWARE_VERSION_OVERRIDE
- #define FIRMWARE_VERSION _BE_TOSTRING(FIRMWARE_VERSION_OVERRIDE)
-#else
- // The 4.0.0 release cut (matches the v4.0.0 tag). The webapp still keys
- // the track JSON budget off this — 8 KB at or above 3.2.0 — so never
- // stamp a build below that line again.
- #define FIRMWARE_VERSION "4.0.0"
-#endif
-
-///////////////////////////////////////////
-// BUILD FEATURE FLAGS
-//
-// Both are plain 0/1 macros with a #ifndef default, so a build can force
-// either state explicitly (-DFLAG=1 / -DFLAG=0). A bare -DFLAG also works
-// (the compiler defines it as 1). Test them with #if, never #ifdef — a
-// deliberate -DFLAG=0 must lose to the feature being off.
-///////////////////////////////////////////
-
-// ---- Onboard (XIAO) battery charging ----
-//
-// 0 (default — master, beta and release all ship this): the firmware keeps
-// its hands off the BQ25100. HICHG is never driven, so the onboard charger
-// stays at its ~50 mA default and an EXTERNAL charging circuit owns the
-// battery. USB is then a host/data connection rather than a charge session:
-// a VBUS wake boots normally instead of shortcutting to the charge screen,
-// and the main menu no longer pulls itself down after
-// USB_MENU_CHARGE_IDLE_MS just because a cable is plugged in.
-//
-// 1: the pre-3.0.1 behavior — hold HICHG high for ~100 mA fast charge and
-// run the charging UX built around it.
-//
-// NOTE (independent of this flag): with VBUS present, shutdown still parks
-// in a display-off loop instead of entering System OFF. VBUS is an
-// always-armed System OFF wake source on the nRF52840, so powering down
-// with the cable still in risks an immediate wake-reset loop. The park is a
-// hardware constraint, not a charging feature.
-#ifndef BIRDSEYE_ENABLE_ONBOARD_CHARGING
- #define BIRDSEYE_ENABLE_ONBOARD_CHARGING 0
-#endif
-
-// ---- SensorEgg wireless EGT (BLE observer, proof of concept) ----
-//
-// 0 (default — master and release): the whole POC is compiled out. No
-// passive scanner, no Temp1 race page, and BLE goes back to coming up
-// lazily on first camera/transfer use instead of at boot. The DOVEX
-// Temp1/Junction1 columns are still written (as `nan`) so the log format is
-// identical across channels.
-//
-// 1 (the beta channel passes -DBIRDSEYE_ENABLE_SENSOREGG=1): scanner,
-// display page and live readings all present.
-#ifndef BIRDSEYE_ENABLE_SENSOREGG
- #define BIRDSEYE_ENABLE_SENSOREGG 0
-#endif
-
-///////////////////////////////////////////
-// BOARD VARIANT
-//
-// Identifies which XIAO nRF52840 this image targets so the companion can
-// fetch the matching OTA package. Reported over BLE as part of the DIS
-// model string ("BirdsEye-" FIRMWARE_VARIANT), which equals the release
-// asset prefix (BirdsEye-sense.zip / BirdsEye-nonsense.zip) so the webapp
-// can map model -> download directly.
-//
-// Resolution order:
-// 1. The build workflows pass -DBIRDSEYE_BOARD_SENSE / -DBIRDSEYE_BOARD_NONSENSE
-// per FQBN — these always win.
-// 2. A plain Arduino IDE build passes no such flag, but the Seeeduino core
-// defines ARDUINO_ from the Tools -> Board selection
-// (ARDUINO_Seeed_XIAO_nRF52840_Sense vs ARDUINO_Seeed_XIAO_nRF52840), so
-// we derive the variant from the board you actually picked — no manual
-// flag needed.
-// 3. If nothing matches (some other core / unknown board), default to
-// "sense" (the primary board).
-// A wrong label only affects which OTA asset the companion offers — the images
-// share the MCU/SoftDevice, the non-Sense image boots fine on a Sense board and
-// vice-versa, and accelerometer presence is auto-detected at runtime regardless
-// of this flag — so a mislabel never bricks.
-///////////////////////////////////////////
-#if defined(BIRDSEYE_BOARD_NONSENSE)
- #define FIRMWARE_VARIANT "nonsense"
-#elif defined(BIRDSEYE_BOARD_SENSE)
- #define FIRMWARE_VARIANT "sense"
-#elif defined(ARDUINO_Seeed_XIAO_nRF52840_Sense)
- #define FIRMWARE_VARIANT "sense"
-#elif defined(ARDUINO_Seeed_XIAO_nRF52840)
- #define FIRMWARE_VARIANT "nonsense"
-#else // unknown board / core — fall back to the primary variant
- #define FIRMWARE_VARIANT "sense"
-#endif
-
-///////////////////////////////////////////
-// BUILD-FLAG CONTRACT: GPS UART BUFFER
-//
-// The core's Serial1 RX ring must be 256 B (stock is 64 B, #ifndef-
-// guarded in the BSP's RingBuffer.h). SoftDevice radio interrupts
-// (unmaskable, prio 0–2) defer the TIMER3 GPS drain ISR (prio 3); at
-// 57600 baud the stock 64 B ring gives only ~1 ms of deferral slack
-// before bytes are silently dropped and 25 Hz PVT frames are lost —
-// the exact field regression the drop counters were built to catch.
-// A silently under-buffered build would reintroduce it, so fail loud.
-//
-// CI passes -DSERIAL_BUFFER_SIZE=256 (all three workflows). For a
-// local Arduino IDE / arduino-cli build, see CONTRIBUTING.md
-// ("Local build flags") — one-time setup via platform.local.txt or
-// --build-property.
-///////////////////////////////////////////
-#ifndef SIM
-static_assert(SERIAL_BUFFER_SIZE >= 256,
- "Build with -DSERIAL_BUFFER_SIZE=256 (see CONTRIBUTING.md, "
- "'Local build flags') — the stock 64 B Serial1 ring drops GPS "
- "bytes under BLE radio load.");
-#endif
-
-///////////////////////////////////////////
-// DEBUG MACROS
-///////////////////////////////////////////
-
-#ifdef HAS_DEBUG
-#define debugln Serial.println
-#define debug Serial.print
-#else
-inline void dummy_debug(...) {
-}
-#define debug dummy_debug
-#define debugln dummy_debug
-#endif
-
-///////////////////////////////////////////
-// PROJECT LIMITS
-// Defined here so structs below can reference them.
-// These were previously in BirdsEye.ino — moved here for
-// visibility in project-wide types.
-///////////////////////////////////////////
-#define MAX_LOCATIONS 200
-#define MAX_LOCATION_LENGTH 13 // 13 is old DOS format for FAT16
-#define MAX_LAYOUTS 10
-#define MAX_LAYOUT_LENGTH 15
-#define DOVEX_HEADER_SIZE 1024 // Reserved header bytes in .dovex files (1 KB)
-#define TRACK_DETECT_RADIUS_MILES 5.0 // Haversine threshold for live track detection
-
-// Shutdown (System OFF) constants
-#define SLEEP_IDLE_TIMEOUT_MS 300000 // 5 min menu idle -> auto-shutdown
-#define SLEEP_LONG_PRESS_MS 5000 // 5s hold for shutdown/reboot combos
-#define CHARGE_DISPLAY_TIMEOUT_MS 10000 // Show charging screen for 10s then display off
-#define USB_MENU_CHARGE_IDLE_MS 60000 // USB on menu: charging loop after 60s of no buttons
-// Auto-race must not hijack a deliberate navigation. Leaving a page drops the
-// user on the main menu, and above the speed/RPM trigger the very next loop
-// iteration would convert that into "start racing" — the menu never even gets
-// drawn. Require the menu to have been settled (no arrival, no button) this
-// long first. Only bites right after an interaction: the normal auto-race case
-// (parked on the menu, then drive off) has been quiet for minutes.
-#define AUTO_RACE_MENU_GRACE_MS 3000
-
-///////////////////////////////////////////
-// BLE RADIO OWNERSHIP
-// The one SoftDevice (single advert set + single peripheral connection
-// slot) is shared between the file-transfer service (bluetooth.ino) and
-// the camera remote (camera_ble.ino). bleOwner (defined in BirdsEye.ino,
-// documented in bluetooth.h) records which subsystem currently owns the
-// advert set, so the shared connect/disconnect callbacks can route a
-// peer to the right module.
-///////////////////////////////////////////
-enum BleOwner : uint8_t {
- BLE_OWNER_NONE = 0, // radio idle — nobody advertising
- BLE_OWNER_TRANSFER = 1, // file-transfer page (BLE_SETUP/BLE_STOP)
- BLE_OWNER_CAMERA = 2, // camera remote (camera_ble)
-};
-
-///////////////////////////////////////////
-// RACE SESSION ENTRY CAUSE
-// How the current race session was started (set by startRaceSession in
-// BirdsEye.ino, RACE_ENTRY_NONE outside a session). Manual/speed sessions
-// have no engine signal, so the camera lifecycle and the auto-idle rule
-// key off this instead of RPM: they record from session start and end
-// after 5 min below 5 mph, where tach sessions keep the RPM-driven rules.
-///////////////////////////////////////////
-enum RaceEntryCause : uint8_t {
- RACE_ENTRY_NONE = 0, // no session active
- RACE_ENTRY_MANUAL = 1, // main-menu Race select
- RACE_ENTRY_SPEED = 2, // auto-race speed trip (>= 10 mph on the menu)
- RACE_ENTRY_TACH = 3, // auto-race RPM trip, or tach-wake boot
-};
-
-///////////////////////////////////////////
-// STRUCT DEFINITIONS
-// Must be in a header so Arduino's auto-prototype generation
-// can resolve these types in function signatures.
-///////////////////////////////////////////
-
-struct ButtonState {
- int pin = 0;
- bool pressed = false; // Flag for "button was pressed" (consumed by handler)
- bool wasReleased = true; // Edge detection: must release before next press
- unsigned long lastPressed = 0;
-};
-
-struct TrackLayout {
- double start_a_lat = 0.00;
- double start_a_lng = 0.00;
- double start_b_lat = 0.00;
- double start_b_lng = 0.00;
-
- // Sector 2 line data (optional)
- double sector_2_a_lat = 0.00;
- double sector_2_a_lng = 0.00;
- double sector_2_b_lat = 0.00;
- double sector_2_b_lng = 0.00;
- bool hasSector2 = false;
-
- // Sector 3 line data (optional)
- double sector_3_a_lat = 0.00;
- double sector_3_a_lng = 0.00;
- double sector_3_b_lat = 0.00;
- double sector_3_b_lng = 0.00;
- bool hasSector3 = false;
-
- // Sprint-only: the separate finish line (a run is start -> finish).
- double finish_a_lat = 0.00;
- double finish_a_lng = 0.00;
- double finish_b_lat = 0.00;
- double finish_b_lng = 0.00;
- bool hasFinish = false;
-
- // Sprint-only: sortable ISO-8601 creation stamp ("YYYY-MM-DDTHH:MM",
- // any prefix). Drives newest-course selection (sprint_select unit) —
- // autocross venues re-lay the course every event. Empty on circuit
- // courses and legacy files.
- char date_created[20] = "";
-};
-
-///////////////////////////////////////////
-// TRACK MANIFEST (in-RAM index for proximity detection)
-// Built during buildTrackList() at boot. Each entry stores
-// the filename, a representative lat/lon from the first course, and
-// which folder (= track kind) the file came from.
-///////////////////////////////////////////
-
-// Track kinds: which SD folder a manifest entry came from. Values mirror
-// the host-tested sprint_select::Kind enum.
-#define TRACK_KIND_CIRCUIT 0 // /TRACKS
-#define TRACK_KIND_SPRINT 1 // /TRACKS/SPRINT
-
-struct TrackManifestEntry {
- char filename[32]; // track filename without extension (matches locations[])
- double lat; // first course's start_a_lat
- double lon; // first course's start_a_lng
- uint8_t kind; // TRACK_KIND_CIRCUIT / TRACK_KIND_SPRINT
-};
-
-///////////////////////////////////////////
-// TRACK METADATA (parsed from new JSON object format)
-///////////////////////////////////////////
-struct TrackMetadata {
- char longName[32];
- char shortName[16];
- char defaultCourse[MAX_LAYOUT_LENGTH];
- float courseLengthFt[MAX_LAYOUTS]; // per-course lengthFt
- bool isSprint; // track-level "type": "sprint" (redundant with the folder,
- // cheap validation that a file landed where it claims)
-};
-
-#endif
+#ifndef _DOVES_PROJECT_H
+#define _DOVES_PROJECT_H
+
+#include
+
+///////////////////////////////////////////
+// DovesDataLogger - Project-Wide Types & Macros
+//
+// This header is included FIRST in BirdsEye.ino so that custom types
+// are available before the Arduino preprocessor generates function
+// prototypes from the other .ino module files.
+///////////////////////////////////////////
+
+///////////////////////////////////////////
+// FIRMWARE VERSION
+//
+// Single source of truth for the *base* firmware version. Exposed over BLE
+// 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.
+//
+// Beta/nightly builds override it from the build flag: the beta workflow
+// passes -DFIRMWARE_VERSION_OVERRIDE=-beta. as a bare token —
+// the stringizer below quotes it, so there is no shell quote-escaping to get
+// wrong. A normal build (prod release, plain IDE, compile-sketch CI) leaves it
+// undefined and uses the literal below.
+///////////////////////////////////////////
+#define _BE_STRINGIFY(x) #x
+#define _BE_TOSTRING(x) _BE_STRINGIFY(x)
+#ifdef FIRMWARE_VERSION_OVERRIDE
+ #define FIRMWARE_VERSION _BE_TOSTRING(FIRMWARE_VERSION_OVERRIDE)
+#else
+ // The 4.1.0 release cut (matches the v4.1.0 tag). The webapp still keys
+ // the track JSON budget off this — 8 KB at or above 3.2.0 — so never
+ // stamp a build below that line again.
+ #define FIRMWARE_VERSION "4.1.0"
+#endif
+
+///////////////////////////////////////////
+// BUILD FEATURE FLAGS
+//
+// Each is a plain 0/1 macro with a #ifndef default, so a build can force
+// either state explicitly (-DFLAG=1 / -DFLAG=0). A bare -DFLAG also works
+// (the compiler defines it as 1). Test them with #if, never #ifdef — a
+// deliberate -DFLAG=0 must lose to the feature being off.
+///////////////////////////////////////////
+
+// ---- Onboard (XIAO) battery charging ----
+//
+// 0 (default — master, beta and release all ship this): the firmware keeps
+// its hands off the BQ25100. HICHG is never driven, so the onboard charger
+// stays at its ~50 mA default and an EXTERNAL charging circuit owns the
+// battery. USB is then a host/data connection rather than a charge session:
+// a VBUS wake boots normally instead of shortcutting to the charge screen,
+// and the main menu no longer pulls itself down after
+// USB_MENU_CHARGE_IDLE_MS just because a cable is plugged in.
+//
+// 1: the pre-3.0.1 behavior — hold HICHG high for ~100 mA fast charge and
+// run the charging UX built around it.
+//
+// NOTE (independent of this flag): with VBUS present, shutdown still parks
+// in a display-off loop instead of entering System OFF. VBUS is an
+// always-armed System OFF wake source on the nRF52840, so powering down
+// with the cable still in risks an immediate wake-reset loop. The park is a
+// hardware constraint, not a charging feature.
+#ifndef BIRDSEYE_ENABLE_ONBOARD_CHARGING
+ #define BIRDSEYE_ENABLE_ONBOARD_CHARGING 0
+#endif
+
+// ---- SensorEgg wireless EGT (BLE observer, proof of concept) ----
+//
+// 0 (default — master and release): the whole POC is compiled out. No
+// passive scanner, no Temp1 race page, and BLE goes back to coming up
+// lazily on first camera/transfer use instead of at boot. The DOVEX
+// Temp1/Junction1 columns are still written (as `nan`) so the log format is
+// identical across channels.
+//
+// 1 (the beta channel passes -DBIRDSEYE_ENABLE_SENSOREGG=1): scanner,
+// display page and live readings all present.
+#ifndef BIRDSEYE_ENABLE_SENSOREGG
+ #define BIRDSEYE_ENABLE_SENSOREGG 0
+#endif
+
+// ---- NeoPixel strip (11 px: 2 status + 9-px pace/RPM strip) ----
+//
+// 1 (default — master, beta and release all ship this as of 4.1.0): the
+// strip is a CORE feature, present in every image so a logger works the
+// moment someone wires LEDs to it. On first boot NEOPIXEL_SETUP()
+// converts the NFC pads to GPIO by programming UICR->NFCPINS and
+// self-resets once so the pin latch takes effect. After that: pin 30 =
+// boost converter EN, pin 31 = WS2812 data. See plan 0006 and neopixel.h.
+//
+// KNOW WHAT THIS COSTS, because it is charged to every unit in the field,
+// not just the ones with LEDs on them. The UICR write is ONE-WAY — undoing
+// it needs a full chip erase, i.e. a bootloader reflash over USB — so the
+// first boot after updating to 4.1.0 permanently spends the NFC pads and
+// reboots itself once, on every device, wired for LEDs or not. That was the
+// deliberate 4.1.0 decision (NFC is not used on this hardware and the pads
+// are otherwise idle); it is recorded here rather than in a commit message
+// because nothing about a later build can undo it.
+//
+// 0 (no shipped channel sets this; -DBIRDSEYE_ENABLE_NEOPIXEL=0 forces it):
+// the subsystem is compiled out — no Adafruit_NeoPixel dependency, and on a
+// board that has NOT already been converted the firmware never writes UICR
+// and never drives pins 30/31. On one that HAS (it ran a flag-on build
+// before), the stubs still hold the boost EN pin low, because a floating EN
+// leaves the 5 V rail up through System OFF — see the #else block in
+// neopixel.ino.
+#ifndef BIRDSEYE_ENABLE_NEOPIXEL
+ #define BIRDSEYE_ENABLE_NEOPIXEL 1
+#endif
+
+// ---- Main-loop CPU profiling (plan 0011) ----
+//
+// 0 (default — master and release): compiled out completely. The section
+// brackets in loop() vanish (they are macros, not calls), no LOOP PROFILE
+// page exists, and pin 30 goes on being the NeoPixel boost converter's EN
+// line exactly as it always has.
+//
+// 1 (the beta channel passes -DBIRDSEYE_ENABLE_PROFILING=1): every
+// subsystem call in loop() is timed, rolled up once a second onto the
+// LOOP PROFILE race page, and pin 30 becomes a scope-readable profiling
+// output. This exists to measure what a main-loop iteration actually
+// costs on this hardware, as the input to the nRF52840-vs-nRF5340 board
+// decision and to any "what would leaving the Arduino core buy us"
+// comparison.
+//
+// KNOW WHAT THIS COSTS, and it is not free: a profiling build gives up
+// firmware control of the 5 V boost rail. Pin 30 cannot be both EN and a
+// profiling output, so the profiler takes it and the regulator is left at
+// its hardware default (EN pulled up = on). NEOPIXEL_SETUP/SLEEP/WAKE
+// stop driving EN, which means the rail — and the idle current of 11
+// WS2812s — stays up through System OFF, where GPIO levels are retained
+// and nothing else will pull it down. A profiling unit left asleep on a
+// battery goes flat. This is a bench build; do not hand one to a driver.
+// See profiling.h for the pin's wiring caveat and the timebase notes.
+#ifndef BIRDSEYE_ENABLE_PROFILING
+ #define BIRDSEYE_ENABLE_PROFILING 0
+#endif
+
+///////////////////////////////////////////
+// BOARD VARIANT
+//
+// Identifies which XIAO nRF52840 this image targets so the companion can
+// fetch the matching OTA package. Reported over BLE as part of the DIS
+// model string ("BirdsEye-" FIRMWARE_VARIANT), which equals the release
+// asset prefix (BirdsEye-sense.zip / BirdsEye-nonsense.zip) so the webapp
+// can map model -> download directly.
+//
+// Resolution order:
+// 1. The build workflows pass -DBIRDSEYE_BOARD_SENSE / -DBIRDSEYE_BOARD_NONSENSE
+// per FQBN — these always win.
+// 2. A plain Arduino IDE build passes no such flag, but the Seeeduino core
+// defines ARDUINO_ from the Tools -> Board selection
+// (ARDUINO_Seeed_XIAO_nRF52840_Sense vs ARDUINO_Seeed_XIAO_nRF52840), so
+// we derive the variant from the board you actually picked — no manual
+// flag needed.
+// 3. If nothing matches (some other core / unknown board), default to
+// "sense" (the primary board).
+// A wrong label only affects which OTA asset the companion offers — the images
+// share the MCU/SoftDevice, the non-Sense image boots fine on a Sense board and
+// vice-versa, and accelerometer presence is auto-detected at runtime regardless
+// of this flag — so a mislabel never bricks.
+///////////////////////////////////////////
+#if defined(BIRDSEYE_BOARD_NONSENSE)
+ #define FIRMWARE_VARIANT "nonsense"
+#elif defined(BIRDSEYE_BOARD_SENSE)
+ #define FIRMWARE_VARIANT "sense"
+#elif defined(ARDUINO_Seeed_XIAO_nRF52840_Sense)
+ #define FIRMWARE_VARIANT "sense"
+#elif defined(ARDUINO_Seeed_XIAO_nRF52840)
+ #define FIRMWARE_VARIANT "nonsense"
+#else // unknown board / core — fall back to the primary variant
+ #define FIRMWARE_VARIANT "sense"
+#endif
+
+///////////////////////////////////////////
+// BUILD-FLAG CONTRACT: GPS UART BUFFER
+//
+// The core's Serial1 RX ring must be 256 B (stock is 64 B, #ifndef-
+// guarded in the BSP's RingBuffer.h). SoftDevice radio interrupts
+// (unmaskable, prio 0–2) defer the TIMER3 GPS drain ISR (prio 3); at
+// 57600 baud the stock 64 B ring gives only ~1 ms of deferral slack
+// before bytes are silently dropped and 25 Hz PVT frames are lost —
+// the exact field regression the drop counters were built to catch.
+// A silently under-buffered build would reintroduce it, so fail loud.
+//
+// CI passes -DSERIAL_BUFFER_SIZE=256 (all three workflows). For a
+// local Arduino IDE / arduino-cli build, see CONTRIBUTING.md
+// ("Local build flags") — one-time setup via platform.local.txt or
+// --build-property.
+///////////////////////////////////////////
+#ifndef SIM
+static_assert(SERIAL_BUFFER_SIZE >= 256,
+ "Build with -DSERIAL_BUFFER_SIZE=256 (see CONTRIBUTING.md, "
+ "'Local build flags') — the stock 64 B Serial1 ring drops GPS "
+ "bytes under BLE radio load.");
+#endif
+
+///////////////////////////////////////////
+// DEBUG MACROS
+///////////////////////////////////////////
+
+#ifdef HAS_DEBUG
+#define debugln Serial.println
+#define debug Serial.print
+#else
+inline void dummy_debug(...) {
+}
+#define debug dummy_debug
+#define debugln dummy_debug
+#endif
+
+///////////////////////////////////////////
+// PROJECT LIMITS
+// Defined here so structs below can reference them.
+// These were previously in BirdsEye.ino — moved here for
+// visibility in project-wide types.
+///////////////////////////////////////////
+#define MAX_LOCATIONS 200
+#define MAX_LOCATION_LENGTH 13 // 13 is old DOS format for FAT16
+#define MAX_LAYOUTS 10
+#define MAX_LAYOUT_LENGTH 15
+#define DOVEX_HEADER_SIZE 1024 // Reserved header bytes in .dovex files (1 KB)
+#define TRACK_DETECT_RADIUS_MILES 5.0 // Haversine threshold for live track detection
+
+// Shutdown (System OFF) constants
+#define SLEEP_IDLE_TIMEOUT_MS 300000 // 5 min menu idle -> auto-shutdown
+#define SLEEP_LONG_PRESS_MS 5000 // 5s hold for shutdown/reboot combos
+#define CHARGE_DISPLAY_TIMEOUT_MS 10000 // Show charging screen for 10s then display off
+#define USB_MENU_CHARGE_IDLE_MS 60000 // USB on menu: charging loop after 60s of no buttons
+// Auto-race must not hijack a deliberate navigation. Leaving a page drops the
+// user on the main menu, and above the speed/RPM trigger the very next loop
+// iteration would convert that into "start racing" — the menu never even gets
+// drawn. Require the menu to have been settled (no arrival, no button) this
+// long first. Only bites right after an interaction: the normal auto-race case
+// (parked on the menu, then drive off) has been quiet for minutes.
+#define AUTO_RACE_MENU_GRACE_MS 3000
+
+///////////////////////////////////////////
+// BLE RADIO OWNERSHIP
+// The one SoftDevice (single advert set + single peripheral connection
+// slot) is shared between the file-transfer service (bluetooth.ino) and
+// the camera remote (camera_ble.ino). bleOwner (defined in BirdsEye.ino,
+// documented in bluetooth.h) records which subsystem currently owns the
+// advert set, so the shared connect/disconnect callbacks can route a
+// peer to the right module.
+///////////////////////////////////////////
+enum BleOwner : uint8_t {
+ BLE_OWNER_NONE = 0, // radio idle — nobody advertising
+ BLE_OWNER_TRANSFER = 1, // file-transfer page (BLE_SETUP/BLE_STOP)
+ BLE_OWNER_CAMERA = 2, // camera remote (camera_ble)
+};
+
+///////////////////////////////////////////
+// RACE SESSION ENTRY CAUSE
+// How the current race session was started (set by startRaceSession in
+// BirdsEye.ino, RACE_ENTRY_NONE outside a session). Manual/speed sessions
+// have no engine signal, so the camera lifecycle and the auto-idle rule
+// key off this instead of RPM: they record from session start and end
+// after 5 min below 5 mph, where tach sessions keep the RPM-driven rules.
+///////////////////////////////////////////
+enum RaceEntryCause : uint8_t {
+ RACE_ENTRY_NONE = 0, // no session active
+ RACE_ENTRY_MANUAL = 1, // main-menu Race select
+ RACE_ENTRY_SPEED = 2, // auto-race speed trip (>= 10 mph on the menu)
+ RACE_ENTRY_TACH = 3, // auto-race RPM trip, or tach-wake boot
+};
+
+///////////////////////////////////////////
+// STRUCT DEFINITIONS
+// Must be in a header so Arduino's auto-prototype generation
+// can resolve these types in function signatures.
+///////////////////////////////////////////
+
+struct ButtonState {
+ int pin = 0;
+ bool pressed = false; // Flag for "button was pressed" (consumed by handler)
+ bool wasReleased = true; // Edge detection: must release before next press
+ unsigned long lastPressed = 0;
+};
+
+struct TrackLayout {
+ double start_a_lat = 0.00;
+ double start_a_lng = 0.00;
+ double start_b_lat = 0.00;
+ double start_b_lng = 0.00;
+
+ // Sector 2 line data (optional)
+ double sector_2_a_lat = 0.00;
+ double sector_2_a_lng = 0.00;
+ double sector_2_b_lat = 0.00;
+ double sector_2_b_lng = 0.00;
+ bool hasSector2 = false;
+
+ // Sector 3 line data (optional)
+ double sector_3_a_lat = 0.00;
+ double sector_3_a_lng = 0.00;
+ double sector_3_b_lat = 0.00;
+ double sector_3_b_lng = 0.00;
+ bool hasSector3 = false;
+
+ // Sprint-only: the separate finish line (a run is start -> finish).
+ double finish_a_lat = 0.00;
+ double finish_a_lng = 0.00;
+ double finish_b_lat = 0.00;
+ double finish_b_lng = 0.00;
+ bool hasFinish = false;
+
+ // Sprint-only: sortable ISO-8601 creation stamp ("YYYY-MM-DDTHH:MM",
+ // any prefix). Drives newest-course selection (sprint_select unit) —
+ // autocross venues re-lay the course every event. Empty on circuit
+ // courses and legacy files.
+ char date_created[20] = "";
+};
+
+///////////////////////////////////////////
+// TRACK MANIFEST (in-RAM index for proximity detection)
+// Built during buildTrackList() at boot. Each entry stores
+// the filename, a representative lat/lon from the first course, and
+// which folder (= track kind) the file came from.
+///////////////////////////////////////////
+
+// Track kinds: which SD folder a manifest entry came from. Values mirror
+// the host-tested sprint_select::Kind enum.
+#define TRACK_KIND_CIRCUIT 0 // /TRACKS
+#define TRACK_KIND_SPRINT 1 // /TRACKS/SPRINT
+
+struct TrackManifestEntry {
+ char filename[32]; // track filename without extension (matches locations[])
+ double lat; // first course's start_a_lat
+ double lon; // first course's start_a_lng
+ uint8_t kind; // TRACK_KIND_CIRCUIT / TRACK_KIND_SPRINT
+};
+
+///////////////////////////////////////////
+// TRACK METADATA (parsed from new JSON object format)
+///////////////////////////////////////////
+struct TrackMetadata {
+ char longName[32];
+ char shortName[16];
+ char defaultCourse[MAX_LAYOUT_LENGTH];
+ float courseLengthFt[MAX_LAYOUTS]; // per-course lengthFt
+ bool isSprint; // track-level "type": "sprint" (redundant with the folder,
+ // cheap validation that a file landed where it claims)
+};
+
+#endif
diff --git a/BirdsEye/replay.h b/BirdsEye/replay.h
index e350046..50e37dd 100644
--- a/BirdsEye/replay.h
+++ b/BirdsEye/replay.h
@@ -18,6 +18,19 @@ void resetReplayState();
// Returns true if any were found.
bool buildReplayFileList();
+extern int numReplayFiles;
+
+// Rows on the session browser: one per file plus a trailing Back row.
+//
+// The single source of truth for that layout — the renderer, the menu's
+// row limit and the selection handler all derive from it, so the Back row
+// cannot end up drawn in one place and unreachable in another. An empty
+// list keeps its one-row "no files" screen (unreachable in practice: the
+// page is only opened when buildReplayFileList() found something).
+inline int replayItemCount() {
+ return numReplayFiles > 0 ? numReplayFiles + 1 : 1;
+}
+
// Read one line from `file` into `buffer` (drops \r, stops at \n /
// EOF). Returns false on EOF when nothing was buffered.
bool readReplayLine(File& file, char* buffer, int bufferSize);
diff --git a/BirdsEye/sd_functions.h b/BirdsEye/sd_functions.h
index 750da09..a71d9d8 100644
--- a/BirdsEye/sd_functions.h
+++ b/BirdsEye/sd_functions.h
@@ -82,6 +82,13 @@ bool sdSetSpiClock(uint32_t maxSck);
// to the normal clock if the fast re-init fails.
void sdSetTransferSpeed(bool fast);
+// The SPI clock the card is actually running at (Hz), i.e. the last one
+// SD.begin() accepted — 0 before the first successful init. Not the same as
+// the clock that was requested: sdSetTransferSpeed(true) silently falls back
+// to the normal clock when the fast re-init fails, and a transfer session
+// running at 2 MHz instead of 8 MHz is worth being able to see.
+uint32_t sdActiveSpiHz();
+
// Make sure /TRACKS exists, creating it when missing (blank soldered-in
// card). Caller must already hold the SD mutex. Returns true when the
// folder exists or was created.
diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino
index 0841333..9768d68 100644
--- a/BirdsEye/sd_functions.ino
+++ b/BirdsEye/sd_functions.ino
@@ -8,6 +8,10 @@
#include "track_json.h"
#include "course_prune.h"
+// The SPI clock the last successful SD.begin() ran at. Module-local state
+// behind sdActiveSpiHz(); 0 until the card has come up once.
+static uint32_t sdActiveSpiClock = 0;
+
/**
* @brief Attempt to acquire SD card access for a subsystem
* @param mode The access mode being requested (SD_ACCESS_*)
@@ -75,6 +79,7 @@ void makeFullTrackPath(const char* trackName, char* filepath, uint8_t kind) {
bool sdSetSpiClock(uint32_t maxSck) {
for (int attempt = 0; attempt < 3; attempt++) {
if (SD.begin(PIN_SPI_CS, maxSck)) {
+ sdActiveSpiClock = maxSck;
return true;
}
debugln(F("SD init attempt failed, retrying..."));
@@ -83,6 +88,15 @@ bool sdSetSpiClock(uint32_t maxSck) {
return false;
}
+// The clock the card is ACTUALLY running at — the last one SD.begin()
+// accepted, not the one that was asked for. sdSetTransferSpeed(true) falls
+// back to the normal clock when the fast re-init fails, and that fallback
+// used to be invisible: a transfer session could quietly run at 2 MHz with
+// nothing anywhere saying so. The Bluetooth page reads this.
+uint32_t sdActiveSpiHz() {
+ return sdActiveSpiClock;
+}
+
// Switch the SD SPI clock between the parked-transfer fast clock and the
// EMI-safe normal clock. Only safe to call when no SD file is open (the
// BLE/USB transfer entry/exit points). Falls back to the normal clock if the
diff --git a/BirdsEye/sector_purple.cpp b/BirdsEye/sector_purple.cpp
new file mode 100644
index 0000000..da0e54f
--- /dev/null
+++ b/BirdsEye/sector_purple.cpp
@@ -0,0 +1,146 @@
+#include "sector_purple.h"
+
+namespace sector_purple {
+
+namespace {
+
+// One rule for both laps and sectors. kBest is checked FIRST: beating
+// the previous time when that previous time was also the session best
+// is a session best, and purple outranks green.
+Verdict verdictFor(uint32_t t, uint32_t prev, uint32_t bestAtOpen) {
+ if (t == 0) {
+ return Verdict::kNone; // never closed / not recorded
+ }
+ if (bestAtOpen != 0 && t < bestAtOpen) {
+ return Verdict::kBest;
+ }
+ if (prev == 0) {
+ return Verdict::kNone; // nothing to compare against yet
+ }
+ return t < prev ? Verdict::kBetter : Verdict::kWorse;
+}
+
+} // namespace
+
+void reset(State& s) {
+ s.lastSector = -1;
+ s.lastLaps = 0;
+ for (int i = 0; i < 3; i++) {
+ s.bestAtOpen[i] = 0;
+ s.lastSectorTime[i] = 0;
+ }
+ s.s1 = 0;
+ s.s2 = 0;
+ s.prevLapTime = 0;
+ s.bestLapAtOpen = 0;
+}
+
+Event update(State& s, const Sample& in) {
+ Event ev;
+
+ if (!in.raceStarted) {
+ reset(s);
+ return ev;
+ }
+
+ if (s.lastSector < 0) {
+ // First sample of a live race: adopt the current position without
+ // firing. Snapshot the open sector's best and the session-best lap
+ // now — mid-lap attach is safe because bests only change at the lap
+ // line, which we handle as an edge below from here on.
+ s.lastSector = in.sectorsConfigured ? in.currentSector : 0;
+ s.lastLaps = in.laps;
+ s.bestLapAtOpen = in.bestLapTime;
+ if (in.sectorsConfigured && in.currentSector >= 1 &&
+ in.currentSector <= 3) {
+ s.bestAtOpen[in.currentSector - 1] =
+ in.bestSectorTime[in.currentSector - 1];
+ }
+ return ev;
+ }
+
+ // Lap edge = sector 3 closed at the start/finish line, and the lap
+ // itself closed. The library may already have updated bests and
+ // rolled the current-lap times, so S3 is derived from the lap total
+ // minus our own recorded S1/S2 and compared against the snapshot
+ // taken when S3 opened.
+ if (in.laps > s.lastLaps) {
+ if (in.sectorsConfigured && s.s1 > 0 && s.s2 > 0 &&
+ in.lastLapTime > s.s1 + s.s2) {
+ uint32_t const s3 = in.lastLapTime - s.s1 - s.s2;
+ ev.closedSector = 3;
+ ev.sectorVerdict = verdictFor(s3, s.lastSectorTime[2], s.bestAtOpen[2]);
+ if (ev.sectorVerdict == Verdict::kBest) {
+ ev.purpleSector = 3;
+ }
+ s.lastSectorTime[2] = s3;
+ }
+
+ // The lap half runs with or without sector lines.
+ if (in.lastLapTime > 0) {
+ ev.lapClosed = true;
+ ev.lapVerdict =
+ verdictFor(in.lastLapTime, s.prevLapTime, s.bestLapAtOpen);
+ ev.purpleLap = (ev.lapVerdict == Verdict::kBest);
+ s.prevLapTime = in.lastLapTime;
+ }
+
+ // Re-arm for the new lap: S1 is open now, and the library has by
+ // now folded the finished lap into its best — which is exactly the
+ // baseline the NEXT lap must beat.
+ s.s1 = 0;
+ s.s2 = 0;
+ s.bestAtOpen[0] = in.bestSectorTime[0];
+ s.bestLapAtOpen = in.bestLapTime;
+ s.lastLaps = in.laps;
+ s.lastSector = in.sectorsConfigured ? in.currentSector : 0;
+ return ev;
+ }
+
+ if (in.sectorsConfigured && in.currentSector != s.lastSector) {
+ if (s.lastSector == 1 && in.currentSector == 2) {
+ // S1 closed: its current-lap time is now populated. A zero here
+ // means it never really closed (GPS dropout across the line), so
+ // report nothing rather than a bogus verdict.
+ s.s1 = in.lapSectorTime[0];
+ if (s.s1 > 0) {
+ ev.closedSector = 1;
+ ev.sectorVerdict =
+ verdictFor(s.s1, s.lastSectorTime[0], s.bestAtOpen[0]);
+ if (ev.sectorVerdict == Verdict::kBest) {
+ ev.purpleSector = 1;
+ }
+ s.lastSectorTime[0] = s.s1;
+ }
+ s.bestAtOpen[1] = in.bestSectorTime[1];
+ } else if (s.lastSector == 2 && in.currentSector == 3) {
+ s.s2 = in.lapSectorTime[1];
+ if (s.s2 > 0) {
+ ev.closedSector = 2;
+ ev.sectorVerdict =
+ verdictFor(s.s2, s.lastSectorTime[1], s.bestAtOpen[1]);
+ if (ev.sectorVerdict == Verdict::kBest) {
+ ev.purpleSector = 2;
+ }
+ s.lastSectorTime[1] = s.s2;
+ }
+ s.bestAtOpen[2] = in.bestSectorTime[2];
+ } else if (in.currentSector == 1) {
+ // Race start / new sprint run: S1 just opened.
+ s.s1 = 0;
+ s.s2 = 0;
+ s.bestAtOpen[0] = in.bestSectorTime[0];
+ } else if (in.currentSector == 0) {
+ // Between sprint runs: nothing in progress. lastSectorTime[] and
+ // prevLapTime deliberately survive — run 2's sector 1 wants to be
+ // compared against run 1's.
+ s.s1 = 0;
+ s.s2 = 0;
+ }
+ s.lastSector = in.currentSector;
+ }
+
+ return ev;
+}
+
+} // namespace sector_purple
diff --git a/BirdsEye/sector_purple.h b/BirdsEye/sector_purple.h
new file mode 100644
index 0000000..f62d27e
--- /dev/null
+++ b/BirdsEye/sector_purple.h
@@ -0,0 +1,113 @@
+#pragma once
+
+#include
+
+///////////////////////////////////////////
+// LAP / SECTOR CLOSE-EDGE MONITOR
+// Watches the active lap timer and reports what just closed: which
+// sector or lap ended this step, how it compared to the last recorded
+// one, and whether it went session-purple. The LED subsystem turns
+// those verdicts into a status colour (led_status) and the purple
+// events into the celebration animation.
+//
+// NOTE ON THE NAME: this unit started life as purple-sector detection
+// only (plan 0006) and kept its filename through plan 0013, which added
+// the lap edge and the better/worse verdicts. Purple is now one of its
+// outputs, not its whole job. A rename is a clean standalone follow-up;
+// it was not worth burying the behaviour diff under file churn.
+//
+// WHY THE EDGE MACHINERY IS SHAPED LIKE THIS: the lap timer library
+// exposes getCurrentSector() / getCurrentLapSectorNTime() /
+// getBestSectorNTime() but no events, and updateBestSectors() runs AT
+// THE START/FINISH CROSSING — not at each sector line — so at the exact
+// moment sector 3 closes, the library may already have folded this lap
+// into the "best" values and rolled the current-lap times over. Same
+// for getBestLapTime(). This unit therefore never compares against live
+// library state at a close edge:
+//
+// - Best times are snapshotted when each sector OPENS (bestAtOpen[])
+// and when each lap opens (bestLapAtOpen).
+// - S1/S2 close on getCurrentSector() transitions (1->2, 2->3) and
+// compare the just-closed current-lap time against the snapshot.
+// - S3 closes on the LAP-COUNT edge, and its time is DERIVED:
+// lastLapTime - s1 - s2 — both inputs immune to the lap-line update.
+// - Purple fires only when the beaten best was nonzero: no purple
+// anywhere on lap 1 (celebrating every first-ever sector is noise).
+//
+// THE BETTER/WORSE VERDICT IS AGAINST THE LAST RECORDED TIME, NOT THE
+// BEST. Comparing against the session best only ever answers "purple or
+// red", which tells the driver nothing about whether they are improving
+// lap over lap. So kBetter/kWorse compare against lastSectorTime[] /
+// prevLapTime, and kBest overrides when the session best also fell.
+//
+// SECTORS ARE OPTIONAL. Lap tracking runs whenever the race has
+// started, including a Lap Anything (WaypointLapTimer) session with no
+// sector lines at all — the lap verdict has to work there too. Only the
+// sector half is gated on sectorsConfigured.
+//
+// Poll-and-diff, the house pattern (sprintLastRunCount). The sketch
+// builds a Sample from the activeTimer*() wrappers each LED frame.
+//
+// Pure logic — no Arduino headers — so it is exercised by host tests.
+///////////////////////////////////////////
+
+namespace sector_purple {
+
+// How a just-closed lap or sector compared. kNone = nothing to compare
+// against (first lap, first time through a sector, or a time we never
+// recorded), which renders as an unlit status LED rather than a guess.
+enum class Verdict : uint8_t {
+ kNone = 0,
+ kBetter, // strictly faster than the last recorded one
+ kWorse, // slower than, or identical to, the last recorded one
+ kBest, // beat the session best — overrides kBetter
+};
+
+struct Sample {
+ // activeTimerSectorsConfigured(): Lap Anything / WaypointLapTimer has
+ // no sectors — false suppresses the sector half; laps still track.
+ bool sectorsConfigured;
+ bool raceStarted;
+ // getCurrentSector(): 0 = race not started / between sprint runs,
+ // 1..3 = active sector.
+ int currentSector;
+ int laps; // completed laps — the S3 / lap close edge
+ uint32_t lastLapTime; // ms
+ uint32_t bestLapTime; // getBestLapTime(), ms, 0 = none
+ uint32_t lapSectorTime[3]; // getCurrentLapSector{1,2,3}Time(), ms
+ uint32_t bestSectorTime[3];// getBestSector{1,2,3}Time(), ms, 0 = none
+};
+
+// What closed on this step. Default-constructed = nothing happened.
+struct Event {
+ int purpleSector = 0; // 1..3 when that sector went session-purple
+ bool purpleLap = false; // the lap just completed is the session best
+ int closedSector = 0; // 1..3 sector that closed this step, else 0
+ Verdict sectorVerdict = Verdict::kNone; // for closedSector
+ bool lapClosed = false;
+ Verdict lapVerdict = Verdict::kNone; // for the lap just completed
+};
+
+struct State {
+ int lastSector; // -1 = uninitialized
+ int lastLaps;
+ uint32_t bestAtOpen[3]; // best time snapshotted when sector N opened
+ uint32_t s1; // this lap's closed sector times (0 = not yet)
+ uint32_t s2;
+ // The most recently COMPLETED time for each sector, and for a lap.
+ // Not strictly "last lap's": if a sector never closed (a GPS dropout
+ // skipped the transition) its entry keeps the older value rather than
+ // going blank, so one glitch doesn't dark the indicator for a lap.
+ uint32_t lastSectorTime[3];
+ uint32_t prevLapTime;
+ uint32_t bestLapAtOpen; // session-best lap as of this lap's start
+};
+
+void reset(State& s);
+
+// Feed one sample and get back whatever closed on this step. Equal
+// times never count as an improvement (strict <), so a repeated
+// identical time reads kWorse and never fires purple.
+Event update(State& s, const Sample& in);
+
+} // namespace sector_purple
diff --git a/BirdsEye/sensoregg.h b/BirdsEye/sensoregg.h
index f29810e..c46cc3b 100644
--- a/BirdsEye/sensoregg.h
+++ b/BirdsEye/sensoregg.h
@@ -58,23 +58,33 @@
// ---- lifecycle (called from BirdsEye.ino) ----
-// Bring up the BLE core (idempotent) and start the passive scanner.
+// Bring up the BLE core (idempotent) and CONFIGURE the passive scanner —
+// it is not started here. The scanner is race-gated (plan 0012):
+// SENSOREGG_LOOP()'s reconcile starts it when a race session begins and
+// stops it when the session ends, so the 44% scan duty is never paid on
+// the menu or during a BLE transfer (where it throttled downloads).
// Call from setup() after CAMERA_SETUP(). NOTE: this makes the SoftDevice
// come up at boot — previously BLE was lazy (first camera/transfer use).
void SENSOREGG_SETUP();
-// Drain the scan double-buffer and parse the newest payload. Call from
-// the normal main-loop path. Not called in the bleActive / usbMscActive
-// parking branches — a stale reading correctly goes NaN there.
+// Drain the scan double-buffer, parse the newest payload, and reconcile
+// the scanner against the race gate (start on race entry with a 1 s
+// retry throttle, stop when the session ends). Call from the normal
+// main-loop path. Not called in the bleActive / usbMscActive parking
+// branches — a stale reading correctly goes NaN there, and the scanner
+// is already down (race over) before either mode can be entered.
void SENSOREGG_LOOP();
-// Stop the passive scanner (shutdown path — the forever-scan must not run
-// into System OFF / the charging park). Idempotent; no-op when the POC is
-// compiled out.
+// Stop the passive scanner and hold it stopped (shutdown path — a scan
+// must not run into System OFF / the charging park — and BLE_SETUP(),
+// which guarantees a transfer session is scan-free). Idempotent; no-op
+// when the POC is compiled out.
void SENSOREGG_SLEEP();
-// Restart the scanner after a charging-loop soft resume
-// (softResumeFromCharging). The scanner config survives a stop.
+// Clear the sleep gate after a charging-loop soft resume
+// (softResumeFromCharging). Does not start the scanner — the resume
+// lands on the menu, and the race gate brings the scan up if a session
+// begins. The scanner config survives a stop.
void SENSOREGG_WAKE();
// ---- data surface (display_pages.ino / gps_functions.ino) ----
diff --git a/BirdsEye/sensoregg.ino b/BirdsEye/sensoregg.ino
index ccf2395..303156f 100644
--- a/BirdsEye/sensoregg.ino
+++ b/BirdsEye/sensoregg.ino
@@ -57,7 +57,7 @@ static bool eggHaveReading = false;
static sensoregg_protocol::SeqMonitor eggSeqMon; // zombie-egg detection
static bool eggScannerRunning = false;
-static bool eggSetupDone = false; // scanner configured — self-heal may (re)start it
+static bool eggSetupDone = false; // scanner configured — SENSOREGG_LOOP may (re)start it
// Sleep gate, read by the scan callback (BLE task): a report accepted just
// before SENSOREGG_SLEEP() has its rx callback deferred, and the callback's
// mandatory Scanner.resume() would restart the scan AFTER the stop (the
@@ -65,7 +65,26 @@ static bool eggSetupDone = false; // scanner configured — self-heal may (re)
// through the entire charging park. volatile: written on the main loop,
// read in BLE task context.
static volatile bool eggSleeping = false;
-static uint32_t eggLastKickMs = 0; // last scanner self-heal kick (main loop)
+static uint32_t eggLastKickMs = 0; // last scanner self-heal kick (main loop)
+static uint32_t eggLastStartTryMs = 0; // last start attempt (retry throttle)
+
+// Throttle for retrying a start() the SoftDevice refused, so a persistent
+// refusal costs one SVC per second instead of one per ~4 ms loop iteration.
+static constexpr uint32_t kEggStartRetryMs = 1000;
+
+// RACE-GATED SCANNER (plan 0012). The scanner's 40-of-every-90 ms radio
+// claim is only paid while a race session is running — the one time the EGT
+// feed is actually consumed (DOVEX rows + the Temp pages are race-only).
+// Outside race mode the radio belongs to whoever needs it clean: the
+// always-on scan was measured throttling BLE file downloads to ~33 KB/s by
+// denying the transfer link's connection-event extension. raceActive is the
+// BirdsEye.ino session flag, visible via the .ino concatenation (same as
+// Bluefruit above). Read from the scan callback (BLE task) too — a plain
+// bool read is atomic on this core, and a stale read only delays the
+// stop/start by one report (the main-loop reconcile owns the real state).
+static bool eggScanWanted() {
+ return raceActive && !eggSleeping;
+}
///////////////////////////////////////////
// SCAN CALLBACK (Bluefruit task context — copy bytes, resume, return)
@@ -116,11 +135,11 @@ static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report) {
// MANDATORY: without resume() the scanner halts after the first report
// — symptom is exactly one reading then permanent silence,
- // indistinguishable from a dead egg. Skipped while sleeping: this
- // deferred callback may run AFTER SENSOREGG_SLEEP()'s stop, and resume()
- // restarts the scan unconditionally — resurrecting the scanner the sleep
- // just killed.
- if (!eggSleeping) {
+ // indistinguishable from a dead egg. Skipped while the scan is not
+ // wanted (sleeping, or race over): this deferred callback may run AFTER
+ // a Scanner.stop(), and resume() restarts the scan unconditionally —
+ // resurrecting the scanner the stop just killed.
+ if (eggScanWanted()) {
Bluefruit.Scanner.resume();
}
}
@@ -152,17 +171,16 @@ void SENSOREGG_SETUP() {
// be magic-rejected in our callback, collapsing scan duty in bursts.
Bluefruit.Scanner.filterMSD(sensoregg_protocol::kCompanyId);
eggSetupDone = true;
- eggScannerRunning = Bluefruit.Scanner.start(0); // 0 = forever
-
- if (eggScannerRunning) {
- debugln(F("SensorEgg: passive scanner started"));
- } else {
- // Known unblock if the installed core refuses an observer without a
- // central slot: Bluefruit.begin(1, 1) in bleCoreEnsureInit() (costs
- // one unused central connection slot). Not taken by default — do not
- // change the shared begin() without re-soaking the camera link.
- debugln(F("SensorEgg: scanner FAILED to start (see begin(1,1) note)"));
- }
+
+ // Deliberately NOT started here (plan 0012). The scanner used to run
+ // forever from boot; SENSOREGG_LOOP()'s reconcile now starts it when a
+ // race session begins and stops it when the session ends, so the menu,
+ // replay and (critically) BLE transfer mode never pay the 44% radio
+ // duty. If the SoftDevice ever refuses the race-time start, the known
+ // unblock is Bluefruit.begin(1, 1) in bleCoreEnsureInit() (costs one
+ // unused central connection slot) — do not change the shared begin()
+ // without re-soaking the camera link.
+ debugln(F("SensorEgg: passive scanner armed (race-gated)"));
}
void SENSOREGG_SLEEP() {
@@ -182,12 +200,11 @@ void SENSOREGG_SLEEP() {
}
void SENSOREGG_WAKE() {
- // Charging-loop soft resume: the scanner config (callback, interval,
- // filters) survives a stop, so a bare start() restores reception.
+ // Charging-loop soft resume: just clear the sleep gate. The scanner is
+ // race-gated (plan 0012) and a charging resume lands on the menu, so
+ // there is nothing to start here — SENSOREGG_LOOP()'s reconcile brings
+ // the scan up if a race session begins (the config survives a stop).
eggSleeping = false;
- if (eggSetupDone && !eggScannerRunning) {
- eggScannerRunning = Bluefruit.Scanner.start(0); // 0 = forever
- }
}
void SENSOREGG_LOOP() {
@@ -209,19 +226,43 @@ void SENSOREGG_LOOP() {
}
}
+ // Race-gate reconcile (plan 0012): the scanner runs only while a race
+ // session is active. Runs every iteration so the scan comes up within
+ // one loop of race entry and goes down within one loop of the session
+ // ending. A start() the SoftDevice refuses (boot race, radio busy)
+ // leaves eggScannerRunning false and is retried on the 1 s throttle —
+ // this reconcile is now the start-retry path the self-heal below used
+ // to be. eggLastKickMs is stamped on every start so a fresh scan gets a
+ // full self-heal interval before its first kick.
+ if (eggSetupDone) {
+ const uint32_t now = millis();
+ if (eggScanWanted() && !eggScannerRunning) {
+ if (eggLastStartTryMs == 0 ||
+ (uint32_t)(now - eggLastStartTryMs) >= kEggStartRetryMs) {
+ eggLastStartTryMs = now;
+ eggLastKickMs = now;
+ eggScannerRunning = Bluefruit.Scanner.start(0); // 0 = forever
+ debugln(eggScannerRunning ? F("SensorEgg: scanner started (race)")
+ : F("SensorEgg: scanner start refused"));
+ }
+ } else if (!eggScanWanted() && eggScannerRunning) {
+ Bluefruit.Scanner.stop();
+ eggScannerRunning = false;
+ eggLastStartTryMs = 0;
+ debugln(F("SensorEgg: scanner stopped (race over)"));
+ }
+ }
+
// Scanner self-heal. The rx path only resumes scanning when our
// deferred callback actually runs; a dropped callback would halt the
// scanner silently and forever. If nothing has been accepted for
// kScannerSelfHealMs, kick stop+start — harmless when the egg is just
// off, curative when the scanner wedged. Throttled by its own stamp so
- // an absent egg costs one kick per interval, not one per loop.
- // Gated on eggSetupDone, NOT eggScannerRunning: a start() the SoftDevice
- // rejected (boot race, charging-loop resume) leaves the scanner down, and
- // this kick is the only retry path — gating on "running" made that state
- // permanent until reboot. (SENSOREGG_LOOP never runs after
- // SENSOREGG_SLEEP — shutdown parks or powers off — so the kick cannot
- // resurrect a deliberately stopped scanner.)
- if (eggSetupDone) {
+ // an absent egg costs one kick per interval, not one per loop. Gated on
+ // eggScannerRunning: with the scanner race-gated, "not running" outside
+ // a race is the intended state, and a refused race-time start is retried
+ // by the reconcile above — this kick only cures a wedge while running.
+ if (eggSetupDone && eggScannerRunning && eggScanWanted()) {
const uint32_t now = millis();
const uint32_t lastAlive = eggHaveReading ? eggRxMs : 0;
if ((uint32_t)(now - lastAlive) >= sensoregg_protocol::kScannerSelfHealMs &&
diff --git a/BirdsEye/setting_parse.cpp b/BirdsEye/setting_parse.cpp
new file mode 100644
index 0000000..ea15cea
--- /dev/null
+++ b/BirdsEye/setting_parse.cpp
@@ -0,0 +1,56 @@
+#include "setting_parse.h"
+
+#include
+
+namespace setting_parse {
+namespace {
+
+bool isSpace(char c) {
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\v' ||
+ c == '\f';
+}
+
+} // namespace
+
+bool parseIntSetting(const char* s, int* out) {
+ if (s == nullptr || out == nullptr) {
+ return false;
+ }
+ const char* p = s;
+ while (isSpace(*p)) {
+ p++;
+ }
+ bool negative = false;
+ if (*p == '+' || *p == '-') {
+ negative = (*p == '-');
+ p++;
+ }
+ if (*p < '0' || *p > '9') {
+ return false; // no digits at all — "" / " " / "abc" / "-" / "+"
+ }
+ // Accumulate in long long so overflow is detected rather than wrapped.
+ // A settings file is hand-editable, so "99999999999" must be rejected,
+ // not folded into some in-range value.
+ long long acc = 0;
+ while (*p >= '0' && *p <= '9') {
+ acc = acc * 10 + (*p - '0');
+ if (acc > 4294967296LL) {
+ return false; // far past any int; stop before acc itself overflows
+ }
+ p++;
+ }
+ while (isSpace(*p)) {
+ p++;
+ }
+ if (*p != '\0') {
+ return false; // trailing junk — "12abc", "1.5", "0x10"
+ }
+ const long long value = negative ? -acc : acc;
+ if (value < (long long)INT_MIN || value > (long long)INT_MAX) {
+ return false;
+ }
+ *out = (int)value;
+ return true;
+}
+
+} // namespace setting_parse
diff --git a/BirdsEye/setting_parse.h b/BirdsEye/setting_parse.h
new file mode 100644
index 0000000..7410159
--- /dev/null
+++ b/BirdsEye/setting_parse.h
@@ -0,0 +1,46 @@
+#pragma once
+
+///////////////////////////////////////////
+// SETTING VALUE PARSING (pure unit)
+//
+// Strict numeric parsing for /SETTINGS.json values, extracted because
+// atoi() is the wrong tool here and the wrongness is invisible.
+//
+// atoi("") and atoi("garbage") both answer 0, with no way to tell either
+// from a real "0". Every numeric setting in BirdsEye.ino is read as
+//
+// if (getSetting(key, buf, n)) { const int v = atoi(buf);
+// if (inRange(v)) applyIt(v); }
+//
+// which is safe only while 0 is OUT of the accepted range — true for
+// target_rpm (floor 1000) and temp1_alert_c (floor 50), and FALSE for
+// every setting plan 0006/0010 added. For led_brightness, 0 is a real and
+// destructive value: it disables the LEDs and the 5 V boost rail never
+// comes up. So a blank or corrupt led_brightness did not "clamp back to
+// the compiled-in default per the house idiom" as its comment claims — it
+// silently killed the strip, and looked exactly like dead hardware.
+// Same shape for led_brightness_night (0 = blank strip) and the two
+// day/night hours (0 = midnight, a legitimate value).
+//
+// parseIntSetting() answers false for anything that is not a complete
+// integer, so the caller's existing range check keeps the compiled-in
+// default — which is what those comments always meant.
+///////////////////////////////////////////
+
+namespace setting_parse {
+
+/**
+ * @brief Parse a settings value as a complete decimal integer.
+ *
+ * Accepts optional leading whitespace and an optional +/- sign, then one
+ * or more digits, then optional trailing whitespace, and nothing else.
+ * Rejects "" / " " / "abc" / "12abc" / "1.5" / "0x10" — anything atoi()
+ * would quietly turn into a number.
+ *
+ * @param s NUL-terminated value (may be nullptr).
+ * @param out Receives the value only when this returns true.
+ * @return true if the whole string is an integer that fits an int.
+ */
+bool parseIntSetting(const char* s, int* out);
+
+} // namespace setting_parse
diff --git a/BirdsEye/settings.h b/BirdsEye/settings.h
index 4282bed..f40519a 100644
--- a/BirdsEye/settings.h
+++ b/BirdsEye/settings.h
@@ -9,6 +9,37 @@
#include
+///////////////////////////////////////////
+// SETTINGS JSON CAPACITY — the single source of truth.
+//
+// Both the read buffer and the ArduinoJson document that parse
+// /SETTINGS.json must be this size, in EVERY module that parses the file.
+// There are two such parsers: settings.ino (getSetting/setSetting) and
+// bluetooth.ino's `SLIST` handler, which enumerates the file for the
+// companion app.
+//
+// This constant exists because those two drifted. Plan 0010 raised
+// settings.ino's pair 512 -> 1024 when nine new keys took the default file
+// from 329 to 538 bytes; bluetooth.ino's copy stayed at 512, so `SLIST`
+// read 511 bytes of a 538-byte file, deserializeJson() returned
+// IncompleteInput, and the handler answered SERR:PARSE on every device
+// instead of listing a single key. Nothing coupled the two numbers, so
+// nothing caught it. Now they cannot drift.
+//
+// TWO separate walls, both sized by this: the READ CAP (a file longer than
+// capacity-1 parses as IncompleteInput and every key read fails) and the
+// DOCUMENT CAPACITY (24 string pairs need JSON_OBJECT_SIZE(24); a <512>
+// document returns NoMemory at 22 pairs regardless of how much was read).
+//
+// Adding settings keys is not free — see the measureJson() guard in
+// setSettingInner(), and subsystem 8 in CLAUDE.md. As of plan 0013 the
+// default file measures 570 bytes on a stock build (23 keys) and 592 on
+// a SensorEgg one (24), rising to 599/621 with the longest values every
+// key accepts, against a 1023-byte read cap. That is roughly sixteen
+// average keys of headroom. Each key costs len(key) + len(value) + 6.
+///////////////////////////////////////////
+#define SETTINGS_JSON_CAPACITY 1024
+
// Initialize the settings file (creates defaults on first boot,
// adds any missing keys on upgrade). Call once from setup() AFTER
// SD_SETUP().
@@ -21,6 +52,15 @@ bool getSetting(const char* key, char* buf, size_t bufSize);
// Read-modify-write a single setting. Returns true on success.
bool setSetting(const char* key, const char* value);
+// Delete a single key. True when the key is gone afterwards, INCLUDING
+// when it was already absent — the caller's post-condition is "not
+// there", not "I removed something". False on any SD/parse failure, so a
+// migration can hold off dropping an old key until the new one is
+// safely written. Deliberately does NOT heal a corrupt file the way
+// setSetting() does: dropping a key is never urgent enough to justify
+// quarantining the user's whole settings file behind their back.
+bool removeSetting(const char* key);
+
// Delete the settings file and re-create with fresh defaults
// (rolls a new random BLE name and PIN).
bool resetSettings();
diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino
index a57ad34..841ebf5 100644
--- a/BirdsEye/settings.ino
+++ b/BirdsEye/settings.ino
@@ -12,8 +12,19 @@ static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json";
// deleted) so its contents can be inspected/salvaged on a computer; a
// pre-existing .bad from an earlier quarantine is overwritten.
static const char SETTINGS_BAD_PATH[] = "/SETTINGS.json.bad";
-static char settingsFileBuffer[512];
-static StaticJsonDocument<512> settingsJson;
+// Sized for the whole settings file plus its NUL, with real headroom:
+// every read path below caps at sizeof(settingsFileBuffer) - 1, so a
+// file larger than this parses as IncompleteInput and EVERY key read
+// fails. That failure is self-inflicting — SETTINGS_SETUP quarantines
+// the "corrupt" file and regenerates it, ensureDefaultSettings grows it
+// back over the cap, and the device loses its settings on a loop.
+// Raised 512 -> 1024 in plan 0010: the 18-key file was already 436 B and
+// the four new keys put it at 543. The document is the other wall — 22
+// string pairs need JSON_OBJECT_SIZE(22) and a <512> doc returns
+// NoMemory. Keep the two numbers equal, and see the measureJson() guard
+// in setSettingInner() before adding more keys.
+static char settingsFileBuffer[SETTINGS_JSON_CAPACITY];
+static StaticJsonDocument settingsJson;
// 12 racing-adjacent words used to build a friendly default device name so
// logs dumped from a fleet of devices stay distinguishable. The literals
@@ -103,6 +114,26 @@ bool createDefaultSettings() {
* Called when settings file already exists to handle firmware upgrades that add new keys.
*/
static void ensureDefaultSettings() {
+ // Plan 0013 renamed `rev_limit` to `target_rpm`. Migrate BEFORE the
+ // defaults table runs: the table only writes a key that is missing, so
+ // once target_rpm exists it is left alone — but if we let the table go
+ // first it would stamp the 15000 default over a user's tuned 7550 and
+ // the old value would be gone. The old key is only dropped once the
+ // new one is confirmed written, so a failed or refused write leaves the
+ // device exactly as it was rather than silently resetting its shift
+ // point.
+ {
+ char legacyBuf[48];
+ if (!getSetting("target_rpm", legacyBuf, sizeof(legacyBuf)) &&
+ getSetting("rev_limit", legacyBuf, sizeof(legacyBuf))) {
+ debug(F("Settings: migrating rev_limit -> target_rpm = "));
+ debugln(legacyBuf);
+ if (setSetting("target_rpm", legacyBuf)) {
+ removeSetting("rev_limit");
+ }
+ }
+ }
+
// Table of all expected settings with their defaults
static const struct { const char* key; const char* defaultValue; } defaults[] = {
{ "driver_name", "Driver" },
@@ -111,11 +142,20 @@ static void ensureDefaultSettings() {
{ "waypoint_speed", "30" },
{ "camera_serial", "" }, // empty = no Insta360 paired
{ "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range
- // Engine geometry for true RPM (plan 0003). These defaults are exactly the
- // old hardcoded behaviour, so auto-populating them on an existing device
- // changes nothing until the user says otherwise.
+ // Engine geometry for true RPM (plans 0003, 0014). spark_mode is the ONLY
+ // one of these two that reaches the RPM math — one clamp on one plug wire
+ // sees one cylinder's ignition regardless of what the engine has.
{ "spark_mode", "wasted" }, // "wasted" = 1 spark/rev (2T or 4T wasted); "single" = 1 per 2 revs
- { "cylinder_count", "1" }, // cylinders the PICKUP SEES, not the engine's
+ // The engine's ACTUAL cylinder count — no pickup-placement mind game, and
+ // no longer a divider (plan 0014). Descriptive: >1 means crank speed is
+ // inferred from one cylinder's firing rate, which is what the settings UI
+ // warns about.
+ { "cylinder_count", "1" },
+ // RPM estimator (plan 0009). "smooth" = outlier gate + RPM-aware noise
+ // model; "legacy" = the pre-0009 filter for A/B against older logs;
+ // "raw" = no filtering at all, so a track session shows exactly what the
+ // pickup delivers. Anything else reads as "smooth".
+ { "tach_filter", "smooth" },
// Panel colours. "normal" is lit-on-black, exactly as shipped; "inverted"
// swaps lit and unlit pixels for glare/daylight readability.
{ "display_invert", "normal" },
@@ -123,6 +163,50 @@ static void ensureDefaultSettings() {
// starts the rotation at the speed page — end users never see the
// diagnostic counters; "show" restores them for development/tuning.
{ "debug_pages", "hide" },
+ // NeoPixel strip (plan 0006). Read on every channel since 4.1.0 —
+ // BIRDSEYE_ENABLE_NEOPIXEL defaults to 1, so these are live
+ // settings on a stock logger, not beta-only bookkeeping.
+ { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled
+ // The SHIFT/warning point, not a limiter (plan 0013 renamed it from
+ // "rev_limit", which taught every new user the wrong thing —
+ // overrev_limit below is the actual problem limit). LED scale
+ // ceiling + the `rpm` status-LED flasher.
+ { "target_rpm", "15000" },
+ // Plan 0007: the PROBLEM limit (0 = disabled) — whole LED chain
+ // flashes red past it, and the tach page's OVER REV header.
+ { "overrev_limit", "0" },
+#if BIRDSEYE_ENABLE_SENSOREGG
+ // Temp1 alert threshold in Celsius. SensorEgg builds only (plan
+ // 0013): with the POC compiled out there is no probe to have a
+ // threshold for, and the key would just spend settings-file bytes
+ // on every stock device.
+ { "temp1_alert_c", "650" },
+#endif
+ // Plan 0013: what each status LED shows, and the speed the 9-px bar
+ // scales against on a session with no tachometer. Mode tokens are
+ // led_status::modeName(): off / rpm / speed / gps / camera / lap /
+ // sector / egt. `egt` renders dark on a build without SensorEgg
+ // support — the setting still round-trips to the companion app.
+ { "led_status_left", "rpm" },
+#if BIRDSEYE_ENABLE_SENSOREGG
+ { "led_status_right", "egt" },
+#else
+ { "led_status_right", "lap" },
+#endif
+ { "target_speed_mph", "60" }, // mph on device; the app converts
+ // Plan 0010: minutes east of UTC (US Central standard = -360, India
+ // = 330). PRESENTATION ONLY — logged timestamps stay UTC. The only
+ // consumer today is the LED day/night brightness swap below, which
+ // needs "7am" to mean the driver's 7am. NO DST: a fixed offset walks
+ // an hour twice a year, which is noise for a dim-after-dark gate.
+ { "utc_offset_min", "0" },
+ // Night brightness cap and the LOCAL wall-clock hours the swap
+ // happens on. Equal hours disable the swap (led_brightness applies
+ // around the clock). 0 = strip dark at night, but the 5 V rail
+ // stays up — only led_brightness 0 cuts the rail.
+ { "led_brightness_night", "16" },
+ { "led_day_start_hour", "7" },
+ { "led_night_start_hour", "19" },
};
char buf[48];
@@ -357,6 +441,28 @@ static bool setSettingInner(const char* key, const char* value, bool healCorrupt
// Update the key
settingsJson[key] = value;
+ // Refuse a write that could not be read back afterwards. Two ways it
+ // could quietly destroy the whole file:
+ // overflowed() - the document ran out of slots, so the assignment
+ // above silently did nothing and serializing now would drop the
+ // key. (The flag is sticky, but settingsJson.clear() above resets
+ // it, so here it can only mean THIS parse+assign.)
+ // measureJson() - the result exceeds settingsFileBuffer. Every read
+ // path caps at sizeof(settingsFileBuffer) - 1, so such a file
+ // parses as IncompleteInput and destroys ALL settings, not just
+ // this key — and the boot-time corrupt-file heal then loops
+ // forever. measureJson() needs no scratch buffer, which matters
+ // here: settingsFileBuffer is off limits because the zero-copy
+ // parse above left the document pointing INTO it.
+ // Refusing one write leaves the existing file intact and readable.
+ if (settingsJson.overflowed() ||
+ measureJson(settingsJson) > sizeof(settingsFileBuffer) - 1) {
+ debug(F("Settings: REFUSING write, would not be readable back: "));
+ debugln(key);
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return false;
+ }
+
// Write back (truncate and rewrite)
settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC);
if (!settingsFile) {
@@ -380,3 +486,57 @@ static bool setSettingInner(const char* key, const char* value, bool healCorrupt
bool setSetting(const char* key, const char* value) {
return setSettingInner(key, value, /*healCorrupt=*/true);
}
+
+bool removeSetting(const char* key) {
+ if (!sdSetupSuccess) return false;
+
+ if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) {
+ debugln(F("Settings: Cannot acquire SD for remove"));
+ return false;
+ }
+
+ settingsJson.clear();
+
+ File settingsFile;
+ settingsFile.open(SETTINGS_FILE_PATH, O_READ);
+ if (!settingsFile) {
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return false; // nothing to remove from
+ }
+ int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1);
+ settingsFile.close();
+ if (bytesRead <= 0) {
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return false;
+ }
+ settingsFileBuffer[bytesRead] = '\0';
+ DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer);
+ if (err != DeserializationError::Ok) {
+ debug(F("Settings: Parse error on remove: "));
+ debugln(err.c_str());
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return false; // deliberately NOT healed here — see the header note
+ }
+
+ if (!settingsJson.containsKey(key)) {
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return true; // already gone: the caller's post-condition holds
+ }
+ settingsJson.remove(key);
+
+ // No size guard needed: a removal can only shrink the file, so it can
+ // never cross the read cap that setSettingInner() has to defend.
+ settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC);
+ if (!settingsFile) {
+ debugln(F("Settings: Failed to open file for remove"));
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+ return false;
+ }
+ serializeJson(settingsJson, settingsFile);
+ settingsFile.close();
+ releaseSDAccess(SD_ACCESS_TRACK_PARSE);
+
+ debug(F("Settings: Removed "));
+ debugln(key);
+ return true;
+}
diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt
index 3855603..648fcea 100644
--- a/BirdsEye/sim/CMakeLists.txt
+++ b/BirdsEye/sim/CMakeLists.txt
@@ -148,9 +148,17 @@ set(SIM_CORE_SOURCES
${BIRDSEYE_DIR}/haversine.cpp
${BIRDSEYE_DIR}/idle_policy.cpp
${BIRDSEYE_DIR}/lap_format.cpp
+ ${BIRDSEYE_DIR}/local_time.cpp
+ ${BIRDSEYE_DIR}/loop_profile.cpp
+ ${BIRDSEYE_DIR}/setting_parse.cpp
+ ${BIRDSEYE_DIR}/led_animations.cpp
+ ${BIRDSEYE_DIR}/led_frame.cpp
+ ${BIRDSEYE_DIR}/led_modes.cpp
+ ${BIRDSEYE_DIR}/led_status.cpp
${BIRDSEYE_DIR}/sat_bars.cpp
${BIRDSEYE_DIR}/sd_access_policy.cpp
${BIRDSEYE_DIR}/sd_format_page.cpp
+ ${BIRDSEYE_DIR}/sector_purple.cpp
${BIRDSEYE_DIR}/sensoregg_protocol.cpp
${BIRDSEYE_DIR}/sprint_select.cpp
${BIRDSEYE_DIR}/tach_filter.cpp
diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt
index aafdb2b..f65b4aa 100644
--- a/BirdsEye/sim/golden/golden_hashes.txt
+++ b/BirdsEye/sim/golden/golden_hashes.txt
@@ -1,11 +1,11 @@
gps_status_no_fix 900 f2276ffc
main_menu_race -1 34b50cd7
main_menu_transfer -1 40da09f8
-transfer_menu -4 3013bbe2
+transfer_menu -4 ae15b2a8
bluetooth_waiting -2 c0f9211b
warning_no_dovex 100 f800764b
warning_create_needs_gps 100 45982329
-course_type_select -12 35882070
+course_type_select -12 8124d214
course_lines_circuit_empty -13 0a3d740a
course_line_detail -14 5ff1d3f5
course_point_idle -15 28961e61
diff --git a/BirdsEye/sim/sim_main.cpp b/BirdsEye/sim/sim_main.cpp
index cc9f9c3..26ec7c5 100644
--- a/BirdsEye/sim/sim_main.cpp
+++ b/BirdsEye/sim/sim_main.cpp
@@ -6,9 +6,11 @@
// translation unit, with sim_prototypes.h standing in for the IDE's
// auto-generated function prototypes.
//
-// bluetooth.ino, camera_ble.ino, firmware_ota.ino and usb_msc.ino are
-// DELIBERATELY ABSENT — no BLE / camera / USB-host in the demo scope.
-// Their public surfaces are satisfied by stubs/module_stubs.cpp.
+// bluetooth.ino, camera_ble.ino, firmware_ota.ino, usb_msc.ino,
+// neopixel.ino and profiling.ino are DELIBERATELY ABSENT — no BLE /
+// camera / USB-host / LED strip in the demo scope, and no pin to profile
+// against a virtual clock. Their public surfaces are satisfied by
+// stubs/module_stubs.cpp.
//
// The firmware sources compile UNMODIFIED (ground rule): everything
// sim-specific lives behind the SIM flag inside them, or out here.
diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h
index 333a6e1..dc2c4e3 100644
--- a/BirdsEye/sim/sim_prototypes.h
+++ b/BirdsEye/sim/sim_prototypes.h
@@ -55,6 +55,10 @@ float activeTimerPaceDifference();
float activeTimerTotalDistance();
unsigned long activeTimerOptimalLapTime();
bool activeTimerSectorsConfigured();
+int activeTimerCurrentSector();
+unsigned long activeTimerLapSectorTime(int sector);
+unsigned long activeTimerBestSectorTime(int sector);
+bool raceEngineStopped();
SprintTimer* getActiveTimerSprint();
bool sprintModeIsActive();
bool activeTimerRunActive();
@@ -181,6 +185,12 @@ void makeFullTrackPath(const char* trackName, char* filepath, uint8_t kind);
bool scanTrackDir(const char* folder, uint8_t kind);
bool sdSetSpiClock(uint32_t maxSck);
void sdSetTransferSpeed(bool fast);
+uint32_t sdActiveSpiHz();
+uint32_t bleTransferRateBps();
+uint16_t bleLinkDataLength();
+uint16_t bleLinkChunkSize();
+uint16_t bleLinkIntervalUnits();
+uint8_t bleLinkPhy();
bool SD_SETUP();
bool sdEnsureTracksFolder();
void sdPerformFormat();
diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp
index 8a7c623..96ea16d 100644
--- a/BirdsEye/sim/stubs/module_stubs.cpp
+++ b/BirdsEye/sim/stubs/module_stubs.cpp
@@ -1,10 +1,10 @@
///////////////////////////////////////////
// Stubs for the firmware modules deliberately excluded from the sim TU:
// bluetooth.ino, camera_ble.ino, usb_msc.ino, firmware_ota.ino,
-// sensoregg.ino.
+// sensoregg.ino, neopixel.ino, profiling.ino.
//
-// The sim has no BLE, no camera, no USB host (demo scope — see the
-// handoff spec). These implement the excluded modules' public headers
+// The sim has no BLE, no camera, no USB host, no LED strip (demo scope
+// — see the handoff spec). These implement the excluded modules' public headers
// so the compiled modules (menus, main loop) link and behave sanely:
// every query reports "not present / not active", every action succeeds
// silently, and nothing prints warnings into the UX.
@@ -16,6 +16,8 @@
#include "bluetooth.h"
#include "camera_ble.h"
+#include "neopixel.h"
+#include "profiling.h"
#include "sensoregg.h"
#include "usb_msc.h"
@@ -41,11 +43,27 @@ void BLE_STOP() {
bleConnected = false;
}
+// On hardware this reboots and never returns; the sim just stops the stub
+// radio and returns so the Bluetooth page's Exit continues to the menu
+// (the golden walk exits this page mid-script).
+void bleExitTransferMode() {
+ BLE_STOP();
+}
+
void bleConnLedOff() {}
void bleShutdownQuiesce() {}
void BLUETOOTH_LOOP() {}
+// Transfer diagnostics the Bluetooth page prints. No radio in the sim, so
+// the page renders its idle "waiting" state and these are never meaningful —
+// they exist so display_pages.ino compiles unchanged.
+uint32_t bleTransferRateBps() { return 0; }
+uint16_t bleLinkDataLength() { return 27; }
+uint16_t bleLinkChunkSize() { return 20; }
+uint16_t bleLinkIntervalUnits() { return 0; }
+uint8_t bleLinkPhy() { return 0; }
+
// ---- camera_ble.ino surface ----
void CAMERA_SETUP() {}
@@ -105,6 +123,30 @@ uint8_t sensoreggBatteryPct() { return 0xFF; }
bool sensoreggTcFault() { return false; }
uint16_t sensoreggSequence() { return 0; }
+// ---- neopixel.ino surface ----
+
+// No LED strip in the sim (and no UICR to program). The pure units
+// (led_frame/led_modes/led_animations/sector_purple) still build into
+// the sim via SIM_CORE_SOURCES for any future host harness use.
+
+void NEOPIXEL_SETUP() {}
+void NEOPIXEL_LOOP() {}
+void NEOPIXEL_SLEEP() {}
+void NEOPIXEL_WAKE() {}
+void neopixelNotifyPurpleSector() {}
+void neopixelNotifyPurpleLap() {}
+
+// ---- profiling.ino surface ----
+
+// BIRDSEYE_ENABLE_PROFILING is a beta-firmware flag and is never set for
+// the sim: there is no pin to toggle and host wall-clock timings of a
+// virtual-clock loop would mean nothing. These are the flag-off stubs
+// the real profiling.ino would provide; the pure loop_profile unit still
+// builds into the sim via SIM_CORE_SOURCES.
+
+void PROFILING_SETUP() {}
+void PROFILING_SLEEP() {}
+
// ---- usb_msc.ino surface ----
bool usbMscActive = false;
diff --git a/BirdsEye/sim/wasm/test.html b/BirdsEye/sim/wasm/test.html
index 7b04858..abb9245 100644
--- a/BirdsEye/sim/wasm/test.html
+++ b/BirdsEye/sim/wasm/test.html
@@ -9,6 +9,13 @@
real .dovex file: rows are injected at 25 Hz virtual time with the RPM
column driving the tach, exactly like the viewer's playback engine
will in Phase 5.
+
+ The "GPS fix" toggle injects a synthetic stationary fix (parked on the
+ preloaded OKC track's start line, deterministic timestamps) so the
+ flows that refuse to run without a fix + time lock are reachable —
+ most usefully the on-device course creator (main menu -> Create), whose
+ 3 s point-averaging hold needs a live 25 Hz fix stream. The mph field
+ sets the injected speed (10+ trips auto-race from a settled menu).
-->
@@ -36,6 +43,9 @@
BirdsEye simulator — Phase 4 harness
+
+
loading…
@@ -86,7 +96,9 @@
BirdsEye simulator — Phase 4 harness
if (e.key === 'ArrowRight') sim.buttonUp(2);
});
-// Optional dovex playback (Phase-5 semantics, minimally).
+// Optional dovex playback (Phase-5 semantics, minimally). Rows are >= 13
+// fields, not exactly 13: 4.0.0 logs append Temp1/Junction1/Temp2 columns
+// (16 fields), and the playback only reads the stable first 13.
let rows = null, rowIdx = 0, playClockMs = 0;
document.getElementById('dovex').addEventListener('change', async (e) => {
const file = e.target.files[0];
@@ -94,14 +106,34 @@
BirdsEye simulator — Phase 4 harness
const text = new TextDecoder().decode(
(await file.arrayBuffer()).slice(1024));
rows = text.split('\n').slice(1).map((l) => l.split(',')).filter(
- (f) => f.length === 13);
+ (f) => f.length >= 13);
rowIdx = 0;
+ document.getElementById('fix').checked = false; // dovex owns the GPS feed
await sim.reset();
sim.init();
playClockMs = Number(rows[0][0]) - 3000; // ~3 s no-fix pre-roll
console.log(`dovex loaded: ${rows.length} rows`);
});
+// Synthetic fix stream for the fix toggle: parked on the preloaded OKC
+// track's start/finish line (the sdfat_shim asset), so track detection
+// fires and the course creator's "Here" prompt has something to offer.
+// Timestamps run from a fixed epoch on the virtual clock — deterministic,
+// and the creator stamps N{YYMMDD}_{HHMM} names from it.
+const kOkcStartLat = 28.41271928, kOkcStartLon = -81.37965158;
+const kSynthEpochMs = 1785076320000; // 2026-08-03T14:32Z
+let synthClockMs = 0;
+
+function injectSynthFix() {
+ sim.injectPvt(JSON.stringify({
+ timestamp: kSynthEpochMs + synthClockMs, sats: 12, hdop: 0.8,
+ lat: kOkcStartLat, lng: kOkcStartLon,
+ speed_mph: Number(document.getElementById('mph').value) || 0,
+ altitude_m: 25, heading_deg: 0, h_acc_m: 1.2, fix: true,
+ accelX: 0, accelY: 0, accelZ: 1,
+ }));
+}
+
let prev = performance.now();
function frame(now) {
const speed = Number(document.getElementById('speed').value);
@@ -122,9 +154,23 @@
BirdsEye simulator — Phase 4 harness
}));
sim.setRpm(Number(f[9]));
}
+ sim.stepMillis(delta);
+ } else if (document.getElementById('fix').checked) {
+ // Synthetic fix: one PVT per <=40 ms step slice — the documented
+ // injection rate (25 Hz). A burst of injects before one big step
+ // would collapse into a single fix and starve the course creator's
+ // averaging hold (it needs >=8 fixes across its 3 s window).
+ let remaining = delta;
+ while (remaining > 0) {
+ const slice = Math.min(remaining, 40);
+ remaining -= slice;
+ synthClockMs += slice;
+ injectSynthFix();
+ sim.stepMillis(slice);
+ }
+ } else {
+ sim.stepMillis(delta);
}
-
- sim.stepMillis(delta);
blit();
const st = sim.getStateJson();
diff --git a/BirdsEye/tach_filter.cpp b/BirdsEye/tach_filter.cpp
index 0a7a4dc..0c5c5da 100644
--- a/BirdsEye/tach_filter.cpp
+++ b/BirdsEye/tach_filter.cpp
@@ -1,20 +1,148 @@
#include "tach_filter.h"
+#include
+
namespace tach_filter {
+namespace {
+
+// Case-insensitive compare over ASCII. Hand-rolled rather than pulling in
+// strcasecmp, which is a POSIX extension the host and the AVR-flavoured
+// embedded toolchains disagree about — this unit stays dependency-free.
+bool equalsIgnoreCase(const char* a, const char* b) {
+ if (a == nullptr || b == nullptr) return false;
+ for (; *a != '\0' && *b != '\0'; ++a, ++b) {
+ char ca = *a, cb = *b;
+ if (ca >= 'A' && ca <= 'Z') ca = (char)(ca - 'A' + 'a');
+ if (cb >= 'A' && cb <= 'Z') cb = (char)(cb - 'A' + 'a');
+ if (ca != cb) return false;
+ }
+ return *a == *b;
+}
+
+} // namespace
+
+Mode modeFromSetting(const char* value) {
+ if (equalsIgnoreCase(value, "raw")) return Mode::kRaw;
+ if (equalsIgnoreCase(value, "legacy")) return Mode::kLegacy;
+ return Mode::kSmooth; // blank, unknown, or a future token
+}
+
+const char* modeName(Mode m) {
+ switch (m) {
+ case Mode::kRaw: return "raw";
+ case Mode::kLegacy: return "legacy";
+ default: return "smooth";
+ }
+}
+
+char modeTag(Mode m) {
+ switch (m) {
+ case Mode::kRaw: return 'R';
+ case Mode::kLegacy: return 'L';
+ default: return 'S';
+ }
+}
+
void reset(Kalman& k) {
k.x = 0.0f;
k.p = kInitialUncertaintyP;
+ k.updates = 0;
+ k.strikes = 0;
+ // k.rejected survives on purpose — see the header.
}
-void update(Kalman& k, float rpmMeasured, int periodCount) {
- if (periodCount <= 0) return;
+float processNoise(Mode m, float dtSeconds) {
+ if (m == Mode::kLegacy) return kProcessNoiseQ;
- // Predict step: constant-RPM model, uncertainty grows
- k.p += kProcessNoiseQ;
+ float dt = dtSeconds;
+ if (!(dt > 0.0f)) dt = 0.0f; // also catches NaN
+ if (dt > kMaxPredictSeconds) dt = kMaxPredictSeconds;
+
+ const float q = kProcessNoiseRate * dt;
+ return q < kProcessNoiseFloorQ ? kProcessNoiseFloorQ : q;
+}
+
+float measurementNoise(Mode m, float rpmMeasured, float revsPerPulse,
+ int periodCount) {
+ const float n = (float)(periodCount > 0 ? periodCount : 1);
+ if (m == Mode::kLegacy) return kMeasurementNoiseRBase / n;
+
+ // A period measurement carries two errors, and BOTH grow with RPM:
+ // * fixed timing jitter on a period of K/rpm microseconds, so the RPM
+ // error is sigma_T * rpm^2 / K — quadratic, and the reason a flat R
+ // under-reports the noise at the top of the rev range;
+ // * real crank speed variation between firings, a fraction of the
+ // period, so frac * rpm — linear.
+ // They are added as standard deviations rather than variances: the
+ // conservative direction, and the gate wants to be loose, not tight.
+ const float rpm = rpmMeasured > 0.0f ? rpmMeasured : 0.0f;
+ const float rpp = revsPerPulse > 0.0f ? revsPerPulse : 1.0f;
+ const float sigma = kTimingJitterUs * rpm * rpm / (60.0e6f * rpp) +
+ kCycleVariationFrac * rpm;
+ return (kMeasurementNoiseRBase + sigma * sigma) / n;
+}
+
+bool update(Kalman& k, Mode m, float rpmMeasured, int periodCount,
+ float dtSeconds, float revsPerPulse) {
+ if (periodCount <= 0) return false;
+
+ if (m == Mode::kRaw) {
+ // No estimator at all: publish what the periods say, spikes included.
+ // The uncertainty is parked high so the very next filtered update
+ // (after a settings change and reboot) re-acquires immediately.
+ k.x = rpmMeasured;
+ k.p = kInitialUncertaintyP;
+ k.strikes = 0;
+ if (k.updates < 0xFFFFu) k.updates++;
+ return true;
+ }
+
+ if (m == Mode::kSmooth && k.updates == 0) {
+ // Nothing is known yet: after an engine-stop reset the estimate is
+ // literally 0 RPM. Filtering toward the first measurement through an
+ // RPM-aware R takes several pulses to arrive anywhere true — and the
+ // gate arms partway up that climb and starts rejecting the engine's
+ // real speed as an outlier. With no prior, the first measurement IS
+ // the estimate. (kLegacy deliberately does not do this: its whole job
+ // is to reproduce the shipped filter, whose flat R made the first
+ // update's gain 0.81 and made the climb short enough not to matter.)
+ k.x = rpmMeasured;
+ k.p = kInitialUncertaintyP;
+ k.updates = 1;
+ return true;
+ }
+
+ const float q = processNoise(m, dtSeconds);
+ const float r = measurementNoise(m, rpmMeasured, revsPerPulse, periodCount);
+
+ // ---- Outlier gate ----
+ // Only kSmooth gates, and only once the estimate means something: out
+ // of reset it is 0 RPM, and gating against that would reject the engine
+ // starting.
+ if (m == Mode::kSmooth && k.updates >= kGatePrimeUpdates) {
+ const float sigma = sqrtf(k.p + q + r);
+ if (fabsf(rpmMeasured - k.x) > kGateSigmas * sigma) {
+ if (k.rejected < 0xFFFFu) k.rejected++;
+ if (k.strikes < 0xFFu) k.strikes++;
+ if (k.strikes < kGateMaxRejects) {
+ return false; // one bad edge is not a new engine speed — coast
+ }
+ // Three in a row is not three coincident bad edges: the estimate is
+ // stale. ADOPT the measurement rather than filtering toward it —
+ // filtering toward it with the huge R that high RPM implies would
+ // take the better part of a second to catch a clutch dump.
+ k.x = rpmMeasured;
+ k.p = kInitialUncertaintyP;
+ k.strikes = 0;
+ if (k.updates < 0xFFFFu) k.updates++;
+ return true;
+ }
+ }
+ k.strikes = 0;
- // Measurement noise scales inversely with number of periods
- const float r = kMeasurementNoiseRBase / (float)periodCount;
+ // Predict step: constant-RPM model, uncertainty grows
+ k.p += q;
// Update step
const float gain = k.p / (k.p + r);
@@ -23,6 +151,9 @@ void update(Kalman& k, float rpmMeasured, int periodCount) {
// Uncertainty floor to prevent numerical collapse
if (k.p < kUncertaintyFloorP) k.p = kUncertaintyFloorP;
+
+ if (k.updates < 0xFFFFu) k.updates++;
+ return true;
}
float rpmFromMeanPeriodUs(float meanPeriodUs, float revsPerPulse) {
@@ -30,40 +161,26 @@ float rpmFromMeanPeriodUs(float meanPeriodUs, float revsPerPulse) {
return (60.0e6f * revsPerPulse) / meanPeriodUs;
}
-namespace {
-
-// Shared by both public helpers so they can never disagree about the
-// geometry — a mismatch would scale RPM by one factor and the debounce by
-// another.
-int clampCylinders(int cylinderCount) {
+int clampCylinderCount(int cylinderCount) {
if (cylinderCount < kMinCylinders) return kMinCylinders;
if (cylinderCount > kMaxCylinders) return kMaxCylinders;
return cylinderCount;
}
-} // namespace
+bool rpmIsInferred(int cylinderCount) {
+ return clampCylinderCount(cylinderCount) > 1;
+}
-float revsPerPulse(int cylinderCount, bool wastedSpark) {
- const float pulsesPerRev = (float)clampCylinders(cylinderCount) *
- (wastedSpark ? kPulsesPerRevWasted : kPulsesPerRevSingle);
- return 1.0f / pulsesPerRev;
+float revsPerPulse(bool wastedSpark) {
+ return wastedSpark ? kRevsPerPulseWasted : kRevsPerPulseSingle;
}
-uint32_t minPulseGapUs(int cylinderCount, bool wastedSpark) {
- const uint32_t cyl = (uint32_t)clampCylinders(cylinderCount);
-
- // Integer division, deliberately: pulses-per-rev is either `cyl` (wasted
- // spark) or `cyl / 2` (single-fire), so dividing the base gap by it is
- // exact in both cases once the single-fire case is expressed as a
- // doubled numerator. Keeps the float rounding — and the rounding-cast
- // bug class that goes with it — out of a value the ISR compares against.
- const uint32_t gap = wastedSpark ? (kBasePulseGapUs / cyl)
- : ((kBasePulseGapUs * 2u) / cyl);
-
- // Fewer pulses per rev (4-stroke single-fire) widens the gap, which is
- // free: there are genuinely fewer edges to catch, so the extra margin
- // only buys more ringing rejection at the same true-RPM ceiling.
- return gap < kMinPulseGapFloorUs ? kMinPulseGapFloorUs : gap;
+uint32_t minPulseGapUs(bool wastedSpark) {
+ // A single-fire plug fires half as often, so doubling its gap keeps the
+ // same true-RPM ceiling in both modes. Integer math, deliberately: this
+ // value is compared against in the ISR, so the rounding-cast bug class
+ // stays out of it.
+ return wastedSpark ? kBasePulseGapUs : (kBasePulseGapUs * 2u);
}
} // namespace tach_filter
diff --git a/BirdsEye/tach_filter.h b/BirdsEye/tach_filter.h
index 56f4d8a..2c25e8c 100644
--- a/BirdsEye/tach_filter.h
+++ b/BirdsEye/tach_filter.h
@@ -10,22 +10,121 @@
// unit owns the predict/update equations and the tuning constants.
//
// Pure logic — no Arduino headers — so it is exercised by host tests.
+//
+// WHERE THE SPIKES CAME FROM (plan 0009). The estimator only ever saw
+// ONE number per pulse — the RPM implied by that period — and folded it
+// in with a steady-state gain of ~0.43. So a single bad edge (ignition
+// ringing sneaking past the debounce, or a spark the pickup missed) is
+// not a small error: at 3000 RPM a spurious edge 4 ms after a real one
+// reads as 15 000 RPM, and 43% of that lands in the output. That is a
+// 5000 RPM spike from ONE bad microsecond reading, and nothing in the
+// pipeline could tell it apart from a real engine speed. Three changes
+// fix it, all in this unit:
+//
+// 1. An OUTLIER GATE. A measurement further than kGateSigmas from the
+// estimate is not folded in — the estimate coasts instead. Three
+// consecutive rejections mean the estimate, not the measurement, is
+// wrong, so the third one is adopted outright.
+// 2. MEASUREMENT NOISE THAT KNOWS THE RPM. RPM = K / period, so a
+// fixed timing error is worth RPM^2/K of RPM error — quadratic. A
+// flat 2500 RPM^2 was roughly right at 4000 RPM and far too
+// confident above 8000, which is why the trace got noisier the
+// harder the engine was working.
+// 3. PROCESS NOISE PER SECOND, not per update. Updates arrive at the
+// pulse rate, so a per-update Q made the filter four times looser
+// at 12 000 RPM than at 3000 — and looser again whenever an SD
+// stall batched several pulses into one update.
///////////////////////////////////////////
namespace tach_filter {
-// Process noise Q: how much RPM^2 the true value can change between
-// updates. A kart engine with a light flywheel can shift ~200 RPM per
-// pulse at 5k RPM. 800 is conservative-smooth; raise to 1500–2000 if
-// tracking feels sluggish.
+///////////////////////////////////////////
+// FILTER MODE (plan 0009)
+//
+// Exposed as the `tach_filter` setting so the filter can be A/B'd against
+// a live engine at the track instead of argued about from a plotted log.
+///////////////////////////////////////////
+enum class Mode : uint8_t {
+ kSmooth = 0, // default: time-based Q, RPM-aware R, outlier gate
+ kLegacy = 1, // the pre-0009 filter exactly: fixed Q/R, no gate
+ kRaw = 2, // no estimator at all — publish what the periods say
+};
+
+// Parse the `tach_filter` setting. Unknown/blank/garbled degrades to
+// kSmooth, the house idiom: a bad settings value must never be the
+// reason a device logs worse data than the shipped default.
+Mode modeFromSetting(const char* value);
+
+// Lowercase setting-token name ("smooth" / "legacy" / "raw") — debug logs.
+const char* modeName(Mode m);
+
+// One-character tag ('S' / 'L' / 'R') for the 21-column display line.
+char modeTag(Mode m);
+
+// Process noise Q for kLegacy: how much RPM^2 the true value can change
+// between UPDATES. Kept only so kLegacy reproduces the shipped filter
+// bit for bit; kSmooth uses the rate below instead.
constexpr float kProcessNoiseQ = 800.0f;
-// Measurement noise R base: variance of a single-period RPM measurement
-// (~50 RPM std dev from combustion variation + ISR latency jitter).
+// Process noise as a RATE (RPM^2 per second), which is what a random-walk
+// model actually needs: updates arrive at the pulse rate, so charging a
+// fixed Q per update silently retunes the filter with engine speed. The
+// value is the legacy Q divided by the 10 ms period of a 6000 RPM single
+// — i.e. kSmooth behaves like the old filter at 6000 RPM and, unlike it,
+// keeps behaving that way everywhere else.
+constexpr float kProcessNoiseRate = 80000.0f;
+
+// Never predict more than this far ahead in one step. A batch that spans
+// a long coast (or a sanity-bounded 2 s period) must not open the
+// uncertainty so wide that the outlier gate stops meaning anything.
+constexpr float kMaxPredictSeconds = 0.5f;
+
+// Floor under the per-update process noise, so a degenerate dt can't
+// freeze the filter by driving the Kalman gain to zero.
+constexpr float kProcessNoiseFloorQ = 25.0f;
+
+// Measurement noise R base: the RPM-independent floor (~50 RPM std dev).
// Scales inversely with the number of periods in the measurement —
// more pulses, more confidence.
constexpr float kMeasurementNoiseRBase = 2500.0f;
+// Fixed per-edge timing uncertainty, microseconds: GPIOTE ISR latency
+// (the handler is deferred by SoftDevice radio ISRs like everything else
+// at app priority) plus edge-shape variation in the pickup. It costs
+// sigma_T * RPM^2 / K of RPM error — 26 RPM at 6000, 143 at 14 000.
+constexpr float kTimingJitterUs = 80.0f;
+
+// Genuine crank speed variation between firings, as a fraction of the
+// period. A single-cylinder engine really does speed up and slow down
+// within one revolution; that is not measurement error, but it is not
+// something anyone wants plotted either. Costs frac * RPM — linear.
+constexpr float kCycleVariationFrac = 0.010f;
+
+///////////////////////////////////////////
+// OUTLIER GATE
+///////////////////////////////////////////
+
+// How many standard deviations of combined (estimate + process +
+// measurement) uncertainty a measurement may sit from the estimate
+// before it is treated as a bad edge rather than a new engine speed.
+// 5 is deliberately loose: the gate exists to catch the half-period and
+// double-period signatures of a spurious/missed spark, which are tens of
+// sigma out, NOT to trim honest noise. Real acceleration never trips it —
+// a 5500 RPM/s pull moves the crank ~25 RPM between pulses.
+constexpr float kGateSigmas = 5.0f;
+
+// Consecutive rejections after which the measurement wins. Three
+// independent periods agreeing that the estimate is wrong is a real step
+// change (clutch dump, spin, a missed batch), not three coincident bad
+// edges — so the third is ADOPTED outright rather than filtered toward,
+// which is what makes a hard deceleration settle in ~80 ms.
+constexpr uint8_t kGateMaxRejects = 3;
+
+// Accepted updates after a reset before the gate arms. Straight out of
+// reset the estimate is 0 RPM and meaningless; gating against it would
+// reject the engine starting.
+constexpr uint16_t kGatePrimeUpdates = 4;
+
// Uncertainty assigned at reset (engine stop / sleep / boot): high, so
// the first real measurement dominates the stale estimate.
constexpr float kInitialUncertaintyP = 10000.0f;
@@ -37,15 +136,38 @@ constexpr float kUncertaintyFloorP = 1.0f;
struct Kalman {
float x = 0.0f; // RPM estimate
float p = kInitialUncertaintyP; // estimate uncertainty (RPM^2)
+ uint16_t updates = 0; // accepted updates since reset (arms the gate)
+ uint8_t strikes = 0; // consecutive gate rejections
+ uint16_t rejected = 0; // lifetime rejections — diagnostic only
};
// Return the estimate to the rest state (RPM 0, high uncertainty).
+// `rejected` deliberately SURVIVES: reset() runs on every engine stop, so
+// clearing it there would zero the counter between every corner and make
+// the pickup-health number on the debug line useless.
void reset(Kalman& k);
+// Process noise for one predict step of `dtSeconds`. kLegacy ignores the
+// argument and returns the flat per-update Q.
+float processNoise(Mode m, float dtSeconds);
+
+// Measurement noise for an `rpmMeasured` reading averaged over
+// `periodCount` periods on a pickup producing `revsPerPulse` revolutions
+// per pulse. kLegacy ignores the RPM and returns kMeasurementNoiseRBase
+// scaled by the count.
+float measurementNoise(Mode m, float rpmMeasured, float revsPerPulse,
+ int periodCount);
+
// Fold one measurement into the estimate: `rpmMeasured` is the RPM
-// implied by the mean of `periodCount` inter-pulse periods. periodCount
-// <= 0 is a no-op.
-void update(Kalman& k, float rpmMeasured, int periodCount);
+// implied by the mean of `periodCount` inter-pulse periods, which span
+// `dtSeconds` of engine time. periodCount <= 0 is a no-op.
+//
+// Returns true when the estimate moved. False means the outlier gate
+// rejected the measurement and the estimate coasted — the caller does
+// nothing about that (coasting IS the handling); the count is available
+// as `k.rejected` for the debug line.
+bool update(Kalman& k, Mode m, float rpmMeasured, int periodCount,
+ float dtSeconds, float revsPerPulse);
// Convert a mean inter-pulse period (microseconds) to RPM for a pickup
// producing `revsPerPulse` revolutions per pulse (wasted spark = 1.0).
@@ -55,59 +177,71 @@ float rpmFromMeanPeriodUs(float meanPeriodUs, float revsPerPulse);
///////////////////////////////////////////
// ENGINE GEOMETRY
//
-// The pickup counts IGNITION PULSES; the tach reports REVOLUTIONS. Those
-// are only the same thing on a single-cylinder engine that fires every
-// revolution — the common kart case, which is why one pulse per rev was
-// assumed for so long. Anything else skews RPM by a fixed factor:
+// The pickup counts IGNITION PULSES on ONE wire. There is one sense wire
+// and one clamp, so what it sees is one cylinder's ignition, whatever the
+// engine has behind it:
+//
+// pulses per rev = (wasted spark ? 1.0 : 0.5)
//
-// pulses per rev = cylinders x (wasted spark ? 1.0 : 0.5)
+// 2-stroke and 4-stroke wasted-spark plugs fire every revolution; a
+// 4-stroke single-fire (traditional distributor/magneto) plug fires once
+// every two revolutions. That is the WHOLE geometry.
//
-// Both settings default to the values that reproduce the old behaviour
-// exactly (1 cylinder, wasted spark => 1.0), so a device that has never
-// been configured reads identically before and after.
+// CYLINDER COUNT IS DELIBERATELY NOT IN THIS MATH. It used to be —
+// `pulses_per_rev = cylinders x sparkFactor` — which silently assumed the
+// pickup saw EVERY cylinder, true only of a clamp on a shared coil/king
+// lead. On the hardware this firmware actually ships with (one clamp, one
+// plug wire) it divided RPM by the cylinder count: a V8 on a traditional
+// magneto, configured honestly as 8 cylinders + single fire, read an
+// EIGHTH of its real crank speed. The setting stayed (it drives the
+// multi-cylinder warning in the settings UI and the docs); the divider
+// did not.
//
-// NOTE ON PICKUP PLACEMENT: `cylinderCount` is the cylinders the pickup
-// SEES, not the engine's. A clamp around one plug wire of a twin sees one.
+// WHAT THIS COSTS, and why it is accepted: on anything but a single, the
+// crank speed is INFERRED from one cylinder's firing rate. Between
+// firings the RPM is an assumption, and a cylinder that stops firing
+// reads as an engine that stopped. That is exactly how every clamp-on
+// inductive tach works, and it is the known and accepted behaviour for
+// this class of pickup.
///////////////////////////////////////////
-// Pulses per revolution contributed by each cylinder.
-constexpr float kPulsesPerRevWasted = 1.0f; // 2-stroke, or 4-stroke wasted spark
-constexpr float kPulsesPerRevSingle = 0.5f; // 4-stroke single-fire: one spark per two revs
+// Revolutions per ignition pulse on the clamped wire.
+constexpr float kRevsPerPulseWasted = 1.0f; // 2-stroke, or 4-stroke wasted spark
+constexpr float kRevsPerPulseSingle = 2.0f; // 4-stroke single-fire: one spark per two revs
-// Clamp for a nonsensical stored value — a bad setting must not divide RPM
-// by zero or by something absurd, it must fall back to sane behaviour.
+// Accepted range for the `cylinder_count` setting. It no longer enters the
+// RPM math (see above) — it is the engine descriptor that decides whether
+// the "RPM is inferred from ignition pulses" warning applies — but the
+// bounds still exist so a corrupt value can be clamped rather than trusted.
constexpr int kMinCylinders = 1;
constexpr int kMaxCylinders = 16;
-// Debounce gap for a single-cylinder wasted-spark engine: the historical
-// 3 ms, which rejects ignition ringing and caps at ~20 000 pulses/min.
-constexpr uint32_t kBasePulseGapUs = 3000;
-
-// Never debounce tighter than this, however many cylinders are configured.
-//
-// Chosen so the full ~20 000 true-RPM ceiling survives up to FOUR cylinders
-// (3000 / 4 = 750); past that the floor binds and the ceiling drops — 10 000
-// true RPM at eight cylinders — which is well clear of anything this logger
-// is pointed at.
+// Debounce gap for a plug firing every revolution: the historical 3 ms,
+// which rejects ignition ringing and caps at ~20 000 RPM.
//
-// Not a CPU limit: the ISR body is <1 us, so even the floor's worst case
-// (~1300 interrupts/s) is negligible. It is a RINGING limit, and the margin
-// holds from both ends — the tach input is RC-filtered (~100 us), and the
-// documented pickup circuits emit pulses MILLISECONDS wide (see
+// Not a CPU limit: the ISR body is <1 us. It is a RINGING limit, and the
+// margin holds from both ends — the tach input is RC-filtered (~100 us),
+// and the documented pickup circuits emit pulses MILLISECONDS wide (see
// TACHOMETER/README.md: circuit 1's 5 ms pulse is itself the ~9800 RPM
-// limit on that hardware), so a spurious edge inside 750 us is not a shape
-// either circuit produces.
-constexpr uint32_t kMinPulseGapFloorUs = 750;
+// limit on that hardware).
+constexpr uint32_t kBasePulseGapUs = 3000;
// Revolutions per pulse for an engine, ready for `rpmFromMeanPeriodUs`.
-// Out-of-range cylinder counts are clamped rather than rejected.
-float revsPerPulse(int cylinderCount, bool wastedSpark);
-
-// The debounce gap that preserves the same TRUE-RPM ceiling on every
-// engine. A fixed 3 ms caps ~20 000 pulses/min, which on a twin firing
-// every rev is only ~10 000 real RPM — a screaming 2-cyl 2T would hit the
-// debounce and read low. Scaling the gap by pulses-per-rev keeps the
-// ceiling where it has always been.
-uint32_t minPulseGapUs(int cylinderCount, bool wastedSpark);
+float revsPerPulse(bool wastedSpark);
+
+// The debounce gap, which holds the same ~20 000 RPM ceiling in both spark
+// modes: a single-fire plug fires half as often, so it gets twice the gap.
+// Cylinder count is not a term here either — one clamped wire never
+// delivers pulses faster than the cylinder it is wrapped around fires.
+uint32_t minPulseGapUs(bool wastedSpark);
+
+// Clamp a stored `cylinder_count` into range. A nonsensical value must not
+// be able to reach the warning logic (or a future consumer) unbounded.
+int clampCylinderCount(int cylinderCount);
+
+// Whether a cylinder count means the crank speed is being INFERRED from
+// one cylinder's ignition pulses — i.e. anything but a single. Drives the
+// user-facing warning; nothing in the RPM path branches on it.
+bool rpmIsInferred(int cylinderCount);
} // namespace tach_filter
diff --git a/BirdsEye/tachometer.h b/BirdsEye/tachometer.h
index 295100c..5ad37b2 100644
--- a/BirdsEye/tachometer.h
+++ b/BirdsEye/tachometer.h
@@ -23,3 +23,8 @@ void TACH_COUNT_PULSE();
// Drain ring buffer, update Kalman estimate, apply engine-stop timeout.
// Call once per main-loop iteration (~250 Hz).
void TACH_LOOP();
+
+// Count of inter-pulse periods the outlier gate rejected this power-cycle
+// (plan 0009). Pickup-health diagnostic for the tach page's debug line;
+// nothing behaves differently on it.
+uint16_t tachRejectedPeriods();
diff --git a/BirdsEye/tachometer.ino b/BirdsEye/tachometer.ino
index 083e0fd..9851526 100644
--- a/BirdsEye/tachometer.ino
+++ b/BirdsEye/tachometer.ino
@@ -12,6 +12,12 @@
// error. Mean of ~3 periods per 25Hz window further reduces noise. The
// Kalman filter smooths mechanical variation while tracking real RPM changes
// bounded by crankshaft inertia.
+//
+// The filter sees ONE number per pulse, so a single bad edge is a spike
+// rather than a wobble — see tach_filter.h for the outlier gate that keeps
+// ignition ringing and missed sparks out of the logged trace (plan 0009),
+// and for the `tach_filter` setting that switches the estimator off for a
+// track-side A/B against the raw pickup.
///////////////////////////////////////////
#include "tachometer.h"
@@ -136,7 +142,17 @@ void TACH_LOOP() {
float meanPeriodUs = (float)periodSum / (float)periodCount;
float rpmMeasured =
tach_filter::rpmFromMeanPeriodUs(meanPeriodUs, tachRevsPerPulse);
- tach_filter::update(tachKalman, rpmMeasured, periodCount);
+ // The predict step advances by the ENGINE time this batch spans —
+ // the sum of the periods themselves — not by wall clock since the
+ // last TACH_LOOP call. At 1500 RPM those differ by 40x (a 40 ms
+ // period vs a 4 ms loop), and charging process noise for time the
+ // crank did not turn is exactly the per-update-Q mistake this
+ // replaces. It is also free: periodSum is already computed, needs
+ // no clock read, and makes the filter's behaviour a pure function
+ // of the pulse train — which is what the host tests exercise.
+ const float dtSeconds = (float)periodSum / 1.0e6f;
+ tach_filter::update(tachKalman, tachFilterMode, rpmMeasured, periodCount,
+ dtSeconds, tachRevsPerPulse);
}
}
@@ -164,3 +180,17 @@ void TACH_LOOP() {
// ---- Step 5: Update reported value ----
tachLastReported = (int)(tachKalman.x + 0.5f);
}
+
+/**
+ * Periods the outlier gate has thrown away this power-cycle.
+ *
+ * Diagnostic only — nothing behaves differently on the count. It is the
+ * one number that says whether the pickup is clean: a session that ends
+ * with single digits saw a clean signal, and a count climbing with RPM
+ * is ignition ringing or missed sparks reaching the ISR. Deliberately
+ * NOT cleared by the engine-stop reset, so it accumulates across a whole
+ * session rather than resetting at every corner.
+ */
+uint16_t tachRejectedPeriods() {
+ return tachKalman.rejected;
+}
diff --git a/BirdsEye/usb_msc.h b/BirdsEye/usb_msc.h
index 6b0f1e3..3d47a5b 100644
--- a/BirdsEye/usb_msc.h
+++ b/BirdsEye/usb_msc.h
@@ -32,6 +32,8 @@ void USB_MSC_SETUP();
// the SD card is busy with another subsystem.
bool USB_MSC_ENABLE();
-// Leave USB mass-storage mode. Reboots the device (NVIC_SystemReset) so
-// the host drive drops and the firmware remounts a fresh filesystem.
+// Leave USB mass-storage mode. Detaches USB (stopping all further SCSI
+// traffic), drains any block callback still executing on the USBD task
+// (WDT-fed), syncs the card, then reboots (NVIC_SystemReset) so the host
+// drive drops and the firmware remounts a fresh filesystem.
void USB_MSC_DISABLE();
diff --git a/BirdsEye/usb_msc.ino b/BirdsEye/usb_msc.ino
index 86bd9eb..636c4c5 100644
--- a/BirdsEye/usb_msc.ino
+++ b/BirdsEye/usb_msc.ino
@@ -11,11 +11,16 @@ static Adafruit_USBD_MSC usb_msc;
bool usbMscActive = false;
-// millis() of the last host WRITE10 data phase serviced on the USBD task.
-// USB_MSC_DISABLE() waits for this to go quiet before it syncs + resets, so
-// a reset can't cut an in-flight writeSectors() and truncate a file / leave
-// the FAT inconsistent.
-static volatile uint32_t mscLastWriteMs = 0;
+// Block-callback activity tracking for the exit drain in USB_MSC_DISABLE().
+// mscIoInFlight is true while ANY block callback (read, write, or flush) is
+// executing on the USBD task; mscLastIoMs is stamped when one finishes. The
+// exit must wait for BOTH: a callback mid-writeSectors() can stall 100 ms–2 s
+// on SD garbage collection, so a time-window check alone (the old
+// mscLastWriteMs, stamped at write ENTRY and ignoring reads entirely) declared
+// the bus quiet while a callback was still on it — and the main loop's
+// syncDevice() + reset then raced the USBD task inside SdFat.
+static volatile bool mscIoInFlight = false;
+static volatile uint32_t mscLastIoMs = 0;
// --- TinyUSB block callbacks -------------------------------------------
// These run from the USBD task, NOT the main loop. The SD mutex
@@ -32,25 +37,34 @@ static volatile uint32_t mscLastWriteMs = 0;
// 3x like the rest of the SD code (ignition EMI can glitch a single op).
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) {
if (bufsize == 0 || (bufsize % 512) != 0) return -1;
+ mscIoInFlight = true;
+ int32_t result = -1;
uint32_t sectors = bufsize / 512;
for (uint8_t attempt = 0; attempt < 3; attempt++) {
if (SD.card()->readSectors(lba, (uint8_t*) buffer, sectors)) {
- return (int32_t) bufsize;
+ result = (int32_t) bufsize;
+ break;
}
}
- return -1;
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
+ return result;
}
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
if (bufsize == 0 || (bufsize % 512) != 0) return -1;
- mscLastWriteMs = millis(); // mark the write window for the exit quiesce
+ mscIoInFlight = true;
+ int32_t result = -1;
uint32_t sectors = bufsize / 512;
for (uint8_t attempt = 0; attempt < 3; attempt++) {
if (SD.card()->writeSectors(lba, buffer, sectors)) {
- return (int32_t) bufsize;
+ result = (int32_t) bufsize;
+ break;
}
}
- return -1;
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
+ return result;
}
// Host signalled it is done writing — flush the card and drop SdFat's
@@ -58,8 +72,11 @@ int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
// mode the firmware doesn't touch the filesystem anyway; this is belt
// and suspenders, and matches the canonical Adafruit msc_sdfat example.)
void msc_flush_cb(void) {
+ mscIoInFlight = true;
SD.card()->syncDevice();
SD.cacheClear();
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
}
// --- Public API --------------------------------------------------------
@@ -139,22 +156,31 @@ void USB_MSC_DISABLE() {
// files the host added/removed are picked up. Mirrors the BLE
// auto-reboot on disconnect.
debugln(F("USB MSC: exiting — rebooting to remount filesystem"));
- // Drop media-ready so the host sees the drive go away, and flush any
- // buffered writes to the card before we reset — the reboot is otherwise
- // a hard cut that would lose a not-yet-synced sector and risk leaving the
- // FAT inconsistent if the host hadn't already ejected.
+ // Drop media-ready so a still-attached host stops queueing new work, then
+ // cut the USB connection entirely. setUnitReady(false) alone only refuses
+ // NEW SCSI commands — an already-dispatched READ10/WRITE10 keeps calling
+ // the block callbacks on the USBD task, and the host keeps issuing more.
+ // Detaching is what actually stops the traffic, so the drain below only
+ // has to outlast the ONE callback that may still be executing. (Harmless
+ // on the cable-pulled path — the bus is already dead.)
usb_msc.setUnitReady(false);
+ TinyUSBDevice.detach();
- // Quiesce before we sync + reset. setUnitReady(false) only stops NEW SCSI
- // commands; a WRITE10 already dispatched keeps calling msc_write_cb on the
- // USBD task. Wait until no write has landed for a short window so the reset
- // can't cut an in-flight writeSectors() (truncated file / inconsistent
- // FAT). Bounded so a wedged host can't hang the exit.
+ // Drain before we sync + reset: wait until no block callback is executing
+ // AND none has finished for a short quiet window. Reads count too — a
+ // concurrent readSectors() wedges the shared SPI bus exactly like a write.
+ // Without this the main-loop syncDevice() raced a callback still inside
+ // SdFat, the exit hung on the wedged bus, and the ~4 s watchdog reset the
+ // device instead of the clean reboot (the "crash on USB exit" field bug).
+ // Bounded generously — SD garbage collection can stall a single
+ // writeSectors() for 100 ms–2 s — and WDT-fed so the wait itself can
+ // never trip the watchdog.
const uint32_t quietMs = 100;
- const uint32_t maxWaitMs = 1000;
+ const uint32_t maxWaitMs = 4000;
const uint32_t waitStart = millis();
while (millis() - waitStart < maxWaitMs) {
- if (millis() - mscLastWriteMs >= quietMs) break; // writes have gone quiet
+ wdtPet();
+ if (!mscIoInFlight && (millis() - mscLastIoMs >= quietMs)) break;
delay(5);
}
diff --git a/CASE/fancy-version.gif b/CASE/fancy-version.gif
new file mode 100644
index 0000000..1f41cb4
Binary files /dev/null and b/CASE/fancy-version.gif differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c677ac..a723843 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,397 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
- **MINOR** — new features or device behavior that is backwards compatible.
- **PATCH** — bug fixes and internal changes with no user-visible behavior change.
+## [Unreleased]
+
+## [4.1.0] - 2026-08-24
+
+MINOR — new settings and device behaviour, backwards compatible with the
+caveats below. Track files, the log filenames and the BLE command protocol
+are unchanged from 4.0.0, so existing tracks, cards and companion apps
+keep working. Two things do change:
+
+- **Stock DOVEX logs drop the three always-`nan` temperature columns** —
+ `Temp1`/`Junction1`/`Temp2` are now written only on SensorEgg builds, so
+ a stock data row has 13 columns instead of 16. Readers key the data
+ section off its own CSV header line, where all three are optional, so
+ 4.0.0 and 4.1.0 logs both load — but anything that assumed a fixed
+ 16-column row needs to read the header.
+- **RPM changes on any device with `cylinder_count` set above 1** — the
+ reading was wrongly divided by the cylinder count and no longer is (see
+ Fixed). Devices left at the default of 1 are byte-identical.
+
+> ### Read this before updating
+>
+> **This release permanently converts the two NFC pads to GPIO, on every
+> device, and reboots once while doing it.**
+>
+> The LED strip stops being a beta-only experiment in 4.1.0 and ships in the
+> normal firmware, so a logger lights up the moment someone wires a strip to
+> it — no special build. Making those two pads usable as GPIO means writing
+> the chip's UICR, and **that write cannot be undone by any later firmware**;
+> reversing it needs a full chip erase and a bootloader reflash over USB.
+> Your logger does the write the first time it boots after updating, then
+> resets itself once so the change takes effect. That single extra reboot
+> during the update is expected — nothing is wrong.
+>
+> This happens whether or not you have LEDs attached, because the firmware
+> cannot know. The trade was made deliberately: this hardware does not use
+> NFC for anything, the pads are otherwise idle, and requiring a separate
+> build to use a headline feature had kept it out of everyone's hands. If you
+> have some future use for the NFC pads on your device, **do not install
+> 4.1.0.**
+>
+> With no strip wired, nothing else changes — the pins simply sit there.
+
+The SensorEgg wireless-EGT proof of concept remains beta-only
+(`BIRDSEYE_ENABLE_SENSOREGG`, off in the published images), and the new
+main-loop CPU profiler is likewise a beta-channel diagnostic
+(`BIRDSEYE_ENABLE_PROFILING`, also off in the published images). Everything
+else below is live for every user.
+
+### Added
+- **NeoPixel LED strip subsystem** (plan 0006) — **now in every build**,
+ see the upgrade note above: 11 WS2812 pixels on the NFC
+ pads converted to GPIO — 2 status indicators + a 9-px strip with a
+ centerline. A global brightness cap (`led_brightness` setting, 0
+ disables the LEDs entirely) that no LED can ever exceed; a pace pip
+ strip mode (left of center red = slower than best, right green =
+ faster) with an RPM scale (green filling to red past halfway toward
+ the new `target_rpm` setting) until pace is meaningful; two
+ user-assignable status LEDs (see the status-modes entry below); a
+ boot animation; and a purple celebration when a session-best sector is
+ set. The strip's 5 V boost converter is enabled by its own pin, so
+ sleep truly powers the LEDs off. **First boot of a flag-on build
+ performs a one-way NFC-pads-to-GPIO conversion (UICR write) and
+ resets once.**
+- **Assignable LED status modes** — the two status pixels are no longer
+ hardcoded. `led_status_left` and `led_status_right` each select one of
+ eight modes from the companion app: **Off**, **Target RPM** (the old
+ rev flasher), **Target speed**, **GPS** (red no sats / yellow sats but
+ no fix / blue fix but no time lock / green locked), **Camera sync**
+ (yellow session-running-camera-not-up / flashing blue linked but not
+ subscribed / steady blue ready / red recording), **Last lap** and
+ **Last sector** (green faster than the previous one, red slower, purple
+ a session best), and **EGT** (SensorEgg builds only). The GPS and
+ camera modes stay lit on the main menu — they answer "am I ready to
+ drive", which is a paddock question. See
+ `docs/plans/0013-led-status-modes.md`.
+ - The lap and sector indicators compare against the **previous** lap or
+ the same sector on the previous lap, deliberately not against the
+ session best: a best-based indicator only ever answers purple or red
+ and tells you nothing about whether you are improving. Nothing to
+ compare against yet renders dark rather than guessing, so lap 1 is
+ unlit.
+- **A speed bar for sessions with no tachometer.** With the engine
+ signal absent the 9-px bar used to sit dark until the first lap
+ completed. It now scales against a new **`target_speed_mph`** setting
+ (default 60 mph, stored in mph — the app converts for display) until
+ pace timing takes over. The speed bar is green all the way up: the RPM
+ bar's red band means "approaching the limiter", and there is no
+ equivalent hazard in reaching a speed you were aiming for.
+- **The purple celebration is now two-stage.** A session-best **sector**
+ keeps the 1.6 s animation; a session-best **lap** gets a longer 2.6 s
+ version with two wave passes, and outranks a sector celebration when
+ both land on the same crossing.
+- **LED day/night brightness + a device timezone** (plan 0010): new `utc_offset_min` setting (minutes east of UTC, default 0)
+ gives the device a local wall clock, and the LED strip swaps to
+ `led_brightness_night` (default 16) between `led_night_start_hour`
+ (19) and `led_day_start_hour` (7) — local hours, so 7am is the
+ driver's 7am rather than Greenwich's. Setting the two hours equal
+ disables the swap. **No DST** — a fixed offset only. **Logged data is
+ unchanged and still UTC**: DOVEX timestamps, the header datetime and
+ every filename stay exactly as they were, and timezone presentation
+ remains the viewing app's job.
+- **Overrev alert** (plan 0007): new `overrev_limit` setting (default 0
+ = disabled) — past it the whole LED chain flashes red and the tach
+ page's `*OVER REV*` header trips, both until RPM falls back below the
+ normal `target_rpm`. The target-RPM warning means the engine is at its
+ ceiling; the overrev limit says it's broken.
+- **Temp1 alert threshold setting** (`temp1_alert_c`, default 650 °C,
+ SensorEgg builds only): the `egt` status-LED mode is a tri-state —
+ flashing red at/above the limit, off when good, solid blue when there
+ is no probe signal.
+- **`tach_filter` setting** (default `smooth`, plan 0009): picks the RPM
+ estimator, so the tach can be A/B'd against a live engine at the track
+ instead of argued about from a plotted log. `smooth` is the new filter
+ below; `legacy` is the pre-0009 one, bit for bit, for comparison
+ against logs already collected; `raw` turns the estimator off entirely
+ so the `rpm` column is exactly what the pickup delivers. Anything
+ unrecognised reads as `smooth`.
+- **Tach pickup-health line**: with `debug_pages` = `show`, the
+ tachometer page's subtext becomes `max:NNNNN S rj:NN` — the estimator
+ in force plus the number of inter-pulse periods the outlier gate has
+ thrown away this power-cycle. A count that climbs with RPM is ignition
+ ringing or missed sparks reaching the ISR, i.e. the pickup rather than
+ the filter.
+- **GPS-search pip**: in race mode without a full GPS lock the strip shows a green pixel bouncing end-to-end instead of the
+ RPM scale, so a not-yet-timing session is visibly "searching".
+- **Connection interval + PHY on the Bluetooth transfer page** — a second
+ diagnostic line during a transfer (e.g. `15.0ms 2M`) alongside plan 0008's
+ rate/SD/PDU/payload line. These are the two throughput levers the central
+ decides and the device can only request: an interval of 30 ms instead of
+ 15 ms is a silent 2x on every download, and a link that ignored the 2M PHY
+ request pays double airtime per packet. Live values, so a mid-transfer
+ renegotiation shows up.
+- **Main-loop CPU profiling (beta channel only)** — a new
+ `BIRDSEYE_ENABLE_PROFILING` build flag, off in master and release, on
+ in every beta build. It times each subsystem call in `loop()`, rolls
+ the result up once a second onto a new **LOOP PROFILE** race page
+ (loop rate, mean and worst iteration, and every section's share of the
+ last second), and drives **pin 30** as a scope-readable profiling
+ output. Groundwork for the nRF52840-vs-nRF5340 board decision and for
+ judging what leaving the Arduino core would buy. See
+ `docs/plans/0011-loop-cpu-profiling.md`.
+ - Timing comes from the Cortex-M4 DWT cycle counter (64 ticks/µs)
+ with a `micros()` fallback; the page flags the fallback with a
+ leading `*` because at 1 µs resolution the small sections are noise.
+ - The stats row shows the mean iteration in microseconds below a
+ millisecond. The first hardware run read `999Hz av0.0` — both fields
+ had been clamped against the ~250 Hz figure this project's docs had
+ long assumed. See `docs/plans/0011-loop-cpu-profiling.md`.
+ - **Rollup windows are closed on `millis()`, not on the cycle
+ counter.** The DWT counter counts CPU cycles and stops when the core
+ halts, so using it to time the window made a "one second" window one
+ second of CPU-awake time — inflating the reported loop rate by the
+ sleep factor and turning every share into a fraction of awake time
+ wearing a wall-time label. All shares are now of real wall time.
+ - New **`SLP`** slot on the page: wall time not spent inside `loop()`
+ at all — scheduler dispatch, other FreeRTOS tasks and CPU sleep, i.e.
+ the actual headroom. Its grid slot came from folding the boot-page
+ state machines (`PGE`) into `DSP`. All fourteen slots are shares of
+ the same second and sum to ~100%.
+ - New host-tested pure unit `loop_profile` holds all the accounting.
+ - **A beta image can no longer switch the 5 V LED boost rail.** Pin 30
+ is the boost converter's EN line and the profiler takes it, so the
+ regulator is left at its hardware default (on) and is never driven
+ low — including at sleep, where the level is retained. A beta unit
+ left asleep on a battery with a strip wired to it will drain it, and
+ if the EN jumper is still connected the profiling toggles will chop
+ the rail at loop rate. Bench builds only; prod images are unaffected.
+ - On a profiling build the race rotation starts at the profile page,
+ which also pulls the two diagnostic pages in regardless of the
+ `debug_pages` setting.
+- **The browser-sim harness can fake a GPS fix.** A "GPS fix" toggle (plus
+ an mph field) streams a deterministic synthetic 25 Hz fix parked on the
+ bundled OKC track's start line, so fix-gated flows — most usefully the
+ on-device course creator, including its 3 s point-averaging hold — can be
+ exercised in the simulator without loading a log file.
+
+### Changed
+- **Settings file and JSON document buffers raised 512 -> 1024 bytes**
+ (plan 0010), and `setSetting()` now refuses a write it could not read
+ back. The settings file was already 436 bytes at 18 keys against a
+ 511-byte read cap; the four new keys would have pushed it to 543,
+ where the file parses as truncated, *every* setting read fails, and
+ the boot-time corrupt-file heal quarantines and regenerates it on a
+ loop — losing the device's BLE name, PIN and pairing every boot. Costs
+ ~1 KB more RAM. **If you add settings keys, check the file size.**
+- **RPM spikes are gone from the logged trace** (plan 0009). A plotted
+ DOVEX `rpm` column showed spikes of thousands of RPM that the engine
+ did not do. The filter was not over-smoothing — it was passing single
+ bad edges straight through. Its steady-state gain was ~0.43 and a
+ measurement is one inter-pulse period, so an ignition ring clearing the
+ 3 ms debounce at 3000 RPM reads as 15 000 RPM and moves the output
+ ~5000 RPM; a missed spark does the same downward. Three fixes, all in
+ the host-tested `tach_filter` unit:
+ - An **outlier gate**: a measurement 5 sigma off the estimate is not
+ folded in, the estimate coasts. Three *consecutive* rejections is a
+ real step change (clutch dump, spin) rather than three coincident bad
+ edges, and the third is adopted outright — so an instant
+ 11 000 → 4000 drop now settles in 80 ms, faster than before.
+ - **Measurement noise that knows the RPM.** RPM = K/period, so a fixed
+ timing error costs RPM^2/K of RPM error. The old flat noise figure was
+ about right at 4000 RPM and far too confident above 8000 — the filter
+ trusted its noisiest readings the most, which is why the trace got
+ worse the harder the engine worked.
+ - **Process noise per second, not per update.** Updates arrive at the
+ pulse rate, so the old fixed per-update figure made the filter four
+ times looser at 12 000 RPM than at 3000, and looser again whenever an
+ SD stall batched several pulses together.
+ Modelled over the whole pipeline on a dirty pickup, error against true
+ RPM drops from 220–500 RPM standard deviation (worst case 2800–6400) to
+ a flat ~20 RPM (worst case under 300) from 1500 to 14 000 RPM. The cost
+ is ~90 ms more lag on a 5500 RPM/s pull. Set `tach_filter` to `legacy`
+ to get the old behaviour back.
+- **Engine dies mid-session** (tach-proven sessions): the pace page shows
+ `STOPPED` instead of a still-counting pace, and the LED bar goes dark
+ while the status LEDs (temp alert) stay live. Clears on restart.
+- **Tach page `*OVER REV*` header** now means actual overrev: it shows
+ only when `overrev_limit` is enabled and RPM reaches it (was a
+ hardcoded 9999 RPM). The `rev_limit` warning stays on the LED only.
+- **Temp status LED flashes red** (was orange), matching the rev
+ flasher's alert language.
+- **Bluetooth downloads are faster, and now say why when they are not**
+ (plan 0008). A 3.3 MB session downloading at 28.8 KB/s on an iPad
+ prompted a look at the whole transfer path. Three things were capping
+ it, all fixed:
+ - The link asks for a bigger link-layer packet (Data Length Extension)
+ on connect, but that request can be rejected outright if it collides
+ with the 2M PHY request fired a line earlier — and nothing checked or
+ retried. An un-extended link splits every notification into ten
+ packets. The device now reads back what actually negotiated half a
+ second after connecting and re-asks if it did not take.
+ - The device asked for a 7.5 ms connection interval, which **iOS is
+ required to reject** (Apple's accessory rules set a 15 ms floor),
+ leaving the connection on whatever iOS picked — commonly 30 ms, so
+ half the transfer opportunities. The device now notices it is slower
+ than 15 ms and makes a second request iOS is allowed to accept.
+ Centrals already running faster than 15 ms are left alone.
+ - Each chunk was read off the SD card immediately before being sent, so
+ every packet waited on a disk read and the card was driven in
+ 244-byte pieces rather than whole sectors. Transfers now read 4 KB
+ ahead and send out of memory.
+- **The Bluetooth page shows live transfer speed** in KB/s alongside the
+ percentage, plus the SD clock, link packet size and payload size in
+ force. The old display was a percentage and nothing else, which is why
+ a 4x slowdown took a full session to spot. The SD figure is the clock
+ actually running, not the one requested — the 8 MHz transfer speed-up
+ falls back to 2 MHz if the card refuses it, and that fallback used to
+ be silent.
+- **`cylinder_count` means the engine's actual cylinder count.** It was
+ documented as "cylinders the **pickup sees**", which asked a V8 owner
+ to enter `1` in a field labelled Cylinders — a trap that produced wrong
+ RPM for anyone who read it literally. It is now descriptive: enter what
+ the engine has. Nothing in the RPM path reads it. Above 1 it drives a
+ warning, in the settings UI and in the docs, that crank speed is
+ **inferred from one cylinder's ignition pulses** — between firings the
+ reading is an assumption and a cylinder that drops out reads as a
+ stopped engine, which is the known and accepted behaviour of every
+ clamp-on inductive tach. No settings-file migration: same keys, same
+ defaults, same size.
+- **`rev_limit` is now `target_rpm`.** It always was the shift/warning
+ point — `overrev_limit` is the real limit — and the old name taught
+ the wrong thing to everyone who opened `/SETTINGS.json`. The old key
+ only ever shipped on the beta channel during this cycle; a device
+ carrying it migrates on first boot — the value is copied into the new
+ key, which is confirmed written before the old key is dropped. No user
+ action needed, and a tuned value is never lost.
+- **The DOVEX `Temp1` / `Junction1` / `Temp2` columns are now written
+ only on SensorEgg builds.** Until now they were written on every
+ channel, as the literal `nan` on a stock image, so the log shape never
+ forked. Stock logs are now 13 data columns and SensorEgg logs 16.
+ Readers key the data section off its own CSV header line, where all
+ three are optional, so both shapes load — but anything that assumed a
+ fixed 16-column row needs to read the header. The `temp1_alert_c`
+ setting is likewise SensorEgg-only now.
+- The right status LED's default is **EGT** on a SensorEgg build (what it
+ did before) and **Last lap** on a stock one.
+
+### Fixed
+- **The Bluetooth settings list works again.** Asking the device to
+ enumerate its settings (`SLIST` — what the companion app's settings
+ screen does) answered a parse error and listed nothing. The nine keys
+ added this release took `/SETTINGS.json` from 329 to 538 bytes, and while
+ the settings module's own reader was raised to 1024 bytes to suit, a
+ *second* copy of that reader inside the Bluetooth code was left at 512 —
+ so it read 511 bytes of a 538-byte file and gave up on the truncated
+ result. Both are now sized by one shared constant, so they cannot drift
+ apart again. Reading and writing individual settings was never affected.
+- **A blank or corrupt `led_brightness` no longer looks like dead
+ hardware.** Every numeric setting was read with a parser that answers
+ "0" for an empty or non-numeric value, and 0 is a real, meaningful
+ setting for most of the new keys — for `led_brightness` it means *LEDs
+ off, and never even power up the 5 V rail*. A garbled value therefore
+ switched the strip off silently instead of falling back to the default,
+ as the code always claimed it did. Values are now parsed strictly:
+ anything that is not a whole number is rejected and the default stands.
+ A deliberate `0` still works exactly as before.
+- **The overrev alert no longer strobes when the two RPM limits are set
+ close together.** `rev_limit` and `overrev_limit` are validated
+ independently, so nothing stopped you setting the overrev limit at or
+ below the rev limit. Doing so inverted the alert's release point,
+ and the whole LED chain flickered at the frame rate instead of flashing.
+- **`STOPPED` on the pace page is no longer cut off.** At the size the page
+ draws it, the text needed 144 pixels of a 128-pixel screen, so the final
+ letter was clipped on every render.
+- **A build with the LED subsystem compiled out now still powers the LED
+ rail down.** Such a build does nothing with the LED pins by design — but
+ on a board whose pads an earlier LED-enabled build had already converted,
+ "nothing" left the boost converter's enable pin undriven, so the 5 V rail
+ could stay up through shutdown and drain the battery of a device with no
+ power switch. It now holds that pin low, but *only* on a board that has
+ already been converted; hardware that never ran an LED-enabled build is
+ still never touched. (No shipped 4.1.0 image is built this way — the LED
+ subsystem is in all of them — so this protects custom builds and anyone
+ rolling one back.)
+- **Four menus you could get into but not out of now have a Back/Cancel
+ row.** The transfer menu (Bluetooth / USB), the replay session browser,
+ and the course creator's first two screens (track prompt, course type)
+ offered only forward choices. None of them is the main menu, and the
+ idle-shutdown timer only runs there, so none of them timed out either —
+ opening one by mistake left the Select + side-button 5 s reboot combo,
+ which is not labelled anywhere, as the only way out. Specifically:
+ Transfer gains **Back**; the session browser gains a **Back** row after
+ the last session (it scrolls into view like any other row); and the
+ course creator's track prompt and type picker each gain **Cancel**,
+ matching the Cancel/Back every screen deeper in that flow already had.
+ The type picker's matters most — entering the creator with no known
+ track nearby skips the prompt and lands there, so it was the first
+ screen those users saw.
+- **Cancelling manual camera-serial entry returns to the camera page.**
+ It dropped to the main menu while OK returned to the camera page, so
+ backing out of a mistyped character threw you two levels out and made
+ the retry a fresh trip through the menu.
+- **Exiting USB transfer mode no longer risks a hang + watchdog reset.**
+ Leaving the USB drive page (Exit button or cable pull) could wedge the
+ device for ~4 seconds and come back via the watchdog instead of the clean
+ reboot: the exit path stopped accepting *new* host commands but a
+ read/write already in flight kept driving the SD card from the USB task
+ while the exit synced the card from the main loop — two tasks on the SPI
+ bus at once. The exit now detaches USB first (so host traffic actually
+ stops), waits out any callback still running — reads included, which were
+ never tracked before — and only then syncs and reboots. The wait is
+ watchdog-fed and sized to survive the SD card's own garbage-collection
+ stalls.
+- **Exiting Bluetooth transfer mode with the button now reboots the device.**
+ Only a phone disconnect triggered the auto-reboot; pressing Exit on the
+ device dropped back to the menu with any settings changed over Bluetooth
+ not yet applied (they only take effect on boot). Both ways out of transfer
+ mode — manual exit and peer disconnect — now reboot, matching how USB
+ transfer mode has always exited.
+- **Browser-sim harness plays 4.0.0 logs again.** The wasm test harness
+ filtered DOVEX rows to exactly 13 columns, so logs from 4.0.0 firmware
+ (16 columns after the `Temp1`/`Junction1`/`Temp2` additions) injected
+ nothing. It now accepts 13+ and reads the stable first 13.
+- **RPM on multi-cylinder engines — the cylinder count no longer divides
+ it.** Since 4.0.0 the tachometer computed
+ `pulses_per_rev = cylinder_count × spark_factor`, which is only correct
+ for a pickup clamped on a shared coil or distributor king lead. This
+ device has one sense wire and one clamp, and it goes around **one spark
+ plug wire** — so it sees one cylinder's ignition however many the engine
+ has, and the cylinder term divided RPM by that count. A V8 on a
+ traditional magneto, configured honestly as 8 cylinders + single fire,
+ read **an eighth** of its real crank speed. RPM is now
+ `revs_per_pulse = spark_mode == wasted ? 1.0 : 2.0` — the spark mode
+ alone. See `docs/plans/0014-tach-rpm-single-pickup.md`.
+ - **BREAKING for anyone who set `cylinder_count` above 1**: their RPM
+ changes, because it stops being divided. Logs recorded before this
+ release read low by exactly the configured cylinder count. Devices
+ left at the default (1 cylinder) are byte-identical.
+ - The ISR debounce loses the same term — one wire can't deliver pulses
+ faster than the cylinder it wraps fires — so it returns to a flat
+ 3 ms (6 ms for single-fire) and the ~20 000 RPM ceiling is now
+ identical in both spark modes instead of falling with each configured
+ cylinder.
+- **The right status LED no longer sits solid blue for an entire race on
+ a logger without SensorEgg support.** The pixel was hardwired to the
+ Temp1 tri-state, whose "no probe signal" state is a solid blue — and on
+ a stock build the reading is permanently unavailable, so every shipped
+ logger with LEDs fitted showed it from lights-out to chequered flag. A
+ build with no probe now renders that mode dark, because there is no
+ signal there to be missing.
+- **BLE download throughput on SensorEgg (beta) builds** — the SensorEgg
+ passive scanner (44% radio duty) ran from boot forever, including through
+ every Bluetooth file transfer, where it denied the link's connection-event
+ extension and throttled downloads to ~33 KB/s against a 120+ KB/s
+ baseline. The scanner is now **race-gated**: it starts when a race session
+ begins and stops when the session ends, so the menu, replay, and transfer
+ mode never share the radio with a scan window. `BLE_SETUP()` additionally
+ quiesces it explicitly so a transfer session carries a guarantee rather
+ than an inference. EGT data is unaffected — the Temp pages and the DOVEX
+ `Temp1`/`Junction1`/`Temp2` columns are race-only consumers. See
+ `docs/plans/0012-download-throughput-regression-deepdive.md`.
+
## [4.0.0] - 2026-08-10
### Added
@@ -169,6 +560,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
- The scan-tuning test's pinned egg advertising interval was stale at
160 units; the egg de-aliased to 179 units (111.875 ms) — pin updated
(anti-phase-lock invariants still hold).
+
### Fixed
- **A corrupted settings file now heals itself instead of poisoning the
device forever.** A hand-edited `SETTINGS.json` with a typo (or any
@@ -371,6 +763,44 @@ makes migrating to it safe on sealed units.
`-DBIRDSEYE_ENABLE_SENSOREGG=1`, and `compile-sketch.yml` does the same
for pull requests targeting `BETA`, so the flag-on build is
compile-checked on the PR rather than first failing at publish time.
+- **Simulator oracle diagnostic replay modes.** `birdseye_sim_oracle
+ --dovex-noheader ` replays a header-less DOVEX log (crashed /
+ power-cut session) through the full pipeline, printing live page / race /
+ track-detect / course / lap state instead of asserting against header
+ laps. `--two-session [break-minutes]` reproduces a whole track
+ day — synthetic session 1, auto-idle session end, a parked break with
+ deterministic GPS drift, then the real log as session 2 — validating the
+ carried-over CourseManager against the log's own header laps (wired into
+ CI as `sim_two_session_carryover`).
+- **GPS pipeline drop instrumentation.** The ~0.9% dropped-PVT regression
+ was invisible on-device (the only signal was the frame-rate readout).
+ The GPS serial path now carries permanent lightweight counters: missing
+ PVT frames vs the nav-rate expectation (window math in the new
+ host-tested `gps_stats` pure unit), worst-case TIMER3 drain deferral in
+ µs (hardware timer capture — measures SoftDevice radio-ISR pressure),
+ the biggest single-fire drain burst vs the core UART ring capacity, and
+ overflow event counts for both the core ring and the 4 KB GPS ring.
+ Surfaced on the GPS debug page — now first in the race rotation — with
+ a one-line `Drops / Ovf` summary on the GPS stats page. ISR cost is a
+ few cycles per fire; ~50 B RAM.
+- **SensorEgg wireless EGT (proof of concept).** The logger passively
+ scans for the DovesSensorEgg — a wireless thermocouple pod that
+ broadcasts EGT + cold-junction temperature in BLE advertising packets
+ (protocol `PW-ADV-1`, 14-byte manufacturer data, ~10 Hz). Passive
+ observer only: no SCAN_REQ, no connection, no GATT link to the egg, so
+ the scanner cannot contend with the Insta360 X4 camera link for TX
+ airtime. Pairing is a hardcoded MAC (`SENSOREGG_MAC` in `sensoregg.h`);
+ the all-zeros default accepts any egg matching the payload magic (POC:
+ exactly one egg exists). Payload parsing + staleness rules live in the
+ host-tested `sensoregg_protocol` pure unit.
+- **DOVEX columns `Temp1` / `Junction1`** (degC, appended after
+ `accel_z`). A stale link (>1 s) or an egg-reported invalid probe logs
+ the literal `nan` — a dropout is a visible gap, never a held flat line
+ (which would be indistinguishable from real data). Old logs and readers
+ are unaffected; the egg fields can never cause a GPS row to be skipped.
+- **Temp1 race page** after the tachometer page: big EGT (degC), small
+ cold-junction + `rf:` link-status subtext, `---` when the egg is
+ silent, `*TC FAULT*` header when the egg reports an open/faulted probe.
### Changed
- **USB behavior with onboard charging disabled (i.e. by default).** A
@@ -385,63 +815,6 @@ makes migrating to it safe on sealed units.
- **DOVEX `Temp1` / `Junction1` are written on every build.** With the
SensorEgg POC compiled out they log the literal `nan`, so logs from the
master and beta channels have identical column layout.
-
-### Added
-- **Simulator oracle diagnostic replay modes.** `birdseye_sim_oracle
- --dovex-noheader ` replays a header-less DOVEX log (crashed /
- power-cut session) through the full pipeline, printing live page / race /
- track-detect / course / lap state instead of asserting against header
- laps. `--two-session [break-minutes]` reproduces a whole track
- day — synthetic session 1, auto-idle session end, a parked break with
- deterministic GPS drift, then the real log as session 2 — validating the
- carried-over CourseManager against the log's own header laps (wired into
- CI as `sim_two_session_carryover`).
-
-### Fixed
-- **Zombie SensorEgg no longer reads as a live link (field incident
- 2026-07-19).** BLE radios rebroadcast the last advertising payload
- autonomously, so an egg whose application hangs keeps beaconing frozen
- data at 10 Hz — the logger showed `rf: OK` with a value that never
- updated, and would have logged a flat line indistinguishable from real
- data. The logger now watches the payload's sequence counter (the app's
- only sign of life, via the host-tested wrap-safe
- `sensoregg_protocol::SeqMonitor`): if packets arrive but the sequence
- hasn't advanced within 1 s, readings go NaN (`nan` in the log, `---` on
- the display) and the Temp1 page shows `rf:HUNG` — the egg needs a power
- cycle.
-- **Menu idle shutdown ignored button activity.** The 5-minute main-menu
- idle timer read the buttons' `pressed` flags at a point in the loop where
- they are always false (set later by `readButtons()`, cleared by
- `resetButtons()` before the next iteration), so navigating the menu never
- reset the clock and the device powered off 5 minutes after menu entry
- regardless of activity. The timer now anchors on the button debouncer's
- persistent `lastPressed` stamps. Regression-tested by the new
- `--two-session` sim oracle mode in CI (the mid-break shutdown it used to
- cause kept session 2 from ever entering race mode).
-- **Track detection silently degraded to Lap Anything when logging started
- first.** `SD_ACCESS_TRACK_PARSE` was denied while `SD_ACCESS_LOGGING`
- held the card — but logging holds it for the entire race session, so any
- boot where the log file was created before the 1 Hz track-detect parse
- (typical tach-wake with a warm GPS) lost course detection for the whole
- session. Track parse / settings reads now nest under the logging hold
- without taking ownership (`sd_access_policy::ownerAfterAcquire`) — they
- run on the same main-loop task with their own file handles, so the
- exclusion never protected anything. Policy change covered by host unit
- tests.
-- **Pull-start / engine-kill lockup (field incident 2026-07-19).** A failed
- first start or killing the motor before GPS acquired its time lock left
- the device apparently frozen: pinned to the RPM page, all buttons dead,
- no watchdog reset. Root cause: the GPS-lock hold (which pins the UI while
- a race session waits for a lock to create its log file) latched on any
- tach pulse — pull-cord ignition blips included — and had no engine-off
- release, while its only automatic session-enders were blocked (3-minute
- auto-idle grace, and full auto-idle suppression once the camera was
- recording). Fixes: the hold now releases after 10 s of engine-off (the
- session stays active and navigable), the pinned tach page shows
- `WAITING GPS LOCK..` instead of pinning silently, and auto-idle may end
- a still-fileless session even while the camera records.
-
-### Changed
- **GPS serial pipeline hardened against BLE radio load (dropped-PVT
fix).** SoftDevice radio interrupts (SensorEgg scan windows, camera
connection events) can defer the TIMER3 GPS drain; at 57600 baud the
@@ -484,44 +857,53 @@ makes migrating to it safe on sealed units.
`sensoregg_protocol::celsiusToFahrenheit()`. DOVEX logging is unchanged
(`Temp1`/`Junction1` stay Celsius). A C/F display setting will follow
in a later release.
-
-### Added
-- **GPS pipeline drop instrumentation.** The ~0.9% dropped-PVT regression
- was invisible on-device (the only signal was the frame-rate readout).
- The GPS serial path now carries permanent lightweight counters: missing
- PVT frames vs the nav-rate expectation (window math in the new
- host-tested `gps_stats` pure unit), worst-case TIMER3 drain deferral in
- µs (hardware timer capture — measures SoftDevice radio-ISR pressure),
- the biggest single-fire drain burst vs the core UART ring capacity, and
- overflow event counts for both the core ring and the 4 KB GPS ring.
- Surfaced on the GPS debug page — now first in the race rotation — with
- a one-line `Drops / Ovf` summary on the GPS stats page. ISR cost is a
- few cycles per fire; ~50 B RAM.
-- **SensorEgg wireless EGT (proof of concept).** The logger passively
- scans for the DovesSensorEgg — a wireless thermocouple pod that
- broadcasts EGT + cold-junction temperature in BLE advertising packets
- (protocol `PW-ADV-1`, 14-byte manufacturer data, ~10 Hz). Passive
- observer only: no SCAN_REQ, no connection, no GATT link to the egg, so
- the scanner cannot contend with the Insta360 X4 camera link for TX
- airtime. Pairing is a hardcoded MAC (`SENSOREGG_MAC` in `sensoregg.h`);
- the all-zeros default accepts any egg matching the payload magic (POC:
- exactly one egg exists). Payload parsing + staleness rules live in the
- host-tested `sensoregg_protocol` pure unit.
-- **DOVEX columns `Temp1` / `Junction1`** (degC, appended after
- `accel_z`). A stale link (>1 s) or an egg-reported invalid probe logs
- the literal `nan` — a dropout is a visible gap, never a held flat line
- (which would be indistinguishable from real data). Old logs and readers
- are unaffected; the egg fields can never cause a GPS row to be skipped.
-- **Temp1 race page** after the tachometer page: big EGT (degC), small
- cold-junction + `rf:` link-status subtext, `---` when the egg is
- silent, `*TC FAULT*` header when the egg reports an open/faulted probe.
-
-### Changed
- **BLE core now starts at boot** (was lazy — first camera/transfer use)
so the SensorEgg scanner is always listening. Idle power draw increases
accordingly; GATT service registration order is unchanged.
### Fixed
+- **Zombie SensorEgg no longer reads as a live link (field incident
+ 2026-07-19).** BLE radios rebroadcast the last advertising payload
+ autonomously, so an egg whose application hangs keeps beaconing frozen
+ data at 10 Hz — the logger showed `rf: OK` with a value that never
+ updated, and would have logged a flat line indistinguishable from real
+ data. The logger now watches the payload's sequence counter (the app's
+ only sign of life, via the host-tested wrap-safe
+ `sensoregg_protocol::SeqMonitor`): if packets arrive but the sequence
+ hasn't advanced within 1 s, readings go NaN (`nan` in the log, `---` on
+ the display) and the Temp1 page shows `rf:HUNG` — the egg needs a power
+ cycle.
+- **Menu idle shutdown ignored button activity.** The 5-minute main-menu
+ idle timer read the buttons' `pressed` flags at a point in the loop where
+ they are always false (set later by `readButtons()`, cleared by
+ `resetButtons()` before the next iteration), so navigating the menu never
+ reset the clock and the device powered off 5 minutes after menu entry
+ regardless of activity. The timer now anchors on the button debouncer's
+ persistent `lastPressed` stamps. Regression-tested by the new
+ `--two-session` sim oracle mode in CI (the mid-break shutdown it used to
+ cause kept session 2 from ever entering race mode).
+- **Track detection silently degraded to Lap Anything when logging started
+ first.** `SD_ACCESS_TRACK_PARSE` was denied while `SD_ACCESS_LOGGING`
+ held the card — but logging holds it for the entire race session, so any
+ boot where the log file was created before the 1 Hz track-detect parse
+ (typical tach-wake with a warm GPS) lost course detection for the whole
+ session. Track parse / settings reads now nest under the logging hold
+ without taking ownership (`sd_access_policy::ownerAfterAcquire`) — they
+ run on the same main-loop task with their own file handles, so the
+ exclusion never protected anything. Policy change covered by host unit
+ tests.
+- **Pull-start / engine-kill lockup (field incident 2026-07-19).** A failed
+ first start or killing the motor before GPS acquired its time lock left
+ the device apparently frozen: pinned to the RPM page, all buttons dead,
+ no watchdog reset. Root cause: the GPS-lock hold (which pins the UI while
+ a race session waits for a lock to create its log file) latched on any
+ tach pulse — pull-cord ignition blips included — and had no engine-off
+ release, while its only automatic session-enders were blocked (3-minute
+ auto-idle grace, and full auto-idle suppression once the camera was
+ recording). Fixes: the hold now releases after 10 s of engine-off (the
+ session stays active and navigable), the pinned tach page shows
+ `WAITING GPS LOCK..` instead of pinning silently, and auto-idle may end
+ a still-fileless session even while the camera records.
- **SensorEgg reception flapping** (1 s of readings, then 1–30 s of
`NA`/`nan`, seen on the first bench soak). Three scanner fixes: an
inline manufacturer-ID filter (`filterMSD(0xFFFF)`) so ambient BLE
@@ -1130,6 +1512,11 @@ all breaking under this project's semver policy.
retried (throttled to 1 Hz) instead of faulting, and a mid-session write
failure stops logging while the race continues — none of these show the
full-screen "Please Reboot Device" fault anymore.
+- Sketch sources moved into the `BirdsEye/` subfolder so the folder name
+ matches `BirdsEye.ino` (Arduino IDE / arduino-cli requirement).
+- DOVEX header read/write now goes through a single tested
+ `dovex_header::format()` / `parse()` implementation. On-disk format is
+ unchanged (byte-for-byte compatible with existing `.dovex` files).
### Added
- **SD-staged firmware OTA over the custom BLE file service.** Because
@@ -1208,13 +1595,6 @@ all breaking under this project's semver policy.
nRF52840 Sense, plus status badges in the README.
- Per-module `.h` headers documenting each subsystem's public interface.
-### Changed
-- Sketch sources moved into the `BirdsEye/` subfolder so the folder name
- matches `BirdsEye.ino` (Arduino IDE / arduino-cli requirement).
-- DOVEX header read/write now goes through a single tested
- `dovex_header::format()` / `parse()` implementation. On-disk format is
- unchanged (byte-for-byte compatible with existing `.dovex` files).
-
### Removed
- **(Breaking)** The legacy `ENABLE_NEW_UI` compile path and everything it
gated: the manual track/location/direction selection menus, the
@@ -1243,7 +1623,9 @@ Initial tagged release. Core capabilities:
- 8+ OLED display pages, Bluetooth LE file download / settings / track
sync, and a low-power sleep mode.
-[Unreleased]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v3.1.0...HEAD
+[Unreleased]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v4.1.0...HEAD
+[4.1.0]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v4.0.0...v4.1.0
+[4.0.0]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v3.1.0...v4.0.0
[3.1.0]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v3.0.2...v3.1.0
[3.0.2]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v3.0.1...v3.0.2
[3.0.1]: https://github.com/TheAngryRaven/DovesDataLogger/compare/v3.0.0...v3.0.1
diff --git a/CLAUDE.md b/CLAUDE.md
index 63abe76..e6d5f9f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,6 +67,17 @@ Core capabilities:
DovesSensorEgg thermocouple pod's advertising broadcasts (`PW-ADV` v1
and v2), logs `Temp1`/`Junction1`/`Temp2` DOVEX columns + Temp1/Temp2
race pages (subsystem 14)
+- **Loop CPU profiling (beta only)**: `BIRDSEYE_ENABLE_PROFILING` times
+ every subsystem call in `loop()`, shows the breakdown on a LOOP PROFILE
+ race page, and drives pin 30 as a scope output — the measurement behind
+ the nRF52840-vs-nRF5340 board decision. **Takes pin 30 from the boost
+ EN line, so a beta image can never switch the 5 V rail** (subsystem 18)
+- **NeoPixel strip**: 11 WS2812 pixels on the NFC pads converted
+ to GPIO — 2 **user-assignable** status LEDs (eight modes: target RPM,
+ target speed, GPS lock, camera sync, last lap, last sector, EGT, off)
+ flanking a 9-px pace-pip / RPM- or speed-scale strip, with a global
+ brightness cap, boot animation, and a two-stage purple celebration for
+ a session-best sector or lap (subsystem 16)
---
@@ -86,12 +97,14 @@ All sketch sources live in `BirdsEye/` so the folder name matches the
| `gps_config.h` | GPS configuration constants (baud rate, nav rate, serial port) |
| `images.h` | PROGMEM bitmap data — the bird splash only; the crossing animation is generated (`crossing_pattern`) |
| `accelerometer.{h,ino}` | LSM6DS3 IMU init and g-force reads (onboard XIAO Sense) |
-| `bluetooth.{h,ino}` | BLE service (file listing, transfer, settings, track sync), auto-reboot on disconnect; shared peripheral BLE core init (+ Just-Works bonding) + `bleOwner` radio-ownership routing |
+| `bluetooth.{h,ino}` | BLE service (file listing, transfer, settings, track sync), reboot on leaving transfer mode (peer disconnect or manual Exit); shared peripheral BLE core init (+ Just-Works bonding) + `bleOwner` radio-ownership routing |
| `camera_ble.{h,ino}` | Insta360 X4 auto-record BLE glue: peripheral remote GATT (0xCE80), all control via ce82 button notifies, executes `camera_fsm` actions, deferred callback→loop pattern (see subsystem 13) |
| `firmware_ota.{h,ino}` | SD-staged firmware OTA: `FW*` BLE protocol, SD staging, CRC verify, self-flash apply (see subsystem 11) |
| `display_pages.{h,ino}` | All page rendering functions (`displayPage_*()`) |
| `display_ui.{h,ino}` | Display init, button reading (multi-sample debounce), menu navigation, I2C bus recovery |
| `gps_functions.{h,ino}` | GPS init (SparkFun UBX PVT), time conversion, DOVEX logging pipeline, TIMER3 serial buffer ISR, V_BCKP recovery |
+| `neopixel.{h,ino}` | NeoPixel strip glue (subsystem 16): one-time UICR NFC→GPIO ensure, boost-EN power control, 30 Hz compose→cap→show frame loop, sleep/wake hooks; all decision math in the led_* / sector_purple pure units |
+| `profiling.{h,ino}` | Main-loop CPU profiling glue (subsystem 18, beta only): DWT/micros timebase probe, section brackets, the pin-30 scope output, once-a-second rollup. Also the reason a beta build never drives the 5 V boost EN |
| `replay.{h,ino}` | Instant DOVEX header replay |
| `sd_functions.{h,ino}` | SD init, track list/JSON parsing (dual format), track manifest, SD access arbitration |
| `sensoregg.{h,ino}` | SensorEgg wireless EGT: passive BLE scan (observer), scan-callback→loop double buffer, `SENSOREGG_MAC` pairing, Temp1/Junction1 data surface (see subsystem 14) |
@@ -115,9 +128,18 @@ desktop toolchain. This is where logic worth unit-testing lives.
| `dovex_header.{h,cpp}` | DOVEX 1 KB header `format()` / `parse()` |
| `filename_validator.{h,cpp}` | FAT-safe / traversal-proof check for BLE filenames |
| `crc32.{h,cpp}` | CRC-32/IEEE-802.3 (zlib) incremental + hex; pins firmware-OTA CRC to the web client |
+| `ble_stream.{h,cpp}` | BLE file-transfer read-ahead bookkeeping: chunk size from the negotiated MTU, the compacting refill (`Move`), slice/consume, transfer rate. An off-by-one here corrupts a downloaded session, so the index math is host-tested away from the radio |
| `sd_access_policy.{h,cpp}` | SD access arbitration decision table (mode values + grant/deny rules) |
| `lap_format.{h,cpp}` | ms → `M:SS.mmm` lap-time rendering (three zero-minutes styles), used by all display pages |
-| `tach_filter.{h,cpp}` | Tachometer 1-D Kalman filter (predict/update math + Q/R tuning constants) **and the engine geometry** — `revsPerPulse` / `minPulseGapUs` from `spark_mode` + `cylinder_count` |
+| `setting_parse.{h,cpp}` | Strict integer parsing for `/SETTINGS.json` values. Exists because `atoi()` answers 0 for `""` and `"garbage"`, and 0 is an in-range, **destructive** value for the LED keys (`led_brightness` 0 = strip off + boost rail never raised) — so a blank setting read as a deliberate "off". Rejects anything that is not a complete integer, so the caller's range check keeps the compiled-in default |
+| `loop_profile.{h,cpp}` | Main-loop CPU accounting: per-section tick accumulation, saturating (never wrapping — a uint32 of DWT ticks is only ~67 s), and the once-a-second rollup into shares of wall time, loop rate, mean and worst iteration, plus measured idle (`SLP`). **Two clocks on purpose**: durations in TICKS with `ticksPerUs` supplied at rollup (so a sub-microsecond section is not quantised to zero), but the WINDOW closed on `millis()` — DWT counts cycles and stops when the core halts, and using it as a wall clock inflated the very first hardware reading. Board-portable by construction — the nRF5340 comparison needs the same instrument |
+| `local_time.{h,cpp}` | UTC + a fixed signed minute offset → local wall clock (4-digit year, correct month/year/leap rollover both ways) + the `isNight()` window test. **No DST, and NOTHING logged goes through it** — saved data stays UTC (subsystem 17) |
+| `led_frame.{h,cpp}` | NeoPixel pixel layout (11 px: 2 status + 9-px strip), `Rgb`/`Frame` PODs, and **`applyCap()` — the single global-brightness choke point** (post-condition: no channel exceeds the cap) |
+| `led_modes.{h,cpp}` | Strip modes + status actions: pace pip math (ms/m, slower = left/red), generic `ScaleSpec` left-fill (RPM red past halfway, speed with no red band at all), the `StatusAction` threshold/hysteresis/flash table, and `flashOn()` — the ONE definition of flash phase, shared with `led_status` |
+| `led_status.{h,cpp}` | The eight assignable status-LED modes (subsystem 16): the mode enum + strict name parser that the `led_status_left`/`led_status_right` settings store, the GPS and camera readiness ladders, and `evalMode()` — which delegates every threshold mode to `led_modes::evalStatus` rather than re-implementing hysteresis. `Inputs.eggSupported` is why an `egt` LED is dark on a stock build instead of a permanent solid blue |
+| `led_animations.{h,cpp}` | Boot + purple-sector animations as pure functions of `(tMs, seed)` — hash-based sparkles, no rand()/millis(), golden-testable |
+| `sector_purple.{h,cpp}` | The lap/sector CLOSE-EDGE monitor (the name predates half its job): open-time best snapshots + a derived S3 defeat the library's lap-line `updateBestSectors()` race, and the same trick one level up defeats it for `getBestLapTime()`. Emits which sector or lap just closed, its verdict **against the last recorded one** (not the best — that only ever answers purple or red), and the two purple flags. No purple on lap 1 |
+| `tach_filter.{h,cpp}` | Tachometer 1-D Kalman filter — predict/update math, the RPM-aware noise models, the **outlier gate** that keeps one bad ignition edge out of the trace (plan 0009), the `tach_filter` mode parser — **and the engine geometry**: `revsPerPulse` / `minPulseGapUs` from `spark_mode` + `cylinder_count` |
| `camera_fsm.{h,cpp}` | Insta360 auto-record lifecycle FSM (8 states, all debounce/retry/timeout timing + tunables); board-portable core shared with the nRF54 "Falcon" target |
| `insta360_protocol.{h,cpp}` | Insta360 X4 BLE frame builders/parsers (wake advert, remote scan response, ce82 buttons, ce82 GPS/RMC frame, ce81 serial parsing, ce81 `0x10` record-timer state parse) with golden-byte tests |
| `sensoregg_protocol.{h,cpp}` | SensorEgg `PW-ADV` v1+v2 advertising payload parser (magic filter, int16 deci-°C decode with `0x8000`→NaN sentinel, flags, sequence, v2 aux thermistor + battery) + wrap-safe 1 s staleness rule + passive-scan tuning constants |
@@ -157,7 +179,7 @@ handoff spec.
| `API.md` | Canonical WASM API contract (v1): artifact set, method surface, injectPvt schema, deltas from the handoff-spec draft (async `reset()` via module re-instantiation) |
| `wasm/bindings.cpp` | EMSCRIPTEN_KEEPALIVE exports over sim_host.h + getStateJson/getVersion/readFile/listFiles |
| `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 |
+| `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) |
@@ -165,7 +187,7 @@ handoff spec.
| 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 all-flags-off defaults |
+| `.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 |
| `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 |
@@ -195,6 +217,8 @@ handoff spec.
| D3 | Button 3 (Right) | INPUT_PULLUP, RC filter recommended |
| D0 | Tachometer input | INPUT_PULLUP, falling-edge ISR |
| PIN_VBAT / VBAT_ENABLE | Battery ADC | 1510/510 ohm divider, 3.6 V ref |
+| Pin 30 (P0.09, NFC1 pad) | 5 V boost converter EN | HIGH = rail on; LOW retained through System OFF. Needs the one-way UICR NFC→GPIO conversion (subsystem 16). **On a profiling build this pin is the profiling output instead and EN is never driven** (subsystem 18) |
+| Pin 31 (P0.10, NFC2 pad) | NeoPixel data | 11 px WS2812, GRB, 800 kHz. Same UICR requirement; swap with pin 30 in `neopixel.h` if wired the other way |
---
@@ -214,6 +238,9 @@ loop() ~250 Hz
├─ checkAutoIdle() tach: 60s <2mph; manual/speed: 5min <5mph (+ camera stop)
├─ updateGpsLockHold() pin user to tach page until GPS time lock
├─ CAMERA_LOOP() step Insta360 auto-record FSM (GPS/tach fresh)
+ ├─ NEOPIXEL_LOOP() LED strip frame at 30 Hz (also called in parked branches)
+ │ (every call above is PROFILE_SECTION-bracketed; the macro expands to
+ │ the bare call unless BIRDSEYE_ENABLE_PROFILING — subsystem 18)
├─ cameraConsumeAutoStop() camera 30s-engine-off stop → endRaceSession + menu
├─ calculateGPSFrameRate() 1-second PVT counter
├─ readButtons() multi-sample debounce + edge detection
@@ -300,9 +327,11 @@ loop() ~250 Hz
### 2. Tachometer (`tachometer.ino`)
- ISR `TACH_COUNT_PULSE()` fires on falling edge of D0.
-- Minimum pulse gap is **derived**, not fixed: `tach_filter::minPulseGapUs()`
- returns 3 ms ÷ pulses-per-rev with a 750 µs floor, so the ~20 000 true-RPM
- ceiling holds up to four cylinders instead of halving with each one added.
+- Minimum pulse gap comes from the spark mode only: `tach_filter::minPulseGapUs()`
+ returns 3 ms for a plug firing every rev and 6 ms for a 4-stroke single-fire
+ plug, so the ~20 000 RPM ceiling is the same in both modes. Cylinder count is
+ not a term (plan 0014) — one clamped wire never delivers pulses faster than
+ the cylinder it is wrapped around fires.
- **Ring buffer architecture**: ISR timestamps every valid pulse into a
16-entry ring buffer (`tachRingBuf`). The ISR checks full before
publishing (SPSC, one slot sacrificed) and drops + sets
@@ -310,30 +339,77 @@ loop() ~250 Hz
`TACH_LOOP()` then discards the one period spanning the gap. `TACH_LOOP()` drains the buffer
each main-loop iteration, computes mean inter-pulse period from ALL
accumulated pulses, and feeds the result through a 1D Kalman filter.
-- **Kalman filter** replaces the old median-of-3 + EMA. Two floats of
- state (estimate + uncertainty in `tach_filter::Kalman`); the
- predict/update math and tuning constants live in the host-tested
- `tach_filter` pure unit. Process noise
- Q = 800 (tuned for kart engine inertia). Measurement noise R scales
- inversely with pulse count (more pulses = more confident).
+- **Kalman filter** replaces the old median-of-3 + EMA. Estimate +
+ uncertainty in `tach_filter::Kalman`; all math and tuning lives in the
+ host-tested `tach_filter` pure unit. Three properties matter, and all
+ three exist because the pre-0009 filter put visible spikes in the
+ logged trace (plan 0009 has the full derivation):
+ - **An outlier gate, not more smoothing.** The estimator sees ONE
+ number per pulse and its steady-state gain is ~0.4, so a single bad
+ edge is a spike, not a wobble: a ring clearing the 3 ms debounce at
+ 3000 RPM reads as 15 000 RPM. A measurement more than `kGateSigmas`
+ (5) from the estimate is **not folded in — the estimate coasts**.
+ Three *consecutive* rejections is a real step change (clutch dump,
+ spin), so the third is **adopted outright** rather than filtered
+ toward. 5 sigma is loose on purpose: the gate catches the
+ half-/double-period signatures of a spurious/missed spark, which are
+ tens of sigma out, and real acceleration (~25 RPM between pulses at
+ 5500 RPM/s) can never trip it. `k.rejected` counts rejections for the
+ tach page's debug line and **survives the engine-stop reset**.
+ - **Measurement noise knows the RPM** (`measurementNoise()`).
+ RPM = K/period, so a fixed timing error costs RPM²/K of RPM error —
+ QUADRATIC. A flat R was right at ~4000 RPM and far too confident
+ above 8000. It also takes `revsPerPulse`: a twin's shorter periods
+ are genuinely noisier at the same RPM.
+ - **Process noise is a RATE** (RPM²/s, `processNoise()`), multiplied by
+ the **engine time the batch spans** — `TACH_LOOP()` passes the sum of
+ the periods it just consumed, never wall clock. Charging a fixed Q
+ per update made the filter 4x looser at 12 000 RPM than at 3000, and
+ looser again whenever an SD stall batched pulses together.
+ The **first measurement after a reset is adopted whole**: with no prior
+ there is nothing to filter against, and a slow climb out of 0 RPM would
+ arm the gate partway up and get the engine's own speed rejected.
+- **`tach_filter` setting — the track-side A/B switch** (default
+ `smooth`). `smooth` is everything above; `legacy` reproduces the
+ pre-0009 filter bit for bit (fixed Q/R, no gate) so new sessions
+ compare against logs already collected; `raw` bypasses the estimator
+ entirely, publishing what the periods say, spikes included — which is
+ how you tell a dirty pickup from a bad filter. Read once at boot into
+ `tachFilterMode` and applied inside `TACH_LOOP()`, so there is still
+ exactly ONE RPM number in the firmware. Anything unrecognised means
+ `smooth`.
- Time-based debounce only. Old volatile flag gate removed — ISR
body is trivially fast (<1 µs) and cannot cause interrupt storms.
-- **True RPM, one correction, one place.** The pickup counts ignition
- pulses; the tach reports revolutions, and those differ on anything but a
- single-cylinder engine firing every rev.
- `pulses_per_rev = cylinder_count × (spark_mode == wasted ? 1.0 : 0.5)`,
- and the reciprocal (`tachRevsPerPulse`, set once at boot) is applied at
- the period→RPM conversion in `TACH_LOOP()` — **before the Kalman
- filter**, because the filter's tuning is in true-RPM units (Q = 800 RPM²
- models crank inertia), so correcting afterwards would filter each engine
- type differently. Defaults (1 cylinder, wasted) give 1.0 — byte-identical
- to the old hardcoded behaviour.
+- **One correction, one place, and cylinder count is NOT in it** (plan
+ 0014). There is one sense wire and one clamp, and it goes around one
+ spark plug wire — so the pickup sees ONE cylinder's ignition whatever
+ the engine has. The whole geometry is the spark mode:
+ `revs_per_pulse = (spark_mode == wasted ? 1.0 : 2.0)`. `tachRevsPerPulse`
+ is set once at boot and applied at the period→RPM conversion in
+ `TACH_LOOP()` — **before the Kalman filter**, because the filter's tuning
+ is in RPM units (Q = 800 RPM² models crank inertia), so correcting
+ afterwards would filter each engine type differently.
**No consumer may re-derive or re-apply this**: display, DOVEX rows, the
camera FSM and auto-race all read the already-corrected
`tachLastReported`. Any new consumer must too.
-- Because RPM is now true RPM, the **thresholds mean what they say on every
- engine** — auto-race (>500), camera wake/record/stop (500/1500/300) no
- longer fire at half the real RPM on a twin.
+- **Why the cylinder term is gone.** Plan 0003 shipped
+ `pulses_per_rev = cylinder_count × sparkFactor`, which is only right for
+ a clamp on a shared coil/king lead. On this hardware it divided RPM by
+ the cylinder count: a V8 on a traditional magneto, configured honestly
+ as 8 cylinders + single fire, read **an eighth** of its crank speed. The
+ plan papered over it by redefining the setting as "cylinders the pickup
+ *sees*" — a field named Cylinders that must not hold the engine's
+ cylinder count. `cylinder_count` is now the engine's actual count, and
+ it is descriptive: `tach_filter::rpmIsInferred()` uses it for the
+ warning, nothing else reads it.
+- **The accepted consequence, stated to the user.** Above one cylinder the
+ crank speed is INFERRED from one cylinder's firing rate — between
+ firings it is an assumption, and a cylinder that stops firing reads as a
+ stopped engine. That is how every clamp-on inductive tach works; the
+ settings UI, the README table and the boot debug line say so rather than
+ leaving it to be discovered from a trace.
+- Thresholds mean what they say on any engine whose clamped plug fires
+ every rev — auto-race (>500), camera wake/record/stop (500/1500/300).
- `tachLastReported` updates every main-loop call (~250 Hz). Consumers
(display at 3 Hz, logging at 25 Hz) rate-limit themselves.
- 500 ms timeout sets RPM to 0 (engine stopped), resets Kalman state.
@@ -438,7 +514,10 @@ loop() ~250 Hz
- Boot/menu: `PAGE_BOOT` (999), `PAGE_GPS_STATUS` (900, satellite status
page every boot lands on — driven by `gpsStatusPageLoop()`, buttons
deliberately no-op'd in `displayLoop()`), `PAGE_MAIN_MENU` (-1).
- - Racing: `GPS_DEBUG` (3, GPS pipeline counters + lap debug) and
+ - Racing: `GPS_PROFILE` (2, LOOP PROFILE — **only exists on a
+ profiling build**, subsystem 18; being below `GPS_DEBUG` in a
+ contiguous rotation, it drags the two diagnostic pages in too),
+ `GPS_DEBUG` (3, GPS pipeline counters + lap debug) and
`GPS_STATS` (4, battery/sats/SD/track status) through `LOGGING_STOP`.
The two diagnostic pages are **hidden by default at runtime**: the
`debug_pages` setting (default `hide`) leaves `runningPageStart` at
@@ -451,10 +530,20 @@ loop() ~250 Hz
master/release default) the block closes up behind the tach page and
`LOGGING_STOP` is 12 instead of 14, same reshuffle idea as
`ENDURANCE_MODE`. Page ids are internal — nothing external sees them.
- - Replay: `PAGE_REPLAY_FILE_SELECT` (-3), `PAGE_REPLAY_RESULTS` (-8),
- `PAGE_REPLAY_EXIT` (-9).
- - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB submenu,
+ - Replay: `PAGE_REPLAY_FILE_SELECT` (-3, sessions + a trailing `Back`
+ row — see `replayItemCount()` in `replay.h`, the single source of that
+ layout for the renderer, the menu limit and the select handler),
+ `PAGE_REPLAY_RESULTS` (-8), `PAGE_REPLAY_EXIT` (-9).
+ - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB/Back submenu,
`PAGE_USB_STORAGE` (-5) USB drive active.
+ - **Every menu page carries a Back/Cancel row.** Both transfer modes and
+ the replay browser leave by rebooting or by walking forward, and the
+ idle-shutdown timer only runs on the main menu (and the fault page), so
+ a menu without one is a hard trap: the only escape is the unlabelled
+ Select+side 5 s reboot combo. The deliberate exceptions are
+ `PAGE_INTERNAL_FAULT` (buttons disabled, own idle shutdown),
+ `PAGE_SD_FORMAT` (nothing to go back to) and the race rotation (leaves
+ via `LOGGING_STOP`, speed-gated).
- BLE: `PAGE_BLUETOOTH` (-2).
- Camera: `PAGE_PAIR_CAMERA` (-6) pairing / paired-status management,
`PAGE_CAMERA_SERIAL_ENTRY` (-7) manual 6-char serial entry fallback,
@@ -522,7 +611,42 @@ loop() ~250 Hz
download directly. `FIRMWARE_VARIANT` is set by the per-FQBN build flag
`-DBIRDSEYE_BOARD_SENSE` / `-DBIRDSEYE_BOARD_NONSENSE` (defaults to
`sense`).
-- MTU negotiation (requests 247, default 23).
+- **Download throughput — three levers, all verified after the fact**
+ (plan 0008). The connect callback fires the three asks (MTU 247, 2M PHY,
+ Data Length Extension) and then **checks them**, because firing is not
+ the same as landing: the SoftDevice runs one link-layer control procedure
+ at a time, so the DLE ask queued right behind the PHY ask can come back
+ `NRF_ERROR_BUSY` into a return value nobody reads. `bleTuneLink()` runs on
+ the main loop at +500 ms (read + correct) and +1500 ms (final readback):
+ - **DLE.** `getDataLength()` still at the 27-byte default means every
+ 244-byte notify fragments into ten link-layer packets — the single
+ biggest tax on a download. Re-request it with nothing else in flight.
+ - **Connection interval.** The advertised preference stays
+ `setConnInterval(6, 12)` (7.5–15 ms) because desktop/Android honour it
+ and must not be slowed. **iOS is required to reject anything under
+ 15 ms** (Apple accessory rules), so it silently keeps its own choice —
+ commonly 30 ms, i.e. half the connection events. Only when the measured
+ interval is slower than the target does the device make a second,
+ Apple-compliant `requestConnectionParameter(12)` ask. Never lower an
+ already-fast interval.
+ - **SD off the radio's critical path.** Chunks stream from a 4 KB
+ read-ahead (`ble_stream::ReadAhead` + `bleStreamBuf`), refilled with one
+ aligned multi-sector read, instead of a `FatFile::read()` between every
+ notify. Refills are **compacting** so every notify but the file's last
+ carries a full chunk. A failed notify simply does not `consume()` — with
+ the bytes in RAM there is no file position to rewind, so the old
+ `seekCur()` hole-punching hazard is structurally gone.
+ Burst sending is bounded by **wall clock** (`kBurstBudgetMs`, 20 ms), not
+ a packet count: the bound exists to keep the exit button and WDT serviced,
+ and a fixed count is a wildly different amount of time on a fast link vs a
+ slow one. A blocked `notify()` is the flow control and is *desirable* — it
+ means the radio is saturated.
+- **The transfer page reports what it got**: live KB/s, the SD clock actually
+ in force (`sdActiveSpiHz()` — the 8 MHz parked bump falls back to 2 MHz
+ silently), the negotiated link-layer PDU, and the ATT payload. Accessors
+ `bleTransferRateBps()` / `bleLinkDataLength()` / `bleLinkChunkSize()`. This
+ exists because a 4x download regression was only noticeable as "the
+ percentage is creeping".
- **No SdFat in the callback task — ever.** Every SD-touching command
(`LIST`, `GET:`, `DELETE:`, `TLIST`, `TGET:` via the deferred
`fileCmdBuffer`; settings, `TPUT:`/`TDEL:`, and `FW*` via their own
@@ -574,11 +698,20 @@ loop() ~250 Hz
raw image chunks to `fwReceiveChunk()` while `fwReceiving()`. The request
characteristic max length was raised from 64 to **244** so ~240-byte image
chunks fit. `BLUETOOTH_LOOP()` calls `FW_OTA_LOOP()` each iteration.
-- **Auto-reboot on BLE disconnect**: `bleDisconnectCallback()` flags a
- deferred teardown that `BLUETOOTH_LOOP()` runs on the main loop —
- `NVIC_SystemReset()` after a 100 ms delay so new settings take effect
- without a manual power cycle, plus `fwReset()` to abort any in-flight OTA
- and free the staging file + SD access. **Exception — OTA apply**: if an
+- **Leaving transfer mode always reboots** — both ways out:
+ - *Peer disconnect*: `bleDisconnectCallback()` flags a deferred teardown
+ that `BLUETOOTH_LOOP()` runs on the main loop — `NVIC_SystemReset()`
+ after a 100 ms delay so new settings take effect without a manual power
+ cycle, plus `fwReset()` to abort any in-flight OTA and free the staging
+ file + SD access.
+ - *Manual Exit* (`bleExitTransferMode()`): the parked-loop Exit button
+ runs `BLE_STOP()` then the same 100 ms-delay reboot. Before 4.1.0 a
+ manual exit dropped back to the menu without rebooting, so settings
+ written over BLE silently didn't apply until the next power cycle. The
+ SIM stub returns after stopping (no reboot) so the golden menu walk can
+ exit the Bluetooth page; `display_ui.ino`'s `PAGE_BLUETOOTH` handler is
+ that sim-only path (on hardware the `bleActive` parked branch owns the
+ button). **Exception — OTA apply**: if an
apply has been requested (`fwApplyRequested()`), the teardown skips *both*
the abort and the reboot. After `FWAPPLY` the web app disconnects on purpose
to let the device self-flash; rebooting here would discard the staged image
@@ -607,9 +740,30 @@ loop() ~250 Hz
Always reads fresh from disk (no cache).
- `setSetting(key, value)` does read-modify-write to update a single key.
- Uses `SD_ACCESS_TRACK_PARSE` mode for brief SD access.
-- Separate `StaticJsonDocument<512>` — does not share the track parser's
+- Separate `StaticJsonDocument<1024>` — does not share the track parser's
`JSON_BUFFER_SIZE` buffer.
-- Total RAM cost: ~1 KB (512-byte file buffer + 512-byte JSON document).
+- Total RAM cost: ~2 KB (1024-byte file buffer + 1024-byte JSON document).
+- **There are TWO parsers of this file, and both are sized by
+ `SETTINGS_JSON_CAPACITY` (`settings.h`).** `settings.ino` owns
+ `getSetting()`/`setSetting()`; `bluetooth.ino`'s `SLIST` handler has its
+ own buffer + document to enumerate the file for the companion app. They
+ drifted once — plan 0010 raised settings.ino's pair 512 → 1024 and
+ missed the BLE copy, so `SLIST` read 511 B of a 538 B file and answered
+ `SERR:PARSE` on every device while `SGET`/`SSET` still worked. The
+ shared constant is what makes that impossible now; if you add a third
+ parser, size it from the same macro.
+- **The two 1024s must stay equal, and adding keys is not free.** Every
+ read path caps at `sizeof(settingsFileBuffer) - 1`, so a file bigger
+ than the buffer parses as `IncompleteInput` and *every* key read
+ fails — which `SETTINGS_SETUP()` reads as corruption, quarantines, and
+ regenerates, whereupon `ensureDefaultSettings()` grows it back over the
+ cap and the device loses its settings on a **boot loop**. Both were
+ 512 until plan 0010: the 18-key file was already 436 B and four new
+ keys put it at 543, over the 511-byte read cap *and* past the old
+ document's capacity (a `<512>` doc returns `NoMemory` at 22 string
+ pairs). `setSettingInner()` now refuses any write whose
+ `measureJson()` exceeds the buffer or whose document `overflowed()`,
+ so the failure is a loud refused write rather than a silent brick.
### 9. CourseManager Integration
@@ -708,9 +862,18 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
System OFF with the conn LED still driven; GPIO state is retained
there, hence the "blue light stays on after sleep" field report) →
`DISPLAY_SLEEP()` → `GPS_SLEEP()` (u-blox software backup, µA, config
- retained while powered; TIMER3 stopped) → IMU power rail off. The
- charging-loop soft resume (`softResumeFromCharging()`) restarts the
- egg scanner via `SENSOREGG_WAKE()`; BLE/camera stay lazy.
+ retained while powered; TIMER3 stopped) → IMU power rail off →
+ `NEOPIXEL_SLEEP()` (blank while 5 V is up, data LOW, then boost EN
+ LOW — before the charging branch, so the strip is dark on the cable
+ too). **`NEOPIXEL_SLEEP()` is not a no-op on a flag-OFF build**: it
+ still drives boost EN low when `UICR->NFCPINS` shows the pads were
+ already converted by an earlier beta build, because the driven level
+ is what survives System OFF and a floating EN leaves the rail up (the
+ same retention as the conn-LED report above). An unconverted board is
+ never touched. The charging-loop soft resume
+ (`softResumeFromCharging()`) restarts the egg scanner via
+ `SENSOREGG_WAKE()` and re-raises the strip via `NEOPIXEL_WAKE()`;
+ BLE/camera stay lazy.
- **System OFF entry** (`shutdownSystemOff()`, no return): wait for the
entry combo's buttons to release (a held button = SENSE satisfied =
instant wake-reset), **sample the tach line's parked idle level**
@@ -839,8 +1002,11 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
`msc_flush_cb` → `syncDevice()` + `SD.cacheClear()`. These run on the
USBD task, not the main loop.
- **UI flow**: main-menu **Transfer** → `PAGE_TRANSFER_MENU` (Bluetooth /
- USB). **Bluetooth** keeps the existing `BLE_SETUP()` + `PAGE_BLUETOOTH`
- path untouched. **USB** → `PAGE_USB_STORAGE` + `USB_MSC_ENABLE()`.
+ USB / Back). **Bluetooth** keeps the existing `BLE_SETUP()` +
+ `PAGE_BLUETOOTH` path untouched. **USB** → `PAGE_USB_STORAGE` +
+ `USB_MSC_ENABLE()`. **Back** returns to the main menu — both transfer
+ modes exit by rebooting, so without it this page could only be left by
+ starting a transfer.
- **Opt-in enumeration**: `USB_MSC_SETUP()` (called from `setup()` after a
successful `SD_SETUP()`) only registers the callbacks — no drive is
presented at boot, so charging/plug-in behaves as before.
@@ -861,13 +1027,18 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
page, and watches VBUS: if the cable is unplugged it calls
`USB_MSC_DISABLE()` so the SD lock and fast SPI clock can't leak past the
session.
-- **Exit = reboot**: `USB_MSC_DISABLE()` drops media-ready, **quiesces** (waits
- up to 1 s for the write block callback to go quiet — `setUnitReady(false)`
- only blocks *new* SCSI commands, so an in-flight `WRITE10` keeps calling
- `msc_write_cb` on the USBD task, and resetting mid-write would truncate a
- file / corrupt the FAT), `syncDevice()`s
- the card, then calls `NVIC_SystemReset()` (mirrors the BLE auto-reboot on
- disconnect). The reboot drops the MSC interface and remounts a clean
+- **Exit = reboot**: `USB_MSC_DISABLE()` drops media-ready, **detaches USB**
+ (`TinyUSBDevice.detach()` — `setUnitReady(false)` only blocks *new* SCSI
+ commands, so without the detach the host keeps issuing traffic and an
+ in-flight `READ10`/`WRITE10` keeps calling the block callbacks on the USBD
+ task), then **drains**: waits (WDT-fed, up to 4 s — SD garbage collection
+ can stall one `writeSectors()` 100 ms–2 s) until no block callback is
+ executing and none has finished for 100 ms, tracked by
+ `mscIoInFlight`/`mscLastIoMs` around all three callbacks — reads and flush
+ included. Only then `syncDevice()` + `NVIC_SystemReset()` (mirrors the BLE
+ transfer-mode exit reboot). The 4.0.0 exit tracked only write *entry*
+ times, so a callback still on the SPI bus raced the main-loop sync and the
+ wedge came back via the watchdog instead of the clean reset. The reboot drops the MSC interface and remounts a clean
filesystem, so host edits are picked up without any SdFat cache-coherency
dance. Triggered by the on-device Exit button or a cable unplug.
- **Mutex**: the whole session holds `SD_ACCESS_USB_MSC`, so logging,
@@ -1051,8 +1222,14 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
(`display_pages.ino` + the page-constant block in `BirdsEye.ino`), and
returns BLE to lazy init. `1` (passed by `beta.yml`, and by
`compile-sketch.yml` for PRs targeting `BETA`) is everything described
- below. The DOVEX `Temp1`/`Junction1` columns are written either way —
- `nan` when the POC is off — so the log format never forks by channel.
+ below. **Plan 0013 closed the three leaks**: the DOVEX
+ `Temp1`/`Junction1`/`Temp2` columns, the `temp1_alert_c` setting, and
+ the status LED that was hardwired to Temp1 are all now behind the flag
+ too — so the log format DOES fork by channel (13 stock columns vs 16),
+ reversing the earlier uniform-shape rule, and a stock logger no longer
+ shows a permanent solid-blue "no probe signal" pixel all race. The
+ `egt` status mode still parses and round-trips on any build; only its
+ rendering is gated (`led_status::Inputs.eggSupported`).
Keep any new egg code behind the flag.
- **What (POC)**: a wireless thermocouple pod (DovesSensorEgg repo) reads a
K-type EGT probe via MCP9600 and broadcasts EGT + cold junction in BLE
@@ -1135,7 +1312,10 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
range) → type picker → line menu → per-line Point A/B → the capture hold.
Input rides the sketch's existing `menuSelectionIndex`/`menuLimit`
machinery; `rowCount()` supplies the limit, which changes with course
- type (sprint grows a Finish row).
+ type (sprint grows a Finish row). **Every screen ends in a Cancel/Back
+ row**, the two entry screens included — the type picker's is the only way
+ out of the creator for a user with no track in range, since that entry
+ path skips the prompt.
- **Point capture averages, it does not snapshot**: a 3 s hold folds every
fresh PVT into a mean. Under `kCaptureMinFixes` (8) usable fixes the hold
**fails** rather than averaging noise into a timing line; fixes worse
@@ -1186,6 +1366,330 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
- **Sim**: fully exercised — five golden fixtures walk the real menus,
inject real PVT, run a real averaging hold, and lock the rendered pixels.
+### 16. NeoPixel Strip (`neopixel.{h,ino}`, `led_frame/led_modes/led_animations/sector_purple.{h,cpp}`)
+
+- **BUILD FLAG — `BIRDSEYE_ENABLE_NEOPIXEL` (`project.h`)**: `1` on
+ **every** channel as of 4.1.0 — master, beta and release — so the strip
+ is a core feature rather than a special build. Everything below is in
+ every image. The cost is charged fleet-wide and cannot be taken back:
+ the first boot after updating performs the ONE-WAY UICR NFC→GPIO
+ conversion and self-resets once, on every device, LEDs wired or not.
+ `0` (no shipped channel sets it) compiles the subsystem out — no
+ Adafruit NeoPixel dependency, and no UICR write on a board that has not
+ already been converted; on one that HAS, the `#else` stubs still hold
+ boost EN low, because a floating EN leaves the 5 V rail up through
+ System OFF.
+- **Hardware**: 11 WS2812 pixels fed by an Adafruit 5 V boost converter.
+ Pixels 0 and 10 are status indicators; pixels 1–9 are the strip with
+ pixel 5 the centerline. Pin 30 (P0.09/NFC1) drives the boost EN
+ (HIGH = 5 V rail on), pin 31 (P0.10/NFC2) is the data line — both
+ `#define`s in `neopixel.h`, swap to match wiring. **The chain is
+ wired data-in on the physical RIGHT** (chain px 0 = rightmost LED):
+ everything renders in logical left-to-right space and
+ `led_frame::physicalIndex()` mirrors the whole chain — status LEDs
+ included — once at push time (`kChainReversed`, true for this build).
+- **NFC→GPIO is a one-time runtime UICR write** (`NEOPIXEL_SETUP()`):
+ if `UICR->NFCPINS` still has the PROTECT bit, unlock NVMC, program
+ `0xFFFFFFFE`, relock, `NVIC_SystemReset()` — NFCPINS latches only at
+ reset. **ONE-WAY** (undo = full chip erase / bootloader reflash;
+ accepted, NFC is never used). Deliberately NOT the
+ `-DCONFIG_NFCT_PINS_AS_GPIOS` core flag: that's consumed by the
+ core's `system_nrf52840.c` (a `.c` file `compiler.cpp.extra_flags`
+ can't reach) and would silently not apply to IDE builds. Direct NVMC
+ access is illegal under the SoftDevice, so `NEOPIXEL_SETUP()` runs
+ **before `CAMERA_SETUP()`/`SENSOREGG_SETUP()`** (which
+ `bleCoreEnsureInit()` on beta) and before `wdtSetup()` arms the
+ watchdog. Every later boot skips the branch.
+- **The global brightness cap is THE invariant**: modes and animations
+ author colors in full 0–255; `led_frame::applyCap()` scales every
+ channel by cap/255 exactly once, at push time, in `npxPushFrame()`.
+ After it, no channel exceeds the cap — host-tested as a
+ post-condition. Never use `strip.setBrightness()` (lossy buffer
+ rewrite, spreads the invariant). **Which** cap is
+ `npxEffectiveBrightness()`: `led_brightness` by day,
+ `led_brightness_night` inside the local night window (subsystem 17).
+ `led_brightness` 0 = LEDs disabled: the boost rail is never even
+ enabled — **a night cap of 0 is NOT the same thing** (blank frames,
+ rail stays up; power-cycling the boost converter at 19:00 mid-session
+ is not a brightness change).
+- **Frame loop** (`NEOPIXEL_LOOP()`, self-throttled 30 Hz, called after
+ `CAMERA_LOOP()` AND inside both parked branches so the strip blanks
+ rather than freezes): snapshot inputs → step the `sector_purple`
+ monitor → compose by priority — **boot animation > overrev
+ whole-chain red flash > purple animation > (parked ‖ !raceActive ‖
+ brightness 0 → off) > race rendering** — → `applyCap` → show.
+- **Strip policy** (plan 0007 order + the 0013 speed arm): the 9-px bar
+ is off outside a race session (driving aid, not menu bling); the two
+ status LEDs follow their own rule below. In race, first match wins:
+ 1. **Engine stopped** (`raceEngineStopped()`: tach-proven session —
+ `raceEntryCause == RACE_ENTRY_TACH`, which manual/speed promote
+ to — at 0 RPM) → bar OFF; status LEDs stay live (a hot engine
+ cooling after a stall is when the temp alert matters). The OLED
+ pace page shows `STOPPED` on the same condition. Self-clears on
+ restart; no-tach devices can never trip it.
+ 2. **No GPS lock** (`!(gpsData.fix && gpsData.timeValid)` — the
+ log-file-creation gate) → green **search pip** bouncing end-to-end
+ (`renderSearchPip`, 1.6 s round-trip triangle wave).
+ 3. **Pace valid** (`activeTimerRaceStarted() && laps >= 1 && !(sprint
+ && between-runs)`, mirroring the OLED pace page) → the **pace
+ pip**: `activeTimerPaceDifference()` is **ms per meter**, positive
+ = slower; full deflection ±1.0 ms/m (`kPaceFullScaleMsPerM`,
+ 0.25/pixel), ±0.125 deadband = dim-white centerline only. Slower =
+ LEFT of center in red, faster = RIGHT in green.
+ 4. **Tach session** (`raceEntryCause == RACE_ENTRY_TACH`) → **RPM
+ scale** (green filling left→right, red past halfway, ceiling =
+ `target_rpm`).
+ 5. Else → **SPEED scale** (plan 0013), ceiling = `target_speed_mph`,
+ **green all the way up** (`kSpeedRedFrac = 1.0`). Without a tach
+ the RPM scale was nine dark pixels until the first lap landed.
+ `RACE_ENTRY_TACH` is the gate rather than a live `rpm > 0` test
+ because manual/speed sessions promote to it at >500 rpm
+ (`idle_policy::tachProven`), so it means "has never seen an
+ engine" and cannot flicker between two scales at every stall. The
+ RPM bar's red band means "approaching the limiter"; there is no
+ equivalent hazard in reaching a target speed, so painting it red
+ would invert the meaning of the same nine pixels.
+- **Status LEDs are USER-ASSIGNED** (plan 0013, `led_status`): pixel 0
+ from `led_status_left`, pixel 10 from `led_status_right`, each one of
+ eight modes — `off` / `rpm` (red flash ≥ `target_rpm`, clears at 97%,
+ 100 ms) / `speed` (red flash ≥ `target_speed_mph`, clears 2 mph
+ below, dark with no fix) / `gps` (steady red no sats · yellow sats but
+ no fix · blue fix but no time lock · green locked) / `camera` (dark
+ unpaired · yellow session up but camera not · **flashing** blue linked
+ yet not ce82-subscribed, i.e. we cannot command it · steady blue ready
+ · red recording) / `lap` / `sector` (steady green–red–purple verdicts,
+ below) / `egt` (Temp1 tri-state: red flash ≥ `temp1_alert_c`, clears
+ 20 °C below, OFF when good, **solid blue** when the probe signal is
+ NaN/stale — a dropout is information).
+ - `rpm`/`speed`/`egt` build a `StatusAction` on the stack and delegate
+ to `led_modes::evalStatus` — hysteresis, latch-release-on-invalid
+ and flash phase have exactly one implementation, with the
+ regression tests they already had. **Never re-implement them.**
+ - A NaN/stale source (checked with `isNanF()`, NEVER `isnan()` —
+ `-Ofast`) always releases the latch. So does leaving race mode, and
+ so does a pixel whose mode is not currently being rendered —
+ a latch left set would flash the instant the next session starts.
+ - **`egt` is dark on a build without `BIRDSEYE_ENABLE_SENSOREGG`**
+ (`led_status::Inputs.eggSupported`), because there is no probe there
+ to lose. Before plan 0013 pixel 10 was hardwired to this mode, so a
+ stock logger showed the blue no-signal state for the whole of every
+ race. The setting still parses and round-trips either way — only the
+ rendering is gated.
+ - **`gps` and `camera` stay lit on the main menu**
+ (`led_status::activeOutsideRace()`); every other mode, and the bar,
+ is race-only.
+ - **Overrev** is not a status mode — it reuses `StatusAction` as a
+ whole-chain action: ≥ `overrev_limit` (setting, 0 = disabled)
+ flashes ALL 11 px red at 100 ms until RPM falls below
+ `target_rpm × 0.97`, the engine-is-broken signal, ranked above the
+ purple celebration.
+- **Lap/sector close-edge monitor** (`sector_purple`, host-tested — the
+ filename predates half its job): the library updates best-sector times
+ **at the start/finish crossing**, not at sector lines, so the monitor
+ snapshots each sector's best when the sector OPENS, closes S1/S2 on
+ `getCurrentSector()` transitions, and derives S3 on the lap edge as
+ `lastLapTime − s1 − s2` — immune to the lap-line race. `bestLapAtOpen`
+ is the same trick one level up, because `getBestLapTime()` is folded at
+ the crossing too. Sprint-first wrappers feed it:
+ `activeTimerCurrentSector()`, `activeTimerLapSectorTime(n)`,
+ `activeTimerBestSectorTime(n)` (WaypointLapTimer → 0, sector half
+ stays idle) plus `activeTimerBestLapTime()`.
+ - **Two outputs.** Purple flags → `neopixelNotifyPurpleSector()` /
+ `neopixelNotifyPurpleLap()`. And a **verdict** per close edge, held
+ by the glue until the next one, driving the `lap` / `sector` status
+ modes: green faster than the LAST recorded one, red slower, purple a
+ session best, **dark when there is nothing to compare against**.
+ - **Verdicts compare the last recorded time, NOT the best** — against
+ the best you only ever get purple or red, which says nothing about
+ whether you are improving. Purple still requires beating a nonzero
+ prior best, so lap 1 is dark everywhere.
+ - **Lap tracking does not need sector lines.** Only the sector half is
+ gated on `sectorsConfigured`; a Lap Anything session still reports
+ lap verdicts. Equal times read as "slower" (strict improvement), and
+ a sector that never closed emits no event at all rather than a bogus
+ verdict.
+- **Animations** (`led_animations`): pure functions of `(tMs, seed)` —
+ sparkles hash `(seed, timeSlot, pixel)`, no `rand()`/`millis()`
+ inside, so frames are golden-testable. Boot: 2.6 s hue comet circling
+ the 11 px + fade-out + white sparkles, plays over the GPS status
+ page. Seeds come from `micros()` (house entropy rule).
+- **Sleep/wake**: `NEOPIXEL_SLEEP()` (in `enterShutdown()` with the IMU
+ rail-off, before the charging branch — so the strip is dark while
+ charging too) blanks the strip while 5 V is up, then data LOW, then
+ boost EN LOW — driven-LOW is retained through System OFF (the "blue
+ LED stays on" precedent). `NEOPIXEL_WAKE()` (charging soft-resume)
+ re-raises EN, waits the 5 ms settle, re-inits the strip.
+- **Radio/timing safety**: Adafruit_NeoPixel's nRF52 `show()` grabs a
+ FREE PWM instance (EasyDMA, interrupts ON, ~0.4 ms for 11 px ≈ 1%
+ CPU at 30 Hz). This sketch uses no `tone()`/`analogWrite()`, so
+ PWM0–2 are always free; TIMER3's GPS drain and the tach ISR are
+ unaffected. If all PWMs were ever occupied the library bit-bangs
+ **with interrupts off** — never create that path. `show()` also
+ mallocs/frees a ~560 B pattern buffer per call: same-size alloc/free
+ is fragmentation-benign, it is NOT a leak.
+- **Sim**: `neopixel.ino` excluded from the sim TU (BLE-module
+ precedent), surface no-op'd in `module_stubs.cpp`; the four pure
+ units build into the sim via `SIM_CORE_SOURCES`.
+- **Still not built**: per-mode threshold/colour customisation (today a
+ mode's colours and hysteresis are the unit's, only the assignment is
+ the user's), temp scales through the same `ScaleSpec`, a C/F display
+ preference, and a strip-mode (bar) selector to match the status-LED
+ one.
+
+### 17. Local Time (`local_time.{h,cpp}`)
+
+- **What**: UTC plus a fixed signed minute offset (`utc_offset_min`),
+ giving the device a local wall clock. Its ONE consumer today is the
+ NeoPixel day/night brightness swap, which needs "7am" to mean the
+ driver's 7am (plan 0010).
+- **NOTHING LOGGED GOES THROUGH THIS.** DOVEX row timestamps are Unix
+ epoch ms (UTC by definition), and the header `datetime`, the log
+ filenames and the generated course names are all still UTC. That is
+ deliberate, not an oversight: a log is routinely *viewed* somewhere
+ other than where it was recorded, so the conversion belongs to the
+ viewing app, which knows the reader's preference. Do not wire
+ `local_time` into the logging pipeline.
+- **No DST, on purpose.** A fixed offset walks the boundary an hour
+ twice a year, which is beneath the resolution of a dim-after-dark
+ gate. 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 changes — which
+ is why `DateTime` carries a **4-digit year** (the sketch's
+ `gpsData.year` is 2-digit; callers add 2000) and why the leap rule is
+ `gps_time::isLeapYear` reused rather than re-derived.
+- **Minutes, not hours** — India +330, Newfoundland −210, Chatham
+ +765. An out-of-band offset (beyond ±840) is **ignored, not clamped**:
+ a corrupt setting must not be able to walk the calendar, so it falls
+ back to 0 = UTC = the pre-0010 behaviour.
+- **`isNight(m, dayStart, nightStart)`** tests the window
+ `[nightStart, dayStart)` **modulo the day**, so the ordinary wrapped
+ case (19:00 → 07:00) needs no special casing at the call site. Equal
+ bounds = empty window = never night, which is how the swap is disabled
+ without a separate enable flag.
+- **No clock means DAY.** `gpsData.timeValid` needs the module's
+ `fullyResolved` (~12.5 min worst case from a cold start);
+ `npxEffectiveBrightness()` renders at the day cap until it lands.
+ Guessing night would bring the strip up dark and read as dead hardware.
+
+### 18. Loop CPU Profiling (`profiling.{h,ino}`, `loop_profile.{h,cpp}`)
+
+- **BUILD FLAG — `BIRDSEYE_ENABLE_PROFILING` (`project.h`)**: `0` in
+ master/release (the whole subsystem vanishes — the section brackets
+ are macros that expand to the bare call, so the loop dispatch is
+ byte-identical to what it always was), `1` on the beta channel
+ (`beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA`).
+ Keep any new profiling code behind the flag.
+- **What it is for.** Two board questions gate the commercial design —
+ nRF52840 or nRF5340 (a dedicated network core would take the BLE
+ stack off this CPU), and whether leaving the Arduino core for the
+ Nordic SDK is worth the one-way effort. Both are answerable by
+ measurement, and nothing in the firmware measured anything finer than
+ a `HAS_DEBUG` millisecond-resolution "SLOW LOOP" print. Plan 0011.
+- **Idle is MEASURED, not assumed.** The first version of this
+ subsystem asserted that `loop()` runs back to back so the duty cycle
+ is 100% by construction and a duty cycle should never be added. That
+ assumption got baked into the measurement and produced a wrong first
+ reading (see the two-clocks note below). The rollup now computes
+ `busyPermille` — wall time actually spent executing `loop()` — and
+ reports the balance as the `SLP` slot: scheduler dispatch, other
+ FreeRTOS tasks, and CPU sleep. Alongside it, what always carried the
+ information: the SHAPE of an iteration — mean and **worst-case**
+ length (an SD garbage-collection stall of 100 ms–2 s never shows in a
+ mean), the per-subsystem split, and how much no subsystem accounts
+ for.
+- **Two instruments, and the second exists to check the first**:
+ - **The pin.** Pin 30 goes HIGH for the span being profiled and LOW
+ outside it — loop period off the rising edges, span cost off the
+ high time, no software in the measurement path.
+ `PROFILING_PIN_SECTION` picks the span: whole loop body by default,
+ or `-DPROFILING_PIN_SECTION=PROF_SEC_GPS` (and friends — a
+ preprocessor mirror of `loop_profile::Section`, `static_assert`ed
+ against it, because `#if` cannot evaluate a scoped C++ name).
+ - **The rollup.** The same sections timed in software and rolled up
+ once a second onto the `GPS_PROFILE` page.
+- **THE PIN COSTS THE 5 V RAIL — this is the whole trade.** Pin 30 is
+ the NeoPixel boost converter's EN line and cannot be both. The
+ profiler takes it, so a profiling build never drives EN (setup, sleep
+ and the charging-loop resume all no-op via `npxBoost*()` in
+ `neopixel.ino`) and the regulator sits at its hardware default
+ (EN pulled up = rail on). That works because the rail does not need
+ firmware control, only switchability — and switching it is a
+ requirement of **use**, not of **testing**. Two accepted consequences:
+ the rail stays up through System OFF (levels are retained there and
+ the driven LOW was the only thing holding it down — the "blue conn LED
+ stays on after sleep" precedent), so a beta unit asleep on a battery
+ with a strip wired drains it; and 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.
+- **Timebase: DWT, verified, with a self-announcing fallback.** Most
+ sections are well under a microsecond, so `micros()` alone quantises
+ half of them to zero. `profEnableDwt()` turns on the Cortex-M4 cycle
+ counter (64 ticks/µs at 64 MHz) and then **reads it across a spin to
+ prove it moved** — a debug probe can hold TRCENA off and CYCCNTENA is
+ architecturally optional, so setting the bit is not evidence. On
+ fallback the page prefixes its first row with `*`.
+- **TWO CLOCKS — do not collapse them back into one.** DWT counts CPU
+ CYCLES, so it stops dead when the core halts (WFE/WFI in the FreeRTOS
+ idle task, `sd_app_evt_wait`). It measures DURATIONS; it is not a
+ clock. The rollup window is closed on `millis()` and every share is
+ computed against that wall time. Using DWT to close the window was the
+ first real bug here: a "one second" window was one second of
+ CPU-awake time, so the loop rate came out multiplied by the sleep
+ factor (the first hardware run pegged the display clamp) and every
+ share was a fraction of awake time wearing a wall-time label. The
+ `micros()` fallback never had the bug — it is a real clock. `Report`
+ carries `awakeUs` next to `windowUs` so the discrepancy stays visible.
+- **The pure unit works in TICKS**, taking `ticksPerUs` only at rollup,
+ so shares come out of raw ticks and lose nothing. Accumulators
+ **saturate, never wrap**: a uint32 of DWT ticks is only ~67 s and a
+ rollup can be arbitrarily late behind a stall — a pegged window reads
+ as pegged, a wrapped one reads as near-idle, which is a lie.
+- **`OTH` is reported, not hidden**: loop time no section bracketed. It
+ is the honesty check on the instrumentation — if it is large, work is
+ happening where `loop()` is not looking. **`SLP`** is its counterpart:
+ wall time not inside `loop()` at all. All 14 slots are shares of the
+ same wall-clock second, so they sum to ~1000‰ — an invariant a reader
+ can check on the page at a glance.
+- **A scope guard, not a `*_LOOP()`.** Both parked branches (`bleActive`,
+ `usbMscActive`) return early and `enterShutdown()` never returns, so
+ the whole-iteration timing AND the rollup live in `~ProfLoopScope()`
+ (`PROFILE_LOOP_SCOPE()` at the top of `loop()`). `PROFILING_SLEEP()`
+ parks the pin LOW on the shutdown path the destructor never reaches.
+ This module deliberately has no `PROFILING_LOOP()`.
+- **Overhead is inside the numbers, on purpose — and it is NOT small.**
+ A bracket is two counter reads plus a saturating add (~30 cycles,
+ ~0.5 µs; ~6–8 µs per iteration across all 13). That was written off
+ as "under 0.1%" against the ~4 ms iteration this file assumed for
+ years; the first hardware run measured a mean iteration **under
+ 100 µs**, which puts the instrument at order 10% of what it reports
+ and means the un-instrumented loop is faster than the rate shown. A
+ bracket still lands in the section it brackets rather than in `OTH`,
+ because for a subsystem's SHARE that is the honest accounting — but a
+ section reading under ~1% is at its own bracket's noise floor, so do
+ not rank those against each other. The pin edges
+ are `#if`'d rather than compared at runtime: with the default
+ whole-loop setting the comparison is provably false for every section,
+ and a dead branch in the two hottest functions in the firmware is
+ exactly the cost a profiler must not add.
+- **The page** (`GPS_PROFILE` = 2, first of the race rotation on a
+ profiling build; the session still LANDS on the speed page, so it is
+ three Lefts away). Eight rows, no title: a stats line (`14481Hz 61us
+ mx42` — mean in µs below 1 ms and ms above, worst iteration always ms;
+ `*` = degraded timebase, `!` = pin refused) then seven rows of two
+ slots covering all 12 sections plus `OTH` and `SLP`. The stats row's
+ clamps are reciprocal-aware rather than fixed: the first hardware run
+ came back `999Hz av0.0` because both fields had been sized from the
+ stale ~250 Hz assumption, and a clamp that hides the finding is worse
+ than no clamp. **Adding a section means finding it a row** — the grid
+ is exactly full, and `SLP`'s slot came from folding the boot-page
+ state machines (`PGE`) into `DSP`. Because the rotation is
+ a contiguous range and this page sits below `GPS_DEBUG`, a profiling
+ build effectively forces `debug_pages=show`.
+- **Sim**: `profiling.ino` is excluded from the sim TU (BLE-module
+ precedent) and stubbed in `module_stubs.cpp` — there is no pin and
+ host timings of a virtual-clock loop would mean nothing. The pure unit
+ still builds in via `SIM_CORE_SOURCES`.
+
---
## Data Formats
@@ -1214,10 +1718,25 @@ timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x
egg link is stale (>1 s), the egg reports an invalid probe/divider, or
(for `Temp2`) the egg is v1 — a dropout must be a visible gap, never a
held value. These fields never cause a GPS row to be skipped.
+ **THE THREE COLUMNS EXIST ONLY ON A `BIRDSEYE_ENABLE_SENSOREGG` BUILD**
+ (plan 0013), so a stock log has 13 data columns and a beta log 16.
+ Until 0013 they were written on every channel purely so the shape never
+ forked; three dead `nan` columns on every row of every stock log paid
+ for nothing. Readers must key off the CSV header line (all three
+ optional there) rather than assuming a column count — the companion
+ app's `doveParser.ts` already builds a name→index map, which is why
+ this cost no client change.
- **Crash safety**: file created with pre-filled newlines to 1024 bytes
before any data. Header written on session end. If header is empty
(crash), GPS data after 1024 is still valid.
- **Filename**: `20YYMMDD_HHMM.dovex`
+- **Everything here is UTC and stays that way.** The row `timestamp` is
+ Unix epoch ms, the header `datetime` is the UTC wall clock, and the
+ filename is stamped from the same UTC fields. The device's
+ `utc_offset_min` setting (subsystem 17) is presentation-only and must
+ never be applied on this path — timezone display is the viewing app's
+ job, since a log is often read in a different zone than it was
+ recorded in.
- 1 KB handles ~100 laps (8 chars per lap time). Extremely unlikely to exceed.
### Track JSON (`/TRACKS/*.json`)
@@ -1277,10 +1796,22 @@ the one loaded). Sector lines stay optional — zero, one, or two.
"debug_pages": "hide",
"spark_mode": "wasted",
"cylinder_count": "1",
+ "tach_filter": "smooth",
"driver_name": "Driver",
"lap_detection_distance": "7",
"waypoint_detection_distance": "30",
- "waypoint_speed": "30"
+ "waypoint_speed": "30",
+ "led_brightness": "64",
+ "target_rpm": "15000",
+ "overrev_limit": "0",
+ "target_speed_mph": "60",
+ "led_status_left": "rpm",
+ "led_status_right": "egt",
+ "temp1_alert_c": "650",
+ "utc_offset_min": "0",
+ "led_brightness_night": "16",
+ "led_day_start_hour": "7",
+ "led_night_start_hour": "19"
}
```
@@ -1297,8 +1828,20 @@ the one loaded). Sector lines stay optional — zero, one, or two.
| `waypoint_speed` | int | `30` | Speed threshold (mph) for waypoint/detection |
| `spark_mode` | string | `"wasted"` | Ignition rate: `wasted` = 1 spark/rev (2T, or 4T wasted spark); `single` = 1 spark per 2 revs (4T single-fire). Anything other than an explicit `single` is treated as `wasted` |
| `display_invert` | string | `"normal"` | Panel colours: `normal` = lit-on-black as shipped, `inverted` = black-on-lit. Anything other than an explicit `inverted` means normal |
-| `debug_pages` | string | `"hide"` | Race-rotation diagnostic pages (`GPS_DEBUG` + `GPS_STATS`): `hide` = rotation starts at the speed page (end-user default), `show` = diagnostics restored at the front. Anything other than an explicit `show` means hide. No-op under `ENDURANCE_MODE` (already starts at speed) |
-| `cylinder_count` | int | `1` | Cylinders the **pickup sees** — a clamp on one plug wire of a twin sees ONE. Only a shared coil / all-cylinder harness sees them all |
+| `debug_pages` | string | `"hide"` | Race-rotation diagnostic pages (`GPS_DEBUG` + `GPS_STATS`): `hide` = rotation starts at the speed page (end-user default), `show` = diagnostics restored at the front. Anything other than an explicit `show` means hide. Also swaps the tachometer page's subtext line for the tach filter diagnostic (`max:NNNNN S rj:NN`, plan 0009). No-op on the rotation under `ENDURANCE_MODE` (already starts at speed) |
+| `cylinder_count` | int | `1` | The engine's **actual** cylinder count. **Does not scale RPM** (plan 0014): one clamp on one plug wire sees one cylinder, so `spark_mode` alone sets the geometry. Above 1 it means crank speed is *inferred* from one cylinder's ignition pulses — standard clamp-on-tach behaviour, warned about in the settings UI. Clamp 1–16 |
+| `tach_filter` | string | `"smooth"` | RPM estimator (plan 0009). `smooth` = outlier gate + RPM-aware noise models; `legacy` = the pre-0009 filter bit for bit, for A/B against older logs; `raw` = no estimator at all, the `rpm` column is exactly what the pickup delivers. Anything else means `smooth`. Diagnostic knob — the intent is one session each at the track, not a permanent tuning dial |
+| `led_brightness` | int | `64` | NeoPixel global brightness cap 0–255 — no LED channel ever exceeds it (`led_frame::applyCap`). `0` disables the LEDs entirely (boost rail never enabled). Written AND parsed on every channel (`ensureDefaultSettings()` + the boot block in `BirdsEye.ino`); only its *use* is compiled out with the flag. A non-numeric value keeps the 64 default — via `setting_parse::parseIntSetting`, never `atoi()`, because `atoi("")` is 0 and 0 here means "LEDs off" |
+| `target_rpm` | int | `15000` | True RPM SHIFT/warning point: RPM-scale ceiling and the `rpm` status-LED flasher threshold. Clamp 1000–20000 (tach filter's ceiling). Was `rev_limit` before plan 0013 — a device carrying the old key has its value migrated into this one on first boot and the old key removed |
+| `overrev_limit` | int | `0` (disabled) | True RPM PROBLEM limit (plan 0007): past it the whole 11-px chain flashes red (outranks the purple celebration) and the tach page shows `*OVER REV*`; latch clears below `target_rpm × 0.97`. 0 = off (no chain flash, no header); else clamp 1000–20000 |
+| `temp1_alert_c` | int | `650` | **SensorEgg builds only** since plan 0013 — a stock image neither writes nor reads it. Temp1 (EGT) alert threshold in **Celsius** for the `egt` status mode: red flash at/above, clears 20 °C below, solid blue when the probe signal is NaN/stale. Clamp 50–1200 |
+| `utc_offset_min` | int | `0` | Minutes east of UTC (US Central standard `-360`, India `330`, Newfoundland `-210`). Clamp ±840; out of band keeps 0 (= UTC). **Presentation only** — nothing logged is converted (subsystem 17) |
+| `led_brightness_night` | int | `16` | NeoPixel cap 0–255 used inside the night window. `0` blanks the strip but leaves the 5 V rail UP — only `led_brightness` 0 cuts the rail |
+| `led_day_start_hour` | int | `7` | **Local** hour the day cap takes over. Clamp 0–23 |
+| `led_night_start_hour` | int | `19` | **Local** hour the night cap takes over. Clamp 0–23. Equal to `led_day_start_hour` = swap disabled (one cap around the clock) |
+| `target_speed_mph` | int | `60` | Ceiling of the LED speed bar on a session with no tachometer, and the `speed` status mode's threshold. **Stored in mph**; the companion app converts for display. Clamp 5–250 — the floor is 5, not 0, because a zero ceiling trips `renderScale`'s span guard and blanks the bar, which is indistinguishable from dead hardware |
+| `led_status_left` | string | `"rpm"` | What the LEFT status pixel shows: `off`/`rpm`/`speed`/`gps`/`camera`/`lap`/`sector`/`egt` (subsystem 16). **Strictly** parsed — anything unrecognised keeps the compiled-in default rather than darkening the LED or picking another mode |
+| `led_status_right` | string | `"egt"` (SensorEgg) / `"lap"` (stock) | Same, for the RIGHT status pixel. `egt` renders dark on a build without SensorEgg support, but still parses and still round-trips over `SGET`/`SLIST` |
- Created automatically on first boot with random BLE values.
- Missing keys auto-populated on boot via `ensureDefaultSettings()`.
@@ -1348,11 +1891,15 @@ the one loaded). Sector lines stay optional — zero, one, or two.
| Course creator name format | `N{YYMMDD}_{HHMM}` (+ `MMDDHHMM` short name) | `course_creator.h` |
| Sprint prune order | renamed-in-app first, then oldest `date_created`; confirm only when a device-named course would go | `course_prune.h` |
| Track JSON coordinate precision | 8 decimals (~1.1 mm) | `track_json.h` |
-| Tach min pulse gap | 3 ms ÷ pulses-per-rev, floor 750 µs | `tach_filter.h` (`minPulseGapUs`) |
-| Tach pulses per rev | `cylinder_count` × (`wasted` ? 1.0 : 0.5); default 1.0 | `tach_filter.h` (`revsPerPulse`) |
+| Tach min pulse gap | 3 ms (`wasted`) / 6 ms (`single`) — same ~20 000 RPM ceiling either way | `tach_filter.h` (`minPulseGapUs`) |
+| Tach revs per pulse | `wasted` ? 1.0 : 2.0 — **no cylinder term** (plan 0014) | `tach_filter.h` (`revsPerPulse`) |
| Tach ring buffer | 16 entries | `BirdsEye.ino` |
-| Tach Kalman Q | 800 RPM² | `tach_filter.h` |
-| Tach Kalman R_BASE | 2500 RPM² | `tach_filter.h` |
+| Tach Kalman Q (legacy mode) | 800 RPM² per update | `tach_filter.h` |
+| Tach Kalman process noise (smooth) | 80 000 RPM²/s × engine time, clamped 0.5 s | `tach_filter.h` |
+| Tach Kalman R_BASE | 2500 RPM² floor | `tach_filter.h` |
+| Tach measurement noise (smooth) | R_BASE + (80 µs × RPM²/K + 1% × RPM)² | `tach_filter.h` |
+| Tach outlier gate | 5 sigma; 3 consecutive rejects → adopt; armed after 4 updates | `tach_filter.h` |
+| Tach filter mode | `tach_filter` setting: `smooth` (default) / `legacy` / `raw` | `tach_filter.h` |
| Track manifest scan throttle | 1 Hz | `BirdsEye.ino` |
| Tach stop timeout | 500 ms | `BirdsEye.ino` |
| Display refresh | 3 Hz | `display_ui.ino` |
@@ -1361,8 +1908,13 @@ the one loaded). Sector lines stay optional — zero, one, or two.
| SD SPI clock (transfer) | 8 MHz (`SD_SPI_SPEED_FAST`) | `BirdsEye.ino` |
| Battery check interval | 5 s | `BirdsEye.ino` |
| BLE default MTU | 23 | `bluetooth.ino` |
+| BLE target conn interval (adaptive 2nd ask) | 12 units = 15 ms, Apple's floor | `bluetooth.ino` |
+| BLE link-layer PDU "extended" floor | 100 B (27 = DLE never happened) | `bluetooth.ino` |
+| BLE transfer read-ahead | 4096 B (`kReadAheadSize`) | `ble_stream.h` |
+| BLE burst budget | 20 ms wall clock (`kBurstBudgetMs`) | `ble_stream.h` |
+| BLE max notify payload | 244 B (`kMaxNotifyLen`) | `ble_stream.h` |
| JSON buffer (`JSON_BUFFER_SIZE`) | 8192 (SIM builds too) — read buffer + `trackJson` doc | `BirdsEye.ino` |
-| Settings JSON buffer | 512 | `settings.ino` |
+| Settings file buffer / JSON doc | 1024 each (keep equal; see subsystem 8) | `settings.ino` |
| Settings file path | `/SETTINGS.json` | `settings.ino` |
| Track upload buffer | `JSON_BUFFER_SIZE` (8192) | `bluetooth.ino` |
| GPS serial buffer | 4096 | `gps_functions.ino` |
@@ -1389,6 +1941,29 @@ the one loaded). Sector lines stay optional — zero, one, or two.
| SensorEgg scanner self-heal | 30 s no packet → stop+start kick | `sensoregg_protocol.h` |
| SensorEgg RSSI floor | −90 dBm | `sensoregg_protocol.h` |
| SensorEgg pairing MAC | `SENSOREGG_MAC` (all-zeros = any egg) | `sensoregg.h` |
+| NeoPixel strip flag | `BIRDSEYE_ENABLE_NEOPIXEL`, default **1** on every channel since 4.1.0 | `project.h` |
+| Loop profiling flag | `BIRDSEYE_ENABLE_PROFILING`, default 0; 1 on the beta channel | `project.h` |
+| Profiling pin / span | 30 (`PROFILING_PIN`, = boost EN) / whole loop (`PROFILING_PIN_SECTION`) | `profiling.h` |
+| Profiling rollup window | 1000 ms (`PROFILING_WINDOW_MS`) | `profiling.h` |
+| Profiling timebase | DWT cycle counter, 64 ticks/us; `micros()` fallback (page shows `*`) | `profiling.ino` |
+| NeoPixel pins | 30 = boost EN, 31 = data (NFC pads, post-UICR) | `neopixel.h` |
+| NeoPixel layout | 11 px: status 0 + strip 1–9 (center px 5) + status 10 | `led_frame.h` |
+| LED frame rate | 30 Hz (`NPX_FRAME_INTERVAL_MS` 33) | `neopixel.ino` |
+| LED brightness default | 64 / 255 (`led_brightness`; 0 = disabled) | `settings.ino` |
+| Pace pip full scale / deadband | ±1.0 ms/m (0.25 per pixel) / ±0.125 | `led_modes.h` |
+| RPM scale red fraction | 0.5 (red past halfway) | `led_modes.h` |
+| Rev flasher clear / EGT clear delta | 97% of `target_rpm` / alert − 20 °C | `led_modes.h` |
+| GPS-search pip bounce period | 1600 ms round trip (`kSearchBouncePeriodMs`) | `led_modes.h` |
+| Overrev latch clear | `target_rpm × 0.97` (same point as the warning flasher) | `neopixel.ino` |
+| Boot / purple animation | 2600 ms / 1600 ms sector, 2600 ms lap (2 waves) | `led_animations.h` |
+| Status LED modes | `off`/`rpm`/`speed`/`gps`/`camera`/`lap`/`sector`/`egt`; `gps`+`camera` also lit on the menu | `led_status.h` |
+| Status LED defaults | left `rpm`; right `egt` on a SensorEgg build, `lap` otherwise | `settings.ino` |
+| Speed bar ceiling / red band | `target_speed_mph` (default 60, clamp 5–250) / none (`kSpeedRedFrac` 1.0) | `settings.ino`, `led_modes.h` |
+| Speed alert clear delta / flash | 2 mph (`kSpeedClearDeltaMph`) / 250 ms half-period | `led_modes.h` |
+| Camera "cannot command" flash | 400 ms half-period (`kCameraFlashHalfPeriodMs`) | `led_status.h` |
+| UTC offset band | ±840 min (±14 h), `kOffsetMinLimit`; out of band = 0 | `local_time.h` |
+| LED day / night window | local 07:00 → 19:00 (`led_day_start_hour` / `led_night_start_hour`); equal = disabled | `settings.ino` |
+| LED night brightness default | 16 / 255 (`led_brightness_night`; 0 = blank, rail stays UP) | `settings.ino` |
---
@@ -1404,6 +1979,7 @@ the one loaded). Sector lines stay optional — zero, one, or two.
| SdFat | SD card (FAT16/32) |
| DovesLapTimer | Lap/sector timing (external: TheAngryRaven/DovesLapTimer). CI refs: `BETA`-targeted builds track the library's `BETA` branch; master/release builds pin `v4.3.0` (bump deliberately) |
| Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant, ±16g) |
+| Adafruit NeoPixel | WS2812 strip driver (subsystem 16). **Only compiled/linked when `BIRDSEYE_ENABLE_NEOPIXEL` is set** — the include sits inside the `#if` in `neopixel.ino`, so a master/release image carries none of it |
| Bluefruit nRF52 | BLE (built into board package) |
| Adafruit TinyUSB | USB Mass Storage (`Adafruit_USBD_MSC`); built into board package |
@@ -1456,8 +2032,9 @@ This device operates in ignition-noise environments. Three layers of defense:
`compiler.cpp.extra_flags` property — a second `--build-property` for
one key replaces the first). Local setup: CONTRIBUTING.md "Local build
flags".
-- **Feature flags** (`project.h`, both default `0`, both tested with `#if`
- so an explicit `-DFLAG=0` wins):
+- **Feature flags** (`project.h`, tested with `#if` so an explicit
+ `-DFLAG=0` wins; `BIRDSEYE_ENABLE_NEOPIXEL` defaults to `1`, the rest
+ to `0`):
- `BIRDSEYE_ENABLE_ONBOARD_CHARGING` — off in **every** channel. See
subsystem 10: HICHG hold + the USB charging UX. The hardware now has
an external charging circuit.
@@ -1465,6 +2042,16 @@ This device operates in ignition-noise environments. Three layers of defense:
(`beta.yml`, plus `compile-sketch.yml` for PRs targeting `BETA` so the
flag-on build is compile-checked before it reaches the publish
workflow). See subsystem 14.
+ - `BIRDSEYE_ENABLE_PROFILING` — off in master/release, **on in beta**
+ (`beta.yml`, plus `compile-sketch.yml` for PRs targeting `BETA`).
+ See subsystem 18: it takes pin 30 from the NeoPixel boost EN line,
+ so a beta image can never switch the 5 V rail.
+ - `BIRDSEYE_ENABLE_NEOPIXEL` — **on everywhere since 4.1.0** (the
+ `project.h` default; no workflow needs to pass it). The first boot of
+ any 4.1.0+ image performs the ONE-WAY UICR NFC→GPIO conversion and
+ self-resets once, on every device. This is the one flag whose default
+ is 1 — see subsystem 16 and the upgrade note at the top of
+ CHANGELOG.md's 4.1.0 section.
When adding a flag: give it a `#ifndef` default in `project.h`, decide
its per-channel value in the workflows, and document it here + in
CONTRIBUTING.md's flag table.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 452a084..d60db35 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,7 +79,9 @@ so an explicit `-DFLAG=0` still turns the feature off.
| Flag | Default | Enabled by | Effect when `1` |
|---|---|---|---|
| `BIRDSEYE_ENABLE_ONBOARD_CHARGING` | `0` | nothing — off in every channel | Holds the BQ25100 HICHG pin high for ~100 mA fast charge and runs the USB charging UX (VBUS wake shortcuts to the charge screen; the main menu drops into the charging loop after `USB_MENU_CHARGE_IDLE_MS`). Off, the firmware leaves HICHG alone and an external charging circuit owns the battery. The VBUS park at shutdown happens either way — see ARCHITECTURE.md. |
-| `BIRDSEYE_ENABLE_SENSOREGG` | `0` | `beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA` | Compiles in the wireless-EGT POC: passive BLE scanner, Temp1 race page, and BLE core up at boot. Off, the accessors return NaN, so `Temp1`/`Junction1` still log as `nan` and the log format is unchanged. |
+| `BIRDSEYE_ENABLE_SENSOREGG` | `0` | `beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA` | Compiles in the wireless-EGT POC: passive BLE scanner, Temp1 race page, and BLE core up at boot. Off, the accessors return NaN AND (since plan 0013) the `Temp1`/`Junction1`/`Temp2` DOVEX columns, the `temp1_alert_c` setting and the `egt` status-LED mode are compiled out — so a stock log has 13 data columns where a beta log has 16. Readers key off the CSV header line, where all three are optional. |
+| `BIRDSEYE_ENABLE_NEOPIXEL` | **`1`** | every channel (the `project.h` default — no workflow passes it) | Compiles in the NeoPixel strip subsystem (plan 0006): on first boot it programs `UICR->NFCPINS` to convert the NFC pads to GPIO (**one-way** — undoing it needs a full chip erase) and self-resets once, then drives pin 30 as the 5 V boost EN and pin 31 as WS2812 data. **The only flag defaulting to 1**, since 4.1.0 — that conversion is therefore charged to every device in the field, LEDs wired or not. Forcing `=0` compiles it out; on a board already converted the stubs still hold boost EN low. Needs the `Adafruit NeoPixel` library. |
+| `BIRDSEYE_ENABLE_PROFILING` | `0` | `beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA` | Compiles in main-loop CPU profiling (plan 0011): every subsystem call in `loop()` is timed, rolled up once a second onto a **LOOP PROFILE** race page, and pin 30 is driven as a scope-readable profiling output. **It takes pin 30 away from the NeoPixel boost converter's EN line**, so a profiling build can never switch the 5 V rail — the regulator stays at its hardware default (on) even through System OFF, where the level is retained. A profiling unit left asleep on a battery with LEDs wired will drain it, and an EN jumper left connected gets chopped at loop rate. Bench builds only. Set `-DPROFILING_PIN_SECTION=PROF_SEC_` to point the pin at one subsystem instead of the whole iteration. |
### arduino-cli
The exact invocation CI uses is in
diff --git a/README.md b/README.md
index 4ba8239..7e5d51e 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@
A high-precision GPS-based lap timer and data logger designed for motorsports and track day enthusiasts. Features 25Hz logging, sector timing, RPM monitoring via tachometer, and multiple customizable display pages.
+
+
+
@@ -220,9 +223,9 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto
| `lap_detection_distance` | Crossing detection threshold in meters | `7` |
| `waypoint_detection_distance` | Waypoint proximity zone in meters (Lap Anything) | `30` |
| `waypoint_speed` | Minimum speed in mph to activate lap timing | `30` |
-| `spark_mode` | How often the ignition fires: `wasted` = once per revolution (2-stroke, or 4-stroke wasted spark), `single` = once per two revolutions (4-stroke single-fire) | `wasted` |
+| `spark_mode` | How often the plug on the clamped wire fires — the **only** setting that scales RPM: `wasted` = once per revolution (2-stroke, or 4-stroke wasted spark), `single` = once per two revolutions (4-stroke single-fire, i.e. a traditional distributor or magneto) | `wasted` |
| `display_invert` | Screen colours: `normal` is the white-on-black it has always been, `inverted` swaps them (black text on a lit screen) for glare | `normal` |
-| `cylinder_count` | Cylinders the **pickup sees** — a clamp on one plug wire of a twin sees ONE; only a shared coil or all-cylinder harness sees them all | `1` |
+| `cylinder_count` | The engine's **actual** cylinder count — enter 8 for a V8. It does **not** scale RPM: the pickup is one clamp on one plug wire, so it sees one cylinder's ignition and `spark_mode` alone sets the math. ⚠️ Above 1 cylinder, crank RPM is **inferred from that one cylinder's ignition pulses** — between firings the reading is an assumption, and a cylinder that drops out reads as a stopped engine. This is normal, expected behaviour for every clamp-on inductive tach | `1` |
## Data Format
@@ -400,7 +403,7 @@ DovesDataLogger/ # repo root
- **Dead Time**: Volatile flag gating prevents interrupt storms from noisy ignition pickups
- **Update Rate**: 3Hz with EMA filter (alpha 0.20)
- **Timeout**: 500ms with no pulse = engine stopped (RPM 0)
-- **Configuration**: 1 pulse per revolution (adjust `tachRevsPerPulse` for multi-cylinder)
+- **Configuration**: `spark_mode` sets the pulses per revolution on the clamped wire — 1 for a 2-stroke or wasted-spark plug, 0.5 for a 4-stroke single-fire (distributor/magneto) plug. The engine's cylinder count does **not** enter it: one clamp reads one cylinder, and its firing rate is the crank speed
### Display Update Rate
- **Refresh**: 3Hz (reduces power consumption)
diff --git a/docs/plans/0003-rpm-spark-cylinder-settings.md b/docs/plans/0003-rpm-spark-cylinder-settings.md
index 52be932..a4b07d9 100644
--- a/docs/plans/0003-rpm-spark-cylinder-settings.md
+++ b/docs/plans/0003-rpm-spark-cylinder-settings.md
@@ -1,5 +1,13 @@
# RPM Accuracy — Spark Type & Cylinder Count Settings
+> **SUPERSEDED IN PART by [0014](0014-tach-rpm-single-pickup.md).**
+> The `pulses_per_rev = cylinder_count × sparkFactor` geometry below is
+> WRONG for this hardware: there is one sense wire and one clamp, on one
+> plug wire, so the pickup never sees more than one cylinder and the
+> cylinder term divided RPM (a V8 read an eighth of its crank speed).
+> `spark_mode` and everything else here still stand; `cylinder_count`
+> remains a setting but no longer reaches the math.
+
> Status: **SHIPPED.** Split out of plan 0002 (sprint mode); independent of
> it. Prompted by the same autocross-kart user group: unknown engine, "kart
> kart or some wacky 2cyl monster", so the logger can no longer assume one
diff --git a/docs/plans/0006-neopixel-strip.md b/docs/plans/0006-neopixel-strip.md
new file mode 100644
index 0000000..a385542
--- /dev/null
+++ b/docs/plans/0006-neopixel-strip.md
@@ -0,0 +1,275 @@
+# 0006 — NeoPixel strip subsystem (2 status LEDs + 9-px strip)
+
+## The hardware
+
+An 11-pixel WS2812 (NeoPixel) strip was added to the device, powered by an
+Adafruit 5 V boost converter with an ENABLE pin. The two nRF52840 NFC pads
+are converted to plain GPIO to drive it:
+
+| Pin | P-number | Role |
+|---|---|---|
+| Arduino 30 (`PIN_NFC1`) | P0.09 | Boost converter EN — HIGH = 5 V rail on, LOW = off |
+| Arduino 31 (`PIN_NFC2`) | P0.10 | NeoPixel data (GRB, 800 kHz) |
+
+Both pads are mapped in the Seeed XIAO variant (`g_ADigitalPinMap[30] = 9`,
+`[31] = 10`), so `pinMode`/`digitalWrite`/Adafruit_NeoPixel work on them
+directly once NFC protection is off. The assignment between the two pads is
+arbitrary — `neopixel.h` holds both as single-point `#define`s so they can be
+swapped to match the actual wiring.
+
+Pixel layout: **pixel 0 and pixel 10 are status indicators**; **pixels 1–9
+are the strip**, with pixel 5 the centerline. The chain is wired with
+data-in on the physical RIGHT end (chain pixel 0 = rightmost LED), so
+the whole chain is mirrored relative to how the driver reads it:
+renderers author in logical left-to-right space and
+`led_frame::physicalIndex()` (`kChainReversed = true`) maps
+logical→wire once, at push time.
+
+## NFC → GPIO: runtime UICR write, not a core build flag
+
+The nRF52840 ships with the NFC pads dedicated to NFCT; converting them to
+GPIO means programming `UICR->NFCPINS` bit 0 to 0, which latches only at
+reset. **This is one-way in practice** (undoing it needs a full chip erase =
+bootloader reflash) — accepted for this project; NFC is never used.
+
+Two ways to do it, and we deliberately chose the second:
+
+1. `-DCONFIG_NFCT_PINS_AS_GPIOS`: consumed by the *core's*
+ `system_nrf52840.c` — a `.c` file, which the repo's established
+ `compiler.cpp.extra_flags` mechanism does not reach. It would need a
+ second `compiler.c.extra_flags` build property in every workflow AND
+ local IDE configuration nobody will remember.
+2. **Runtime write in `NEOPIXEL_SETUP()`** (what we do): if
+ `NRF_UICR->NFCPINS` bit 0 is still set, unlock NVMC, write
+ `0xFFFFFFFE` (a 1→0-only word write — no erase needed), relock, and
+ `NVIC_SystemReset()`. One reset per device lifetime; every later boot
+ skips the branch entirely. Works identically for CI, IDE, and OTA
+ builds.
+
+Constraints that dictate the placement of `NEOPIXEL_SETUP()` in `setup()`:
+
+- **Before the SoftDevice comes up.** Direct NVMC access is illegal once the
+ SoftDevice is enabled; on the beta channel `SENSOREGG_SETUP()` calls
+ `bleCoreEnsureInit()` at boot, so the neopixel setup must run earlier.
+ It sits right after the settings block, before `CAMERA_SETUP()`.
+- **Before `wdtSetup()`** (armed last in `setup()`), so the one-time reset
+ can't race the watchdog.
+- After `SETTINGS_SETUP()`, so `led_brightness` is readable (brightness 0 =
+ LEDs disabled entirely — the boost is never even enabled).
+
+The reset lands in `captureBootWakeCause()` as a plain soft reset — a normal
+boot, no special handling.
+
+**The flag-off build must never touch UICR or drive pins 30/31.** The entire
+module body is inside `#if BIRDSEYE_ENABLE_NEOPIXEL`; the `#else` stubs do
+nothing (sensoregg.ino pattern).
+
+## The global brightness cap — the core requirement
+
+One rule: **once set, no LED may ever exceed the cap.** Enforced in one
+place — `led_frame::applyCap()`, called exactly once per frame in the push
+path. Every mode and animation authors colors in full 0–255 and never
+worries about brightness; the cap is a post-condition tested by sweeping
+caps × saturated frames in the host tests. We deliberately do NOT use
+`Adafruit_NeoPixel::setBrightness()` (it rewrites the pixel buffer lossily
+and would spread the invariant across call sites).
+
+Default `led_brightness` = 64 (~25 %). 11 px full-white at 255 is ~660 mA
+on the 5 V rail; at 64 it's ~165 mA — inside the boost's budget and sane on
+battery, still clearly visible in daylight peripheral vision.
+
+## Architecture
+
+Pure logic (host-tested, Arduino-free) does everything except talk to pins:
+
+| Unit | Owns |
+|---|---|
+| `led_frame.{h,cpp}` | Pixel layout constants, `Rgb`/`Frame` PODs, integer color scaling, **`applyCap()`** |
+| `led_modes.{h,cpp}` | Pace pip math, generic `ScaleSpec` fill (RPM now, temps later), the `StatusAction` table + `evalStatus()` hysteresis/flash |
+| `led_animations.{h,cpp}` | Boot + purple-sector animations as pure functions of `(tMs, seed)` |
+| `sector_purple.{h,cpp}` | Session-best ("purple") sector detection, poll-and-diff |
+
+The glue (`neopixel.{h,ino}`) snapshots inputs each frame, composes by
+priority, applies the cap, and pushes to the strip:
+
+```
+NEOPIXEL_LOOP() (30 Hz self-throttle)
+ ├─ inputs: tachLastReported, activeTimerPaceDifference() + validity,
+ │ sensoreggEgtC() (isNanF-guarded), raceActive, parked flags
+ ├─ sector_purple::update() → nonzero: restart purple animation
+ ├─ compose (first match wins):
+ │ 1. boot animation running
+ │ 2. purple animation running
+ │ 3. parked (BLE/USB) ‖ !raceActive ‖ brightness==0 → all off
+ │ 4. strip: pace valid ? pace pip : RPM scale
+ │ status px0/px10: evalStatus(action table)
+ ├─ led_frame::applyCap(frame, settingLedBrightness)
+ └─ strip.setPixelColor()×11, strip.show()
+```
+
+### Strip mode policy
+
+Off outside a race session — the strip is a driving aid, menu glow burns
+battery. In race: **RPM scale** until pace is valid, then the **pace pip**.
+Pace validity mirrors the OLED pace page:
+`activeTimerRaceStarted() && activeTimerLaps() >= 1` and not
+sprint-between-runs. Sprint between runs falls back to the RPM scale.
+
+### Pace pip
+
+`activeTimerPaceDifference()` is **ms per meter**, positive = slower than
+best. Full deflection at ±1.0 ms/m (`kPaceFullScaleMsPerM`) — on a ~1.2 km
+kart lap that's ~1.2 s/lap, a pin-the-needle delta; 0.25 ms/m per pixel
+step. Deadband ±0.125 (half a step) shows the dim-white center pixel =
+"on pace". Slower = pip LEFT of center in red, faster = RIGHT in green.
+
+### Scale mode (RPM, later temps)
+
+Generic `ScaleSpec {min, max, redFrac, lowColor, highColor}` — fill from
+the left, lit pixels at/past `redFrac` of the fill render the high color.
+RPM uses `{0, rev_limit, 0.5, green, red}` — red past halfway, per spec.
+The same renderer will serve EGT/temp scales when phase 2 makes the mode
+selectable.
+
+### Status LED actions — the phase-2 assignability hook
+
+Each status LED is driven by a `StatusAction` POD:
+`{source, threshold, clearBelow, color, flashHalfPeriodMs}` evaluated
+against per-frame values with a hysteresis latch. Hardcoded defaults now:
+
+- **px 0**: rev-limit flasher — red, fires ≥ `rev_limit`, clears < 97 %,
+ 100 ms half-period.
+- **px 10**: EGT flasher — orange, fires ≥ 650 °C (`kEgtAlertC`), clears
+ < 630 °C, 250 ms half-period. NaN/stale EGT (isNanF!) → LED off AND
+ latch released — never latch stale data (house rule).
+
+Phase 2 ("user controls these settings") parses settings into the same
+POD table — the evaluation code doesn't change, only where the table comes
+from. `Source` is an enum (`kNone/kRpm/kEgtC`) that grows entries as new
+sources appear.
+
+### Animations — deterministic by construction
+
+No `rand()`, no `millis()` inside the units: a frame is a pure function of
+`(tMs since start, seed)`. Sparkle positions come from an xorshift-style
+hash of `(seed, timeSlot, pixel)`, so host tests golden-lock exact frames.
+The glue supplies `millis()` deltas and a `micros()`-derived seed (house
+entropy rule — never `analogRead`).
+
+- **Boot** (2600 ms): hue comet circling all 11 px as a ring (2
+ revolutions with trailing fade, 0–1500 ms), global fade-out
+ (1500–2200 ms), white sparkle glints overlapping (900–2600 ms).
+ Non-blocking — plays over the GPS status page.
+- **Purple sector** (1600 ms): purple wave expanding from the center px
+ outward across strip + status (0–400 ms), solid purple with sparkles
+ (400–1200 ms), fade out (1200–1600 ms). Overrides strip AND status;
+ normal mode resumes automatically; a new purple mid-animation restarts
+ it.
+
+### Purple detection — and the sector-3 / lap-line race
+
+The library (DovesLapTimer / SprintTimer) exposes `getCurrentSector()`,
+`getCurrentLapSector{1,2,3}Time()`, `getBestSector{1,2,3}Time()` — but
+`updateBestSectors()` runs **at the start/finish crossing**, not at each
+sector line. So at the exact moment sector 3 closes, the library may have
+already folded this lap into the "best" values and rolled the current-lap
+times to the new lap. `sector_purple` defeats this by never comparing
+against live state at a close edge:
+
+- **Best times are snapshotted when each sector OPENS** (`bestAtOpen[3]`).
+- S1/S2 close on `getCurrentSector()` transitions (1→2, 2→3): compare the
+ just-closed `getCurrentLapSectorNTime()` against `bestAtOpen[n]`, then
+ snapshot the next sector's best.
+- **S3 closes on the lap edge** (`laps` increment), and its time is
+ *derived*: `s3 = lastLapTime − s1 − s2` (guarded on both being present
+ and the difference positive) — both inputs immune to the library's
+ lap-line side effects. Compare vs `bestAtOpen[2]`, re-arm for the new
+ lap.
+- Purple fires only when the beaten best was **nonzero** — no purple
+ anywhere on lap 1 (flashing every first-lap sector is noise).
+- `sectorsConfigured == false` (Lap Anything / WaypointLapTimer has no
+ sectors) or race not started → full reset, never fires.
+
+Three new sprint-first wrappers join the `activeTimer*()` family:
+`activeTimerCurrentSector()`, `activeTimerLapSectorTime(n)`,
+`activeTimerBestSectorTime(n)` (WLT/null → 0).
+
+## Sleep / wake
+
+GPIO state is **retained in System OFF** (the "blue LED stays on after
+sleep" field report). `NEOPIXEL_SLEEP()` — slotted in `enterShutdown()`
+with the IMU rail-off, before the charging branch so the strip is dark
+while charging too — therefore unconditionally:
+
+1. blanks the strip and `show()`s while 5 V is still up,
+2. drives the data pin LOW (never leave data high into an unpowered strip
+ — it back-powers the pixels through the data diode),
+3. drives boost EN LOW — the 5 V rail is truly off, retained through
+ System OFF.
+
+`NEOPIXEL_WAKE()` (charging-loop soft resume, beside `SENSOREGG_WAKE()`):
+EN HIGH, ~5 ms boost settle, blank frame; composition resumes next loop.
+
+## Parked loop branches
+
+The BLE-transfer and USB-MSC branches early-return before the main frame.
+`NEOPIXEL_LOOP()` is called inside both so composition sees "parked" and
+blanks the strip — a frozen mid-pattern strip would look crashed.
+
+## Settings
+
+| Key | Default | Meaning |
+|---|---|---|
+| `led_brightness` | `"64"` | Global cap 0–255; 0 = LEDs disabled (boost never enabled) |
+| `rev_limit` | `"15000"` | True RPM for the scale ceiling + rev flasher; clamp 1000–20000 |
+
+Both rows added unconditionally to `ensureDefaultSettings()` (uniform
+SETTINGS.json across channels — the SensorEgg DOVEX-column precedent).
+Phase 2 adds mode/action assignment keys.
+
+## Feature flag / channels
+
+`BIRDSEYE_ENABLE_NEOPIXEL` (`project.h`, `#ifndef` default 0, tested with
+`#if`): **on** in beta (`beta.yml` + `compile-sketch.yml` for
+BETA-targeted PRs), **off** in master/release. The `Adafruit NeoPixel`
+library is installed in all three workflows either way (harmless when
+unused; keeps the lists uniform).
+
+## Radio / timing safety
+
+Adafruit_NeoPixel's nRF52 backend claims a *free* PWM instance
+(`ENABLE == 0`) per `show()`, drives it via EasyDMA and busy-waits SEQEND
+with **interrupts on** (~0.4 ms for 11 px ≈ 1 % CPU at 30 Hz). This sketch
+uses no `tone()`/`analogWrite()`/HwPWM, so PWM0–2 are always free; TIMER3's
+GPS drain and the tach ISR are unaffected. Two things future maintainers
+must not "fix":
+
+- If ever all PWMs are occupied, the library falls back to a
+ cycle-counted bit-bang **with interrupts disabled** for ~0.4 ms. Still
+ inside the 256 B serial ring's ~44 ms slack, but don't create that path.
+- `show()` mallocs/frees a ~560 B PWM pattern buffer every call. Same-size
+ alloc/free is fragmentation-benign on this allocator — it is not a leak.
+
+## Sim
+
+`neopixel.ino` is excluded from the sim TU (BLE-module precedent) and its
+surface no-op'd in `module_stubs.cpp`. The four pure units join
+`SIM_CORE_SOURCES`. The three sector wrappers get sim_prototypes entries.
+
+## Hardware-only verification (open items)
+
+- One-time UICR write + single self-reset on first flag-on boot.
+- Boost EN polarity (assumed HIGH = on, Adafruit precedent) + settle time.
+- GRB color order and strip orientation (`kChainReversed` if mounted
+ data-end-right).
+- Real current draw at cap 64; sleep current with EN low.
+- 3.3 V data into 5 V pixels — user bench-proven on another nRF board.
+- Cold power-on EN float: before `setup()` the EN pin floats; if the boost
+ board pulls EN up, the strip powers briefly with idle data — harmless
+ (pixels stay dark), noted for completeness.
+
+## Phase 2 (deliberately out of scope here)
+
+Settings-driven assignment: strip mode selection, per-status-LED action
+source/threshold/color, temp scales via `ScaleSpec`, C/F preference,
+brightness UI. The POD tables above are the interface that phase fills in.
diff --git a/docs/plans/0007-led-rev-temp-alerts.md b/docs/plans/0007-led-rev-temp-alerts.md
new file mode 100644
index 0000000..99e2f22
--- /dev/null
+++ b/docs/plans/0007-led-rev-temp-alerts.md
@@ -0,0 +1,97 @@
+# 0007 — LED rev/overrev + temp alerts, GPS-search pip, engine-stopped gate
+
+First track-shakedown follow-up to the NeoPixel subsystem (plan 0006). Four
+changes, all driven by real use on a kart with a 7600 RPM hard limiter.
+
+## Two rev thresholds, two meanings
+
+- **`rev_limit`** (existing setting, semantics sharpened): the WARNING
+ limit — where the driver wants to know the engine is at the ceiling
+ (7550 on the reference engine, just under its 7600 limiter — a carb
+ tuning indicator). Drives the left status LED's red flasher and the
+ RPM-scale ceiling, exactly as before — and nothing else: the warning
+ deliberately does NOT touch the OLED.
+- **`overrev_limit`** (new, default `0` = disabled): the PROBLEM limit —
+ if the engine ever spins here (8500 reference), something is
+ mechanically wrong. While latched, the **whole 11-px chain flashes
+ red**, overriding everything except the boot animation (an engine
+ problem outranks the purple celebration), and the OLED tach page
+ shows `*OVER REV*` — that header is tied to this SAME limit, only
+ when enabled (>0) and tripped, never at the warning limit (it had
+ been hardcoded at a meaningless `> 9999`). The latch releases below
+ `rev_limit × 0.97` — the same clear fraction the warning flasher uses,
+ so a brief spike leaves an unambiguous flash rather than a flicker.
+ Implemented with the existing `StatusAction`/`evalStatus` machinery
+ (its `StatusState.active` latch drives a whole-frame fill in the
+ glue) — no new evaluation code, and phase 2 can expose it like any
+ other action.
+
+## Temp alert on the right status LED — tri-state
+
+New setting **`temp1_alert_c`** (default `650` °C ≈ 1200 °F, the value
+that was previously a constexpr). The right LED becomes:
+
+- **flashing red** at/above the threshold (was orange; red per driver
+ preference), clearing 20 °C below it (the existing 650/630 hysteresis
+ generalized);
+- **off** when the reading is good and below the limit;
+- **solid blue** when there is *no probe signal* — Temp1 is NaN/stale
+ (egg dropout, hung egg, no egg). `StatusAction` grew an
+ `invalidColor` field for this; the rev flasher sets it to off, so
+ nothing else changes. A logger with no egg paired shows blue all
+ race — accepted for now; phase 2's assignability makes it
+ configurable.
+
+## GPS-search pip
+
+Race mode with no GPS lock used to show the RPM scale — nothing told
+the driver why laps weren't timing. Now the strip shows a single green
+pixel bouncing end-to-end (triangle wave, 1.6 s round trip,
+`led_modes::renderSearchPip` — a pure function of elapsed time, host
+tested) until `gpsData.fix && gpsData.timeValid` — the same condition
+that allows log-file creation and releases the WAITING-GPS-LOCK page
+pin. Then RPM scale, then pace once the first lap is in (unchanged).
+
+## Engine-stopped gate
+
+If a session's engine has proven itself (`raceEntryCause ==
+RACE_ENTRY_TACH` — which manual/speed sessions promote to at >500 RPM,
+so no-tach devices are untouched) and the tach reads 0, the session is
+in a stall/DNF posture, not a lap:
+
+- the 9-px bar goes dark (a pace pip counting away next to a dead
+ engine reads as a glitch);
+- the **status LEDs stay live** — the temp alert while a hot engine
+ cools is exactly when it matters;
+- the OLED pace page shows **STOPPED** instead of a still-counting pace
+ number (and suppresses the notably-faster flash animation).
+
+A restart clears it automatically — the tach reports nonzero within one
+loop of the first pulse burst.
+
+Shared condition: `raceEngineStopped()` in the sketch, used by both the
+LED glue and the pace page.
+
+## Strip composition after this plan
+
+```
+boot animation
+ > overrev whole-chain red flash (race only, latched)
+ > purple sector celebration
+ > parked / not racing / brightness 0 -> off
+ > race strip:
+ engine stopped -> bar off (status LEDs live)
+ no fix+timeValid -> green search pip bouncing
+ pace valid -> pace pip
+ else -> RPM scale
+ status px0: rev-limit flasher (red, >= rev_limit, clear 97%)
+ status px10: temp tri-state (blue = no signal / off / red flash)
+```
+
+## Settings summary
+
+| Key | Default | Notes |
+|---|---|---|
+| `rev_limit` | `15000` | unchanged — LED warning flasher + scale ceiling only |
+| `overrev_limit` | `0` | 0 = disabled; clamp 1000–20000; whole-chain red flash |
+| `temp1_alert_c` | `650` | °C; clamp 50–1200; clear = alert − 20 °C |
diff --git a/docs/plans/0008-ble-download-throughput.md b/docs/plans/0008-ble-download-throughput.md
new file mode 100644
index 0000000..3612c67
--- /dev/null
+++ b/docs/plans/0008-ble-download-throughput.md
@@ -0,0 +1,140 @@
+# Getting BLE downloads back off the floor
+
+> Status: **DONE (firmware).** Needs a bench measurement to confirm which of
+> the three levers below was actually holding the field device at ~29 KB/s.
+
+## Why this exists
+
+A 3.3 MB session downloaded at **28.8 KB/s** (ETA 1m52s) on an iPad, against a
+remembered **~130 KB/s** from an earlier bench run. Same firmware family, same
+protocol. Everything else about that day was healthy — the driving loop was
+tight enough to drop 5 PVT frames in 20 minutes, and a second session dropped
+none — so the loop is not what regressed. Downloads are.
+
+The first suspicion was the SD card: the normal clock is a deliberately slow
+2 MHz for ignition EMI, and the fix for that (`sdSetTransferSpeed(true)` →
+8 MHz while parked) already shipped in 4.0.0. **It is still wired up
+correctly** — `BLE_SETUP()` bumps it, `BLE_STOP()` reverts it. So "we forgot to
+put the clock back up" was not the bug. But `sdSetSpiClock()` **silently falls
+back to 2 MHz** when the 8 MHz re-init fails, and nothing anywhere surfaced
+which clock the session actually got. That silence is a bug in its own right,
+and it is fixed here.
+
+Reading the transfer path with the SD theory set aside turned up three real
+throughput ceilings, in rough order of expected impact.
+
+## Lever 1 — the Data Length Extension request was fired and never checked
+
+`Bluefruit.configPrphConn(247, ...)` sets the **ATT MTU** to 247, so a
+notification carries a 244-byte payload. It says nothing about the **link-layer
+PDU**, and if that stays at the 27-byte default every notification is
+fragmented into ten link-layer packets, each paying its own
+preamble/access-address/header/CRC and inter-frame space. That is roughly a
+3–4× tax — the size of the gap being complained about.
+
+The connect callback *does* call `connection->requestDataLengthUpdate()`, so
+this looked handled. It is not, for two reasons:
+
+- **It can be rejected on arrival.** The SoftDevice runs one link-layer control
+ procedure at a time, and the DLE ask is fired on the line immediately after
+ `requestPHY(2M)`. A collision comes back as `NRF_ERROR_BUSY`, which
+ `VERIFY_STATUS` turns into a `false` return — and the return value is
+ discarded at the call site. Nothing retries.
+- **Nothing ever read the result.** `BLEConnection::_data_length` starts at 27
+ and only moves on a `BLE_GAP_EVT_DATA_LENGTH_UPDATE`. No code path anywhere
+ looked at `getDataLength()`, so a link that silently stayed un-extended was
+ indistinguishable from one that did not.
+
+Centrals that initiate DLE themselves — Chrome on desktop and Android do —
+paper over this entirely, which is very plausibly why the bench run hit
+~130 KB/s while an iOS Web Bluetooth shim did not.
+
+Fix: verify. `bleTuneLink()` reads `getDataLength()` once the link has settled
+and re-asks if it is still at the default, with nothing else in flight to
+collide with. Nordic's guidance is to extend *after* the ATT MTU exchange
+anyway, so the retry lands in the right order for free.
+
+## Lever 2 — the connection-interval request iOS was always going to reject
+
+`Bluefruit.Periph.setConnInterval(6, 12)` advertises a preferred interval of
+7.5–15 ms. Apple's accessory guidelines require an accessory's requested
+interval to satisfy `Interval Min ≥ 15 ms`, so **iOS rejects this outright** and
+leaves the connection on whatever it picked at connect (commonly 30 ms). Half
+the connection events, half the throughput.
+
+The naive fix — raise the preferred interval to 15 ms — would slow down every
+central that *does* honour 7.5 ms. So the PPCP stays exactly as it is, and the
+link-tuning block adds an **adaptive second ask**: read the interval actually in
+force, and only if it is slower than 15 ms issue an explicit, Apple-compliant
+`requestConnectionParameter(12)`. Centrals already running fast are untouched;
+iOS gets an offer it is allowed to accept.
+
+Bluefruit's `requestConnectionParameter()` sets min == max, so the ask is a flat
+15 ms with the library's default slave latency 0 and 2 s supervision timeout —
+all inside Apple's bounds. Some readings of the guideline also want
+`Interval Max ≥ Interval Min + 15 ms`; if a central holds us to that it rejects
+this ask too and we are exactly where we already were, so the downside is zero
+and the upside is 2×. The bench measurement is what settles it.
+
+## Lever 3 — the SD read was serialised into the radio's critical path
+
+The burst loop read `chunkSize` bytes straight off SdFat and immediately
+notified them, ten times per `loop()`. Two costs:
+
+- **Every notification waits on a disk read.** `notify()` blocking on a full
+ HVN queue is good flow control — that is the radio being saturated, which is
+ the goal. A 244-byte `FatFile::read()` in between is not: it is dead radio
+ time, and at 2 MHz (the silent-fallback case) it is *a lot* of dead radio
+ time.
+- **244 is not 512.** Unaligned sub-sector reads run through SdFat's single
+ 512-byte cache block, so a large file costs one single-block SD command per
+ sector, each with its own command/response round trip.
+
+Fix: a 4 KB read-ahead. One aligned `read()` pulls eight sectors in a single
+multi-block transfer, and the notifications then stream out of RAM. The refill
+stall (~4.3 ms at 8 MHz) is shorter than a connection interval and is covered by
+the ~2.4 KB already sitting in the SoftDevice's 10-deep HVN queue, so the radio
+does not go idle across it.
+
+Refills are **compacting** — the unsent tail moves to the front — so every
+notification except the file's last one carries a full chunk. Without that, each
+4 KB boundary would emit a runt packet (4096 mod 244 = 190).
+
+The burst bound changes from "10 chunks" to "20 ms of wall clock", because the
+point of the bound is loop responsiveness, not a packet count: 10 chunks is
+2.4 KB, which is a very different amount of time on a fast link than on a slow
+one. 20 ms keeps the exit button and the watchdog serviced with three orders of
+magnitude to spare.
+
+### Failure handling got simpler, and safer
+
+The old loop reacted to a failed `notify()` by `seekCur(-bytesRead)` — rewinding
+the file, because the read had already advanced the position and dropping the
+chunk would silently punch a hole in a transfer that still reported `DONE`.
+With a read-ahead there is nothing to rewind: the bytes are in RAM and the
+buffer head simply does not advance. The hole is structurally impossible rather
+than patched.
+
+## Where the logic lives
+
+`ble_stream.{h,cpp}` (host-tested, Arduino-free) owns the index arithmetic —
+chunk sizing from the negotiated MTU, the compacting refill, slice/consume, and
+the throughput rate. `bluetooth.ino` owns the bytes, the file, and the radio.
+That split is what makes the off-by-one that would corrupt a download testable
+on a desktop instead of discoverable on a track day.
+
+## Making the next regression visible
+
+The whole reason this took a reading session to notice is that the device
+reported a percentage and nothing else. The Bluetooth page now shows live
+**KB/s** alongside the percentage, plus a one-line link summary:
+
+```
+Transfer: 42%
+118KB/s 8M 2M 244
+```
+
+— rate, SD clock, link-layer PDU (the DLE result), ATT payload. If downloads are
+slow again, that line says which lever slipped without needing a rebuild.
+`sdActiveSpiHz()` backs the SD figure with the clock that was actually applied,
+not the one that was requested.
diff --git a/docs/plans/0009-tach-rpm-spikes.md b/docs/plans/0009-tach-rpm-spikes.md
new file mode 100644
index 0000000..7376c51
--- /dev/null
+++ b/docs/plans/0009-tach-rpm-spikes.md
@@ -0,0 +1,161 @@
+# RPM Spikes — Outlier Gate, RPM-Aware Noise, and a Track-Side A/B Switch
+
+> Status: **IMPLEMENTED**, awaiting track validation. Prompted by a field
+> report: a single-cylinder kart with a digital-chip magneto, whose plotted
+> DOVEX `rpm` column is full of spikes that the engine plainly did not do.
+>
+> The reporter's hypothesis was that the filter was *over*-compensating.
+> It was the opposite — see below. The `tach_filter` setting exists so that
+> question gets answered with a session at the track rather than an argument
+> about a graph.
+
+## The complaint
+
+> "I understand it's a small single cyl engine, but our magnetos do have
+> digital chips in them, RPM in my opinion should appear more stable than it
+> is. Are we compensating too much with the interpolation?"
+
+## What was actually happening
+
+There is no interpolation in the RPM path — there never was. The pipeline is:
+ISR timestamps each falling edge → `TACH_LOOP()` computes the mean of the
+inter-pulse periods that arrived since the last call → that mean becomes one
+RPM number → a 1-D Kalman filter folds it in. The 25 Hz DOVEX row is a point
+sample of whatever the filter last published.
+
+Three defects in that chain, none of them "too much smoothing":
+
+### 1. One bad edge went straight to the output
+
+The estimator's steady-state gain was ~0.43 (Q = 800, R = 2500), so **43% of
+every single measurement landed in the published value** — and a measurement
+is one period. At 3000 RPM (a 20 ms period), an ignition ring that clears the
+3 ms debounce by arriving 4 ms after a real edge reads as 15 000 RPM. The
+filter had no way to tell that from the engine, so it moved ~5000 RPM. One
+bad microsecond reading, one spike on the graph. A *missed* spark is the
+mirror image: the period doubles, the measurement halves, the trace dives.
+
+The 3 ms debounce is a fixed ceiling guard (~20 000 pulses/min), not a
+running-speed guard. At 3000 RPM it leaves 17 ms of every period wide open to
+a spurious edge.
+
+### 2. The noise model was RPM-blind, and RPM error is quadratic in RPM
+
+RPM = K / period, so `dRPM/dT = RPM² / K`: a **fixed** timing error is worth
+**quadratically more RPM** the faster the engine turns. 80 µs of ISR latency
+(the tach handler is deferred by SoftDevice radio ISRs like everything else
+at app priority) costs 6 RPM at 3000 and 143 RPM at 14 000. A flat
+R = 2500 RPM² (50 RPM σ) was about right at 4000 RPM and far too confident
+above 8000 — so the filter trusted its noisiest measurements the most,
+which is why the trace got worse the harder the engine was working.
+
+### 3. Process noise was charged per update, not per second
+
+Updates arrive at the *pulse* rate. A fixed Q = 800 per update means the
+filter's time constant is constant in pulses and therefore **four times
+looser at 12 000 RPM than at 3000** — and looser again whenever an SD GC
+stall batched several pulses into one update. The amount of smoothing
+silently depended on engine speed and on SD card mood.
+
+## The fix (all in the `tach_filter` pure unit)
+
+1. **Outlier gate.** A measurement more than `kGateSigmas` (5) from the
+ estimate, measured against the combined estimate + process + measurement
+ uncertainty, is not folded in — the estimate coasts. 5σ is deliberately
+ loose: the gate is there for the half-period and double-period signatures
+ of a spurious/missed spark, which are tens of sigma out, not to trim
+ honest noise. A 5500 RPM/s pull moves the crank ~25 RPM between pulses,
+ so real acceleration cannot trip it (asserted in the tests).
+2. **Escape hatch.** Three *consecutive* rejections is not three coincident
+ bad edges — it is a real step change (clutch dump, spin, a batch lost to
+ a stall) and the estimate is the thing that is wrong. The third
+ measurement is **adopted outright** rather than filtered toward, which is
+ what makes an instant 11 000 → 4000 drop settle in 80 ms (faster than the
+ old filter's 120 ms). Rejections must be consecutive, or a pickup emitting
+ one bad edge every few seconds would eventually adopt one of them.
+3. **`measurementNoise()` knows the RPM** — the quadratic timing-jitter term
+ plus a linear term for genuine crank speed variation between firings
+ (a single-cylinder engine really does speed up and slow down within a
+ revolution; that is not measurement error, but nobody wants it plotted).
+ It also knows `revsPerPulse`, so a twin's shorter periods are correctly
+ treated as noisier at the same RPM.
+4. **`processNoise()` is a rate** (RPM²/s) times the engine time the batch
+ spans. `TACH_LOOP()` passes the sum of the periods it just consumed, not
+ wall clock — at 1500 RPM those differ by 40x, and charging process noise
+ for time the crank did not turn is the same mistake in a new place.
+ The rate is set so the filter behaves exactly like the old one at
+ 6000 RPM, and unlike it, keeps behaving that way everywhere else.
+5. **First measurement after a reset is adopted.** With no prior, filtering
+ toward it through an RPM-aware R takes several pulses to arrive anywhere
+ true — and the gate would arm partway up that climb and start rejecting
+ the engine's real speed.
+
+## Modelled result
+
+Whole-pipeline simulation (ISR debounce → ring batching with 30 ms loop
+stalls → filter → 25 Hz sampling), constant engine speed, 1% crank
+variation, 150 µs ISR latency jitter, 0.5% spurious edges and 0.5% missed
+sparks. Error against the true RPM, in RPM:
+
+| Engine | legacy σ | legacy worst | **smooth σ** | **smooth worst** | raw σ |
+|---|---|---|---|---|---|
+| 1 500 | 501 | 6 352 | **16** | **163** | 943 |
+| 3 000 | 292 | 5 657 | **19** | **286** | 446 |
+| 6 000 | 420 | 5 031 | **22** | **80** | 677 |
+| 10 000 | 220 | 2 809 | **22** | **75** | 448 |
+| 14 000 | 254 | 2 977 | **22** | **73** | 482 |
+
+Cost, on a clean signal: a 5500 RPM/s pull lags 489 RPM instead of 267
+(~90 ms). An instant 11 000 → 4000 step settles *faster*, 80 ms vs 120 ms.
+
+Note the `raw` column. It is worse than legacy at every engine speed —
+because it is the pickup, unfiltered. That is the number that answers the
+original question: the filter was not manufacturing the spikes, it was
+passing them through.
+
+## The `tach_filter` setting
+
+| Value | Behaviour |
+|---|---|
+| `smooth` (default) | Everything above. |
+| `legacy` | The pre-0009 filter, bit for bit — fixed Q/R, no gate. Comparable against existing logs. |
+| `raw` | No estimator at all: the DOVEX `rpm` column is exactly what the periods say. |
+
+Anything unrecognised reads as `smooth`, the house idiom. The mode is read
+once at boot and applied before the filter, so it affects display, DOVEX
+rows, the camera FSM and the LEDs identically — there is still exactly one
+RPM number in the firmware (plan 0003's rule).
+
+**Why a setting and not just the fix.** The spikes are either the pickup or
+the estimator, and no amount of reading the code settles which. `raw` for one
+session shows the signal with nothing in the way; `legacy` reproduces the
+logs already collected; `smooth` is what ships. Three sessions at one track
+day and the question is closed.
+
+**Deliberately not a DOVEX column.** Logging raw and filtered side by side
+would A/B in a single session, but it forks the log format for a debug
+feature and needs a viewer change. The setting switches what goes into the
+existing `rpm` column, so nothing downstream has to know.
+
+## Pickup-health diagnostic
+
+With `debug_pages` = `show`, the tachometer page's subtext line becomes
+`max:NNNNN S rj:NN` — the estimator in force and the number of periods the
+gate has thrown away this power-cycle. It is the direct measurement of
+signal quality: single digits over a session means a clean pickup; a count
+that climbs with RPM is ignition ringing or missed sparks reaching the ISR.
+The count deliberately survives the engine-stop reset, or it would zero at
+every corner.
+
+## Not done, on purpose
+
+- **Adaptive (RPM-scaled) blanking in the ISR.** Modelled first, and it made
+ things *worse*: blanking off the last accepted period means one spurious
+ edge blanks the next real one, which manufactures a doubled period and a
+ downward spike. It would have to blank off the filtered estimate, which
+ puts filter state in the ISR. The gate solves the same case downstream
+ with none of that.
+- **Re-tuning the debounce floor.** It is a ceiling guard and correct as is
+ (plan 0003). Ringing that clears it is the gate's problem now.
+- **Time-window averaging of the logged column.** The estimator is now
+ quiet enough (σ ≈ 20 RPM) that resampling would only add lag.
diff --git a/docs/plans/0010-local-timezone-led-day-night.md b/docs/plans/0010-local-timezone-led-day-night.md
new file mode 100644
index 0000000..5d423c9
--- /dev/null
+++ b/docs/plans/0010-local-timezone-led-day-night.md
@@ -0,0 +1,149 @@
+# Local Timezone (fixed offset) + LED Day/Night Brightness
+
+> Status: **IMPLEMENTED**. Scope was deliberately cut twice during design —
+> once to drop DST, once to keep local time out of the logging pipeline
+> entirely. Both cuts are load-bearing; read *Non-goals* before widening this.
+
+## The ask
+
+> "I want to toggle the lights between daylight and night time, but sunrise/
+> sunset isn't warranted — 7am needs to be local 7am."
+
+The NeoPixel strip (plan 0006) runs at one brightness cap around the clock.
+At a night event that cap is either blinding or, if turned down for the dark,
+useless in daylight. The fix is a second cap and a clock to switch on.
+
+## Why the device had no clock to switch on
+
+The GPS delivers UTC and nothing in the firmware had ever needed anything
+else. `gpsData.hour` is raw UTC out of the PVT callback
+(`gps_functions.ino`), and every consumer — filenames, the DOVEX header
+`datetime`, generated course names — wants UTC anyway. So there was no
+timezone concept to fix; there was one to add.
+
+The failure mode without it is not subtle. A US Central driver at 07:30
+local is at 12:30 UTC: a naive UTC gate calls that the middle of the night
+and dims the strip in broad daylight. Symmetrically, a 21:00 local session
+is 02:00 UTC *the next day*, so the gate gets the right answer for the wrong
+reason and the date is off by one.
+
+## What was built
+
+A pure unit, `local_time.{h,cpp}`, and a single consumer.
+
+**`local_time`** — a fixed signed offset in minutes, applied to a 4-digit-year
+`DateTime`, with correct date rollover in both directions across month, year
+and leap-day boundaries. Plus `isNight()`, which tests membership in the
+window `[nightStart, dayStart)` *modulo the day*, so the ordinary wrapped case
+(19:00 → 07:00) needs no special casing at the call site. Host-tested
+(`tests/local_time_test.cpp`), Arduino-free, builds into the sim.
+
+Three details worth keeping:
+
+- **Minutes, not hours.** India is +5:30, Newfoundland −3:30, the Chatham
+ Islands +12:45. An hours-only offset is wrong for real places.
+- **Four-digit year.** `GpsData.year` is 2-digit for compatibility with the
+ existing filename and header format strings; the unit takes 4 digits and
+ callers add 2000. The leap rule (`gps_time::isLeapYear`, reused rather than
+ re-derived) genuinely needs the century, and a future feature that wants
+ real civil time will need it too.
+- **An out-of-band offset is ignored, not clamped.** A corrupt setting must
+ not be able to walk the calendar; it falls back to 0, which is exactly the
+ pre-0010 behaviour.
+
+**The consumer** is `npxEffectiveBrightness()` in `neopixel.ino`, feeding
+`npxPushFrame()`. That function was already the single global-brightness
+choke point (`led_frame::applyCap`, plan 0006), so the whole feature is a
+change of argument at one call site — the invariant that no channel ever
+exceeds the cap is untouched.
+
+## Non-goals — the two cuts
+
+**No DST, on purpose.** A fixed offset means the boundary walks an hour twice
+a year. For a "stop blinding the driver after dark" gate that is beneath the
+feature's resolution. The alternatives were US/EU rule tables (~40 lines and
+a permanent correctness liability as legislatures keep changing the dates) or
+tzdata (~100 KB and needs shipping updates to a sealed device). Neither buys
+anything a driver would notice. `local_time` is where the rules would go if
+that ever changes.
+
+**Local time never touches saved data.** DOVEX row timestamps are Unix epoch
+milliseconds — UTC by definition — and the header `datetime`, the log
+filenames and the generated course names all stay UTC. This is not laziness:
+a log is routinely *viewed* somewhere other than where it was recorded, so
+the conversion belongs on the presentation side, where the viewer's own
+preference is known. The webapp already does most of this (it does not yet
+offer local-timezone picking, which is where that work continues).
+
+The practical consequence: nothing in the logging pipeline may call into
+`local_time`. The header comment says so.
+
+## Traps
+
+1. **`led_brightness == 0` means "cut the 5 V rail", and only that setting
+ does.** `NEOPIXEL_SETUP()`/`NEOPIXEL_WAKE()` hold the boost EN pin low when
+ it is 0. A *night* brightness of 0 must therefore blank the frame and leave
+ the rail up — power-cycling a boost converter at 19:00 mid-session is not a
+ brightness change. `npxEffectiveBrightness()` keeps the two meanings apart.
+2. **No clock means day, not night.** `gpsData.timeValid` requires the module's
+ `fullyResolved`, which is up to ~12.5 minutes from a cold start. Rendering
+ at night brightness in that window would mean a strip that comes up dark
+ and reads as broken hardware. It renders at day brightness until the lock
+ lands, then swaps.
+3. **Equal hours disable the swap.** `isNight()` returns false for an empty
+ window, so `led_day_start_hour == led_night_start_hour` means one cap
+ around the clock — no separate enable flag to keep in sync.
+
+## The settings file was one key away from bricking
+
+Adding four keys turned up a latent cliff. `settings.ino` had a 512-byte
+file buffer and a `StaticJsonDocument<512>`, and **every read path caps at
+`sizeof(settingsFileBuffer) - 1`** — 511 bytes. The existing 18-key file
+already serialized to **436 bytes**. The four keys here put it at **543**.
+
+Past the cap the failure is silent and self-inflicting:
+
+1. `settingsFile.read()` truncates at 511 bytes, mid-token.
+2. `deserializeJson` returns `IncompleteInput`, so *every* `getSetting()`
+ fails — not just the new keys.
+3. `SETTINGS_SETUP()` reads that as corruption and quarantines the file to
+ `SETTINGS.json.bad`, regenerating fresh defaults (new random BLE name,
+ new PIN, new device name — the user's pairing is gone).
+4. `ensureDefaultSettings()` then adds the missing keys back, one
+ read-modify-write each, until the file crosses 511 again.
+5. Loop, every boot, forever.
+
+The document was a second, independent wall: a `<512>` doc returns
+`NoMemory` at 22 string pairs (measured against the pinned ArduinoJson
+6.21.5, not estimated).
+
+Fixed by raising both to **1024** (keep them equal — the invariant is that
+the buffer can always hold what the document serializes) and, because the
+next contributor will not have measured, adding a guard in
+`setSettingInner()` that refuses a write when the document `overflowed()`
+or `measureJson()` exceeds the buffer. That converts a silent permanent
+brick into one loudly refused write with the old file left intact.
+
+Worth stating plainly: **adding a settings key is not free.** Check the
+serialized size.
+
+## Settings
+
+| Key | Default | Notes |
+|---|---|---|
+| `utc_offset_min` | `0` | Minutes east of UTC; clamp ±840. US Central standard = `-360` |
+| `led_brightness_night` | `16` | Night cap 0-255; 0 blanks the strip, rail stays up |
+| `led_day_start_hour` | `7` | Local hour the day cap takes over |
+| `led_night_start_hour` | `19` | Local hour the night cap takes over |
+
+Written on every channel (the SensorEgg DOVEX-column precedent: uniform
+`SETTINGS.json` shape), read only by a `BIRDSEYE_ENABLE_NEOPIXEL` build.
+Settable over BLE via the existing `SSET` — no protocol work, and no
+on-device entry UI, consistent with the no-text-entry rule.
+
+## Follow-ups, not done here
+
+- The webapp learning to pick a viewing timezone for logs.
+- Local time on the OLED, if a page ever wants to show a clock.
+- A day/night *look* (different colors/modes), rather than just a different
+ cap. The cap was chosen for v1 precisely because it is one argument.
diff --git a/docs/plans/0011-loop-cpu-profiling.md b/docs/plans/0011-loop-cpu-profiling.md
new file mode 100644
index 0000000..e946a52
--- /dev/null
+++ b/docs/plans/0011-loop-cpu-profiling.md
@@ -0,0 +1,241 @@
+# 0011 — Main-loop CPU profiling (beta channel)
+
+**Status:** implemented (beta channel only)
+**Flag:** `BIRDSEYE_ENABLE_PROFILING` (`project.h`, default 0; `beta.yml`
+and `compile-sketch.yml` pass `=1` on the BETA branch)
+**Touches:** `loop_profile.{h,cpp}` (new pure unit), `profiling.{h,ino}`
+(new glue module), `BirdsEye.ino` (loop instrumentation, page constant),
+`display_pages.{h,ino}` (LOOP PROFILE page), `display_ui.ino` (routing),
+`neopixel.ino` (yields the boost EN pin), `beta.yml`,
+`compile-sketch.yml`, `clang-tidy.yml`, `tests/`
+
+## Why
+
+The commercial board has to be specified, and two open questions gate it:
+
+1. **nRF52840 or nRF5340?** The 5340 is the reliability upgrade for BLE
+ (dedicated network core, so the radio stack stops sharing a CPU with
+ the superloop), and it is more silicon than the product may need.
+2. **Arduino core or Nordic SDK?** Leaving the Arduino core is a large,
+ one-way piece of work. It is worth doing only if the core is where a
+ meaningful fraction of the time goes.
+
+Both are answerable with measurement rather than argument, and neither
+is answerable today: the firmware carries no timing instrumentation
+beyond a `HAS_DEBUG` "SLOW LOOP" print with millisecond resolution — too
+coarse to see a subsystem that costs 400 µs, and compiled out of every
+shipped build.
+
+So: measure a real iteration, on the real hardware, under a real 25 Hz
+GPS + logging + LED + BLE workload, and see where it goes.
+
+## What "CPU usage" means here
+
+> **Superseded, and the correction is the interesting part.** This
+> section originally argued that a duty cycle was meaningless because
+> `loop()` runs back to back and therefore the CPU is busy 100% of the
+> time by construction. That was an *assumption*, it was baked into the
+> measurement (see *Two clocks* below), and it made the first hardware
+> reading wrong. The profiler now measures how much wall time is
+> actually spent executing `loop()` and reports the balance as `SLP`.
+> Everything below about the shape of an iteration still holds; it is
+> just no longer the only thing worth reporting.
+
+The number that carries information is 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,
+- how much of it no subsystem accounts for.
+
+That is what decides both questions above. If the loop is 4 ms and
+`readButtons()`'s multi-sample debounce is 1.5 ms of pure blocking
+`delayMicroseconds`, the answer is "fix the firmware", not "buy a bigger
+chip". If it is spread evenly across genuine work, the chip is the
+conversation.
+
+## Two instruments
+
+**The pin.** Pin 30 is driven HIGH for the span being profiled and LOW
+outside it. A scope or logic analyser reads the loop period off the
+rising edges and the span's cost off the high time, with no software in
+the measurement path — which is exactly what makes it worth having
+alongside the software numbers: it is the thing that says the software
+is telling the truth. `PROFILING_PIN_SECTION` picks the span (the whole
+loop body by default; `-DPROFILING_PIN_SECTION=PROF_SEC_GPS` and friends
+re-point it).
+
+**The rollup.** Every section is also timed in software and rolled up
+once a second onto the LOOP PROFILE race page — first page of the race
+rotation on a profiling build, three Lefts from the speed page the
+session lands on. That is the instrument you can read while driving, and
+the one that survives not having a scope at the track.
+
+## The pin costs the 5 V rail, and that is the whole trade
+
+Pin 30 is the NeoPixel boost converter's EN line. It cannot be both.
+
+The profiler takes it, so a profiling build never drives EN — not at
+setup, not at sleep, not on the charging-loop resume. The regulator sits
+at its hardware default (EN pulled up = rail on), which is why this
+works at all: the rail does not *need* firmware control, it only needs
+it to be switchable, and switching it is a *use* requirement, not a
+*testing* one.
+
+Two consequences, both accepted:
+
+- **The rail stays up through System OFF.** GPIO levels are retained
+ there and the driven LOW was the only thing holding it down (the same
+ retention behind the "blue conn LED stays on after sleep" report). A
+ profiling unit left asleep on a battery with a strip wired to it goes
+ flat. Bench builds only.
+- **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. The LEDs are not what is being measured.
+
+Prod is untouched: `BIRDSEYE_ENABLE_PROFILING` defaults to 0, the
+section brackets are macros that expand to the bare call, and pin 30
+goes on being EN exactly as it always has.
+
+## Timebase: DWT, with a fallback that announces itself
+
+Most sections are well under a microsecond, so `micros()` alone would
+quantise half of them to zero. The profiler uses the Cortex-M4's DWT
+cycle counter — 64 ticks per microsecond at 64 MHz — and falls back to
+`micros()` when it will not run (a debug probe can own TRCENA, and
+CYCCNTENA is architecturally optional).
+
+"We set the bit" is not evidence, so `profEnableDwt()` reads the counter
+across a short spin and requires it to have moved. When the fallback is
+live the page prefixes its first row with `*`, because at 1 µs
+resolution the small numbers are noise and a reader has to know that.
+
+The pure unit therefore accumulates **ticks**, not microseconds, and is
+handed `ticksPerUs` only at rollup — ratios come out of raw ticks and
+lose nothing.
+
+## Design notes worth keeping
+
+- **Two clocks, and mixing them was the first real bug.** DWT counts CPU
+ *cycles*: it stops dead when the core halts (WFE/WFI in the FreeRTOS
+ idle task, `sd_app_evt_wait`). It is the right instrument for timing
+ code that is definitely executing and the wrong one for deciding how
+ much time has passed. Closing the rollup window on it meant the "one
+ second" window was one second of CPU-awake time, so the loop rate came
+ out multiplied by the sleep factor and every share was a fraction of
+ awake time wearing a wall-time label. Windows now close on `millis()`
+ and all shares are of wall time; durations are still ticks. The
+ `micros()` fallback never had the bug — it is a real clock.
+- **Saturating accumulators.** A uint32 of DWT ticks is only ~67 s and a
+ rollup can be arbitrarily late if the loop stalls. A saturated window
+ reads as pegged, which is true; a wrapped one reads as near-idle,
+ which is a lie.
+- **`OTH` is reported, not hidden.** Loop time that no section bracketed
+ gets its own slot. It is the honesty check on the instrumentation: if
+ it is large, time is going somewhere `loop()` is not bracketing.
+- **A scope guard, not a `PROFILING_LOOP()` call.** Both parked branches
+ (`bleActive`, `usbMscActive`) return early and `enterShutdown()` never
+ returns at all. A profiler that goes quiet exactly when the firmware
+ parks would be measuring the wrong thing, so the whole-iteration
+ timing and the rollup live in `~ProfLoopScope()`.
+- **The overhead is inside the numbers — and at the real loop rate it is
+ order 10%, not the 0.1% first claimed.** A bracket is two counter reads
+ plus a saturating add: ~30 cycles, ~0.5 µs, so ~6–8 µs per iteration
+ across all 13. That was written off against the ~4 ms iteration this
+ project's docs assumed; the first hardware run measured a mean
+ iteration under 100 µs. A bracket still lands in the section it
+ brackets rather than in `OTH`, because for a subsystem's *share* that
+ is the honest accounting — but sections under ~1% sit at their own
+ bracket's noise floor, and the un-instrumented loop is faster than the
+ rate shown.
+- **The pin edges are `#if`'d, not branched.** With the default
+ whole-loop setting the runtime comparison is provably false for every
+ section, and a dead branch in the two hottest functions in the
+ firmware is precisely the cost a profiler must not add.
+- **Profiling implies `debug_pages=show`.** The race rotation is a
+ contiguous range and the profile page sits below `GPS_DEBUG`, so
+ lowering the start to reach it pulls the two diagnostic pages in too.
+ On a bench build that is what you want anyway.
+
+## What to do with the numbers
+
+Read a full session — idle in the pits, out on track, during an SD
+flush, with the camera connected and streaming its 10 Hz GPS overlay.
+Then:
+
+- **One subsystem dominates** → fix or offload that, and neither the
+ 5340 nor the SDK port is justified yet.
+- **`BLE`/`CAM` cost is spiky and correlates with radio activity** →
+ that is the 5340's actual argument: a separate network core takes the
+ stack off this CPU entirely.
+- **Time is spread evenly across genuine work and `loopMaxUs` is
+ brushing the 40 ms 25 Hz PVT budget** → the chip is the conversation.
+- **`OTH` is large** → the instrumentation is incomplete; bracket more
+ before drawing any conclusion.
+
+## First hardware run (bench, 2026-08-24)
+
+Not a track session — treat these as a bench baseline, not the answer:
+
+```
+999Hz av0.0 mx42 <- 999 and 0.0 are display clamp artifacts, since fixed
+GPS 20.6 TCH 3.0
+ACC 0.7 BLE 0.8
+EGG 2.3 TRK 1.3
+LAP 2.0 IDL 4.6
+CAM 7.9 LED 3.1
+BTN 8.8 PGE 1.1
+DSP 14.9 OTH 17.6
+```
+
+No `*` and no `!`, so the DWT timebase and the pin were both live. What
+it says, and what it changes:
+
+- **The rate is not trustworthy as printed, and chasing why found a
+ bug.** It hit the 999 display clamp and the mean read 0.0 — both
+ fields had been sized from the stale ~250 Hz figure. But the deeper
+ problem was the window: it was closed on the DWT cycle counter, which
+ stops when the core sleeps, so the reported rate is the true rate
+ multiplied by however much of the second the CPU was awake, and every
+ share is a fraction of awake time rather than of wall time. Whether
+ the loop really is that fast, or the CPU sleeps most of the second,
+ the fixed build now answers directly: `SLP` reads ~0 in the first
+ case and large in the second.
+- **`mx` 42 ms against a 1600 ms GPS-ring ceiling and a 4000 ms
+ watchdog.** Worst-case latency is nowhere near anything that matters.
+- **Nothing dominates.** The largest section is `GPS` at 20.6%, and the
+ sections sum to ~88.7%. Shares are ratios of the same (mis-sized)
+ denominator, so their RELATIVE sizes survive the bug even though their
+ absolute values do not — the ranking below is still worth reading.
+- **The shape is polling overhead, not work.** `BTN` at 8.8% with nobody
+ touching a button is six `digitalRead()` calls per iteration at over a
+ kilohertz; `DSP` at 14.9% is mostly a `displayLoop()` that early-returns
+ on a 3 Hz gate; `OTH` at 17.6% is the unbracketed glue's `millis()`
+ calls. Subsystems that need 3–50 Hz are being polled ~1000× faster.
+
+**Read on the board question, held loosely until a re-run:** `mx` is
+nowhere near any budget and no section dominates, so nothing here argues
+for an nRF5340 on throughput grounds. If the 5340 is bought it should be
+for BLE reliability — which this page cannot measure directly (see the
+caveat below).
+
+## Still outstanding
+
+- **A re-run on the fixed build.** The absolute rate and every absolute
+ share from the run above are suspect until the window is wall-clocked.
+ The first thing to read on the new build is `SLP`: near zero means the
+ loop genuinely is that fast and the old numbers were only clamped;
+ large means the CPU sleeps and the old numbers were inflated by
+ exactly that factor.
+- **A session under real load.** The run above did have a GPS lock and
+ did trigger camera recording, so it was not idle — but a full track
+ session with sustained logging is still the number that decides
+ things.
+- **The BLE question.** Neither instrument separates SoftDevice /
+ Bluefruit task time from the section it preempted — FreeRTOS preemption
+ smears it across whatever was running. Watching how section *variance*
+ moves when a phone connects or the camera links is the available proxy;
+ a direct measurement would need the ISRs instrumented too.
+- The nRF5340 comparison needs the same instrument on that target. The
+ pure unit is board-portable by construction (no Arduino headers, no
+ platform `#ifdef`s) — same rule as `camera_fsm`, for the same reason.
diff --git a/docs/plans/0012-download-throughput-regression-deepdive.md b/docs/plans/0012-download-throughput-regression-deepdive.md
new file mode 100644
index 0000000..d304357
--- /dev/null
+++ b/docs/plans/0012-download-throughput-regression-deepdive.md
@@ -0,0 +1,175 @@
+# BLE download throughput regression — transport-stack deep dive
+
+> Status: **FIXES 1+2 IMPLEMENTED** (same branch). Fix 1 landed *wider* than
+> first proposed, by owner decision: the egg scanner is **race-gated** — it
+> runs only while a race session is active (`SENSOREGG_LOOP()` reconcile on
+> `raceActive`), not merely stopped during transfer mode; `BLE_SETUP()` still
+> calls `SENSOREGG_SLEEP()` as an explicit guarantee. Fix 2 adds a second
+> transfer-page line with the live connection interval + PHY
+> (`bleLinkIntervalUnits()` / `bleLinkPhy()`). Fix 3 (the bench matrix) is
+> the outstanding step; fix 4 stays contingent on its results.
+>
+> Field observation (2026-08-24): `Transfer: 77% / 33KB/s 8M 251
+> 244` on the device's own transfer page, against a remembered 120+ KB/s.
+
+## The one photo already rules most of the stack out
+
+The diagnostic line plan 0008 added exists for exactly this moment, and it is
+doing its job. `33KB/s 8M 251 244` decodes as:
+
+| Field | Value | Meaning | Verdict |
+|---|---|---|---|
+| `33KB/s` | 33 KB/s | live transfer rate | the complaint |
+| `8M` | 8 MHz | SD SPI clock **actually in force** (`sdActiveSpiHz()`) | **SD fast clock is applied — not the bug** |
+| `251` | 251 B | negotiated link-layer PDU (`getDataLength()`) | **DLE landed — not the bug** |
+| `244` | 244 B | ATT payload per notification (MTU 247) | **MTU landed — not the bug** |
+
+So all three of plan 0008's levers verifiably engaged on this very session.
+The regression lives in something the line does *not* show.
+
+### The SD card is exonerated (the direct question)
+
+*"Are we resetting the SD card properly to faster speeds before the
+transfer?"* — **Yes, verified two independent ways:**
+
+1. **Code path**: `BLE_SETUP()`'s first statement is
+ `sdSetTransferSpeed(true)` (bluetooth.ino), before the radio even comes
+ up; `BLE_STOP()` reverts it; the phone-disconnect auto-reboot makes a
+ leaked fast clock impossible. `sdSetTransferSpeed()` falls back to 2 MHz
+ only if the 8 MHz re-init fails — and then `sdActiveSpiHz()` would show
+ `2M` on the page. The photo shows `8M`.
+2. **Arithmetic**: the transfer path *reads* through the 4 KB read-ahead
+ (`ble_stream::ReadAhead`). One aligned 4 KB multi-sector read at 8 MHz is
+ ~4.3 ms → a ~950 KB/s ceiling; even the 2 MHz fallback ceilings at
+ ~240 KB/s. Neither can produce a 33 KB/s plateau. The SD card cannot be
+ the limiter at any clock the firmware uses.
+
+Also note: downloads never touch the SD **write** path. Recent SD-adjacent
+work (SLIST buffer sizing, the format page, course-creator temp-file writes)
+is entirely off the `GET:` read path — `git diff` from the plan-0008 commit to
+HEAD shows `bluetooth.ino`'s transfer code and `ble_stream.{h,cpp}` are
+byte-identical except the SLIST buffer change, and `sd_functions.ino`'s
+read/clock path is untouched. **Nothing in the firmware transfer code
+regressed since 0008.** Write-hardening work may still be worth doing for the
+logging path, but it is not what broke downloads.
+
+### The viewer is exonerated too
+
+`src/lib/ble/fileTransfer.ts` (DovesDataViewer) is unchanged since May. Its
+hot path is already lean — push chunk into an array, bump a counter,
+rAF-throttled UI — and, more fundamentally, ATT notifications carry no
+app-level acknowledgement: a slow receiving app cannot throttle the device's
+notify rate. The 33 KB/s is measured **on the device** and reflects what the
+radio actually shipped. Nothing in the web app polls the device mid-download
+(no BATT/status timers during a transfer).
+
+## What the rate arithmetic says
+
+33 KB/s ÷ 244 B ≈ **138 notifications per second**. Against plausible
+connection intervals:
+
+| Interval | Events/s | Notifies **per connection event** |
+|---|---|---|
+| 15 ms | 66.7 | **~2.1** |
+| 30 ms | 33.3 | ~4.2 |
+| 48.75 ms (Android default, no priority request) | 20.5 | ~6.8 |
+
+120+ KB/s needs ~500 notifies/s — many packets per event via **connection
+event length extension**. ~2 packets per 15 ms event is the classic signature
+of an event that closes right after its first exchange: extension denied, or
+the central closing early. The firmware's TX pipeline is not the constraint —
+the 10-deep HVN queue plus the 20 ms burst loop keeps the SoftDevice saturated
+(a blocked `notify()` is the design's flow control), and the parked loop
+spends ≥90% of wall clock inside the burst.
+
+**Conclusion: this is a radio-scheduling / link-parameter limit, not a code
+path, not the SD card.** Two suspects, both currently invisible.
+
+## Suspect 1 (prime, beta builds): the SensorEgg scanner runs through the whole transfer
+
+On a `BIRDSEYE_ENABLE_SENSOREGG=1` build (every beta image),
+`SENSOREGG_SETUP()` starts a **forever passive scan at 44% radio duty**
+(40 ms window / 90 ms interval, `sensoregg_protocol.h`) at boot — egg present
+or not. The only stop is `SENSOREGG_SLEEP()` in `enterShutdown()`.
+**`BLE_SETUP()` never stops it**, so the scanner holds its 40-of-every-90 ms
+radio claim for the entire transfer session.
+
+On the S140 SoftDevice, *connection event length extension* — the mechanism
+that turns 2 packets/event into 15+ — is one of the lowest scheduler
+priorities, below the scanner's timeslots, and a persistently blocked scanner
+periodically gets a raised priority to guarantee progress. A 44%-duty scanner
+therefore both denies extension for much of every interval and periodically
+preempts events outright. This can plausibly cost 2–3× on its own, and it is
+the one radio-environment change that maps onto the timeline: the 120+ KB/s
+readings, and plan 0008's ~130 KB/s bench memory, predate the egg scanner
+being live on the measuring device; every beta image since carries it.
+
+The camera subsystem is *not* a suspect: opening the Bluetooth page calls
+`CAMERA_FORCE_RELEASE()` (drops the link, stops camera adverts) before
+`BLE_SETUP()`.
+
+**Fix**: pause the scanner for the transfer session — `SENSOREGG_SLEEP()` in
+`BLE_SETUP()`. No wake path is needed: every exit from transfer mode reboots
+(manual Exit and peer-disconnect both `NVIC_SystemReset()`), so the scanner
+comes back with the fresh boot. One line, and the EGT feed loses nothing — the
+parked transfer loop never reads it anyway (`SENSOREGG_LOOP()` is skipped
+while `bleActive`).
+
+## Suspect 2: the two link parameters the display still hides
+
+Plan 0008's display line shows SD clock, PDU, and ATT payload — the three
+levers it fixed. The two levers it *asked for but could not force* are not
+shown, so a session where they didn't land is indistinguishable from one
+where they did:
+
+- **Connection interval.** The adaptive second ask
+ (`requestConnectionParameter(12)`) fires at +500 ms only when the measured
+ interval is slower than 15 ms, and Bluefruit's API sends **min == max**.
+ Apple's accessory rules are commonly read to also require
+ `Interval Max ≥ Interval Min + 15 ms`, so iOS may reject the flat-15 ms ask
+ and stay at 30 ms — plan 0008 explicitly accepted that risk as "downside
+ zero" *because there was no measurement either way*. There still isn't.
+ Android centrals sit near 48.75 ms unless someone requests
+ `CONNECTION_PRIORITY_HIGH` — and the native (Tauri) app's BLE backend does
+ not (nothing in `lib/loggers/doveslogger` or the Rust side requests it), so
+ a native-app download leans entirely on the firmware's second ask landing.
+- **PHY.** `requestPHY(2M)` is fired at connect and never read back. A link
+ that stayed at 1M doubles every packet's airtime, which halves
+ packets-per-event exactly when extension is already being denied.
+
+**Fix**: extend the diagnostic line (there is a spare display row under it):
+show `getConnectionInterval()` (ms) and `getPHY()` during a transfer, e.g. a
+second line `15ms 2M`. Then the next photo of a slow session names the lever
+that slipped — the entire philosophy of the existing line. Optionally: when
+the flat-15 ms second ask is rejected (interval unchanged at the +1500 ms
+readback), issue a third ask as a range (12–24 units) via
+`sd_ble_gap_conn_param_update` directly, which iOS is permitted to accept.
+
+## Ranked fix plan
+
+1. **Stop the egg scanner during transfer mode** (`SENSOREGG_SLEEP()` at the
+ top of `BLE_SETUP()`). Highest expected win on beta builds; one line;
+ reboot-on-exit makes it self-restoring. Bench A/B: same device, same
+ central, before/after — the KB/s line is the instrument.
+2. **Add interval + PHY to the transfer page.** Small, no behavior change,
+ converts the next regression report from a guess into a diagnosis.
+3. **Bench matrix** with (1)+(2) flashed: desktop Chrome, Android app,
+ iPad — record `KB/s / SD / PDU / chunk / interval / PHY` for each. This
+ retires the last unknowns and finally closes plan 0008's "needs a bench
+ measurement" status.
+4. Only if iOS is still slow after (1)–(3): the range-form connection-param
+ ask (12–24 units) for Apple's `Max ≥ Min + 15` reading.
+
+## Explicitly not the problem
+
+- SD SPI clock switch (verified in code and on-screen; reads ceiling at
+ ~950 KB/s at 8 MHz).
+- SD write hardening / recent SD changes (transfer path is read-only and
+ byte-identical since plan 0008).
+- The read-ahead / burst loop (keeps the HVN queue saturated; ~2 pkt/event is
+ a scheduler symptom, not a starvation symptom).
+- The web viewer (unchanged since May; notifications are unacknowledged at
+ ATT level, so the client cannot slow the device).
+- The camera link (released before transfer mode opens).
+- NeoPixel/profiling in the parked loop (~0.4 ms per 33 ms frame and
+ sub-µs brackets respectively — irrelevant at these rates).
diff --git a/docs/plans/0013-led-status-modes.md b/docs/plans/0013-led-status-modes.md
new file mode 100644
index 0000000..9b49c53
--- /dev/null
+++ b/docs/plans/0013-led-status-modes.md
@@ -0,0 +1,253 @@
+# 0013 — Assignable status LEDs, speed bar, lap/sector verdicts, two-stage purple
+
+Phase 2 of the NeoPixel subsystem. Plan 0006 ended with an explicit
+promise — *"settings-driven assignment: strip mode selection,
+per-status-LED action source/threshold/color… The POD tables above are
+the interface that phase fills in."* This fills it in, plus the four
+usability gaps a season of driving turned up.
+
+## The five problems
+
+1. **Both status LEDs were hardcoded** — left the rev flasher, right the
+ SensorEgg Temp1 tri-state. The right one was a shipped bug: on a
+ build without `BIRDSEYE_ENABLE_SENSOREGG` the accessor is permanently
+ NaN, so `evalStatus` took its `invalidColor` branch and the pixel sat
+ **solid blue for every second of every race on every stock logger**.
+ Plan 0007 recorded it as "known, accepted until phase-2
+ assignability".
+2. **The bar was dead on a car with no tachometer.** With RPM pinned at
+ 0 the scale renders nine dark pixels from lights-out until the first
+ lap completes and pace takes over.
+3. **`rev_limit` was misnamed.** It is the SHIFT/warning point;
+ `overrev_limit` (plan 0007) is the actual limit. Anyone opening
+ `/SETTINGS.json` learned the wrong thing.
+4. **Purple only ever fired on a sector**, never on a session-best lap —
+ the bigger achievement had no signal at all.
+5. **Egg gating leaked.** Three things were not behind the flag: the
+ status LED above, the `temp1_alert_c` setting, and the DOVEX
+ `Temp1`/`Junction1`/`Temp2` columns.
+
+## Status-LED modes — the new `led_status` pure unit
+
+Two settings, `led_status_left` and `led_status_right`, each naming one
+of eight modes. `led_status::evalMode()` is a pure function of an
+`Inputs` snapshot the glue builds once per frame and evaluates twice, so
+the two pixels can never disagree about the same instant.
+
+| Mode | Token | Rendering |
+|---|---|---|
+| Off | `off` | dark |
+| Target RPM | `rpm` | red flash at/above `target_rpm`, clears below 97 % — byte-for-byte the pre-0013 left LED |
+| Target speed | `speed` | red flash at/above `target_speed_mph`, clears 2 mph below; **off** with no GPS fix |
+| GPS | `gps` | steady: red no sats · yellow sats-no-fix · blue fix-no-time-lock · green locked |
+| Camera | `camera` | off unpaired · yellow session running with the camera not up · **flashing** blue linked but not ce82-subscribed · steady blue linked + subscribed · red recording |
+| Last lap | `lap` | steady, held: off no comparison · green faster than the previous lap · red slower · purple session best |
+| Last sector | `sector` | same, for the most recently closed sector vs the same sector last lap |
+| EGT | `egt` | Temp1 tri-state — red flash hot, off good, solid blue no probe signal. **SensorEgg builds only** |
+
+Three design choices worth keeping:
+
+- **Threshold modes delegate.** `rpm`/`speed`/`egt` build a
+ `led_modes::StatusAction` on the stack and hand it to the existing
+ `led_modes::evalStatus()`. Hysteresis, latch-release-on-invalid and
+ flash phase stay in one place with the regression tests they already
+ have — including the inverted-hysteresis strobe guard. `led_status`
+ owns the mode table; `led_modes` keeps owning threshold evaluation.
+ `flashOn()` was extracted from `evalStatus` so the camera mode's flash
+ and the threshold modes' flash are one definition, not two.
+- **`eggSupported` is an `Inputs` field, not an `#if` inside the unit.**
+ The host test binary compiles with the flag off, so a compile-time
+ gate would make the beta behaviour untestable. It also keeps the
+ *setting* round-tripping: `egt` still parses, still reports back over
+ `SGET`/`SLIST`, and only the rendering is gated. There is a test that
+ drives `kEgt` with `eggSupported = false` and a hot **valid** reading
+ and requires darkness — proving the gate is independent of the NaN
+ path that caused the original bug.
+- **`parseMode` is strict** (`bool parseMode(const char*, Mode*)`),
+ deliberately unlike `tach_filter::modeFromSetting()`'s safe fallback.
+ A typo must not silently pick a different mode or dark an LED: false
+ means "keep the compiled-in default", the same contract
+ `setting_parse::parseIntSetting()` has.
+
+### They stay lit on the menu — but only two of them
+
+`gps` and `camera` answer "am I ready to drive", which is a question you
+ask in the paddock, not on track. `led_status::activeOutsideRace()` names
+exactly those two; every other mode, and the whole 9-px bar, stays
+race-only as before. A pixel that is not being rendered has its latch
+**released**, not merely darkened — leaving one set would let a threshold
+that tripped in the last seconds of a session flash the instant the next
+one starts.
+
+## Speed bar for tach-less sessions
+
+The strip ladder's final `else` splits on `raceEntryCause ==
+RACE_ENTRY_TACH`:
+
+```
+engine stopped (proven tach session, 0 rpm) -> bar off
+no fix + timeValid -> green search pip
+pace valid -> pace pip
+tach session -> RPM scale (0..target_rpm)
+else -> SPEED scale (0..target_speed_mph)
+```
+
+`RACE_ENTRY_TACH` is the right gate and needs no new state:
+`idle_policy::tachProven()` already promotes MANUAL and SPEED sessions to
+TACH the moment the engine clears 500 rpm, so "not TACH" means precisely
+"this session has never seen an engine". It is the same predicate
+`raceEngineStopped()` keys on, so a speed-scale session can never blank
+its own bar. The rejected alternative — a live `tachLastReported > 0`
+test — would flip the bar between two scales at every stall, idle dip and
+pit stop.
+
+Accepted consequence: a tach kart entered via the menu shows the speed
+bar until the engine first crosses 500 rpm, a second or two of pit-out.
+It self-heals and never reverts.
+
+**`kSpeedRedFrac = 1.0f` — the speed bar has no red band.** The RPM bar's
+red half means "approaching the limiter, back off". There is no
+equivalent hazard in approaching your target speed, and painting the same
+nine pixels red for the thing you were aiming at inverts their meaning on
+identical hardware. `renderScale`'s `redFrom` lands at `kStripCount`,
+which no lit index reaches, so the bar is green all the way up. The "you
+got there" signal is a full bar plus the `speed` status mode.
+
+## Lap/sector verdicts — one state machine, not two
+
+The `lap` and `sector` modes need the previous lap's per-sector times and
+the previous lap time. `sector_purple` already owned the close-edge state
+machine that survives the library updating bests **at the start/finish
+crossing** — snapshot at sector OPEN, derive S3 as `lastLapTime - s1 -
+s2`. Duplicating that was the real hazard, so it was extended instead:
+`update()` now returns an `Event` (which sector or lap closed, its
+verdict, and the two purple flags) and `State` gained
+`lastSectorTime[3]`, `prevLapTime` and `bestLapAtOpen` — the same
+open-time snapshot trick, one level up, because the library folds a
+finished lap into `getBestLapTime()` at the crossing too.
+
+**The verdict is against the LAST RECORDED time, not the best.** Against
+the best you only ever get purple or red, which says nothing about
+whether you are improving. So `kBetter`/`kWorse` compare against the
+previous same-sector / previous lap, and `kBest` overrides when the
+session best also fell. `kNone` (nothing to compare against yet) renders
+dark rather than guessing — so lap 1 is unlit.
+
+Two smaller rules: equal times read as `kWorse` (the unit's existing
+strict-improvement rule, extended), and a sector that never closed —
+a dropout across the line leaving a zero current-lap time — emits **no
+event at all** and leaves the recorded time alone, so one glitch does not
+dark the indicator for a lap.
+
+**Sectors are now optional.** Lap tracking runs whenever the race has
+started, including a Lap Anything session with no sector lines; before
+this the whole monitor reset every frame on that input. Only the sector
+half is gated on `sectorsConfigured`.
+
+The file keeps the name `sector_purple` — purple is now one of its
+outputs rather than its whole job, and renaming it would have churned two
+CMake lists, a workflow, a test filename and three includes for zero
+behaviour change, burying the diff. A rename is a clean standalone
+follow-up; the header says so.
+
+## Two-stage purple
+
+A session-best **sector** keeps the shipped 1600 ms animation. A
+session-best **lap** gets `renderPurpleLap()`: 2600 ms with two
+centre-out wave passes — the same length as the boot flourish, already
+proven not to overstay. Both call one static renderer parameterised by
+duration/wave/passes, so a visual tweak lands in one place and
+`renderPurple()`'s golden frames still pin the sector animation byte for
+byte (there is a test asserting the two produce identical output through
+the first wave).
+
+A lap purple **outranks** a sector one: they land on the same frame
+whenever the last sector of a purple lap is also purple, and re-arming
+the shorter animation there would cut the bigger moment short.
+`neopixelNotifyPurpleLap()` takes over an in-flight sector animation;
+`neopixelNotifyPurpleSector()` declines to stomp a lap one.
+
+## `rev_limit` → `target_rpm`
+
+The key is renamed, with a migration at the top of
+`ensureDefaultSettings()` — before the defaults table walk, because that
+table writes the *default* for a missing key and would otherwise stamp
+15000 over a user's tuned 7550 on the very next boot:
+
+```
+if target_rpm absent and rev_limit present:
+ if setSetting("target_rpm", ) succeeded:
+ removeSetting("rev_limit")
+```
+
+The old key is dropped only once the new one is confirmed written, so a
+failed or refused write leaves the device exactly as it was rather than
+silently resetting its shift point. `settings.ino` gained
+`removeSetting()` for this — deliberately without `setSetting()`'s
+corrupt-file heal, since dropping a key is never urgent enough to justify
+quarantining someone's settings behind their back.
+
+## Egg gating
+
+| Item | Before | After |
+|---|---|---|
+| `sensoregg.ino`, accessors, Temp1/Temp2 pages, page constants, camera-test `egg:` line | gated | unchanged |
+| Right status LED hardwired to Temp1 | **ungated** — solid blue all race on stock builds | one selectable mode; `eggSupported == false` renders it dark |
+| `temp1_alert_c` default + boot read | **ungated** | gated |
+| DOVEX `Temp1`/`Junction1`/`Temp2` columns + CSV header | **ungated** (`nan` on every stock row) | gated |
+
+**The log format now forks by channel**, and that is a deliberate reversal
+of the rule the DOVEX columns were originally written under ("a log from
+a SensorEgg build and one from a stock build have identical shape").
+Stock logs are 13 data columns, SensorEgg logs 16. Three dead `nan`
+columns on every row of every stock log paid for nothing, and readers key
+the data section off its own CSV header line where all three are
+optional — the companion app's `doveParser.ts` builds a name→index map
+and does not read those columns at all today, so both shapes parse with
+no client change.
+
+`int settingTemp1AlertC` stays defined unconditionally (only the default
+and the read are gated) so the LED glue needs no `#if` at its use site.
+
+## Settings
+
+| Key | Default | Clamp | Notes |
+|---|---|---|---|
+| `target_rpm` | `15000` | 1000–20000 | replaces `rev_limit`, migrated |
+| `target_speed_mph` | `60` | 5–250 | mph on device; the app converts for display. Floor is 5, not 0, because `renderScale`'s span guard blanks the bar on a zero ceiling — indistinguishable from dead hardware |
+| `led_status_left` | `rpm` | strict `parseMode` | preserves the old left LED |
+| `led_status_right` | `egt` (SensorEgg) / `lap` (stock) | strict `parseMode` | preserves the old right LED on beta; gives stock builds something useful instead of a blue pixel |
+| `temp1_alert_c` | `650` | 50–1200 | now SensorEgg-only |
+
+**Byte budget.** The read cap is 1023 bytes and exceeding it is a silent,
+self-inflicting boot loop (see `settings.h`). Measured, not estimated:
+the default file goes from 543 bytes to **570 on a stock build** (23
+keys) and **592 on a SensorEgg one** (24) — 599 / 621 with the longest
+value every key will accept, and ~612 for the single boot on a SensorEgg
+device where both `target_rpm` and `rev_limit` exist. Gating
+`temp1_alert_c` and dropping `rev_limit` paid for two of the three new
+keys. That leaves roughly sixteen average keys of headroom; each costs
+`len(key) + len(value) + 6`.
+
+## CI
+
+`.github/workflows/clang-tidy.yml` lists every pure unit by hand —
+`led_status.cpp` is added there, or it would be silently unanalysed.
+
+`compile-sketch.yml` gained a `compile-stock-arm` job, gated on the same
+BETA condition as the flag-on build. The main job picks ONE flag set per
+PR, so a BETA-targeted PR compiled only with the flags on — and this plan
+adds several `#else` arms (the DOVEX row and header shapes, the settings
+defaults) that would otherwise reach master having never been through a
+compiler. One board is enough; the arms differ only in feature flags.
+
+## Testing
+
+`tests/led_status_test.cpp` is new (mode-name round-trip and strict-parse
+rejection, every ladder level, the camera flash-vs-steady distinction,
+threshold hysteresis, the egg gate, verdict colours, latch lifetime).
+`sector_purple_test.cpp`'s eight existing cases are preserved through a
+`purpleOf()` shim and joined by the lap/sector verdict cases, including
+the mirror of the S3 anti-race case one level up: fold the new best into
+`bestLapTime` *before* the edge is seen and require the purple lap to
+still fire.
diff --git a/docs/plans/0014-tach-rpm-single-pickup.md b/docs/plans/0014-tach-rpm-single-pickup.md
new file mode 100644
index 0000000..ffb3599
--- /dev/null
+++ b/docs/plans/0014-tach-rpm-single-pickup.md
@@ -0,0 +1,84 @@
+# 0014 — Tach RPM from a single pickup: cylinder count is not a divider
+
+> Status: implemented.
+> Supersedes the RPM geometry of
+> [0003](0003-rpm-spark-cylinder-settings.md). `spark_mode` survives
+> unchanged; `cylinder_count` survives as a setting but leaves the math.
+
+## The bug
+
+Plan 0003 made RPM "true RPM" with:
+
+```
+pulses_per_rev = cylinder_count × (spark_mode == wasted ? 1.0 : 0.5)
+```
+
+That formula is only correct if the pickup sees **every** cylinder — a
+clamp on a shared coil or king lead. This device has **one sense wire and
+one clamp**, and it goes around **one spark plug wire**. On that hardware
+the formula divides RPM by the cylinder count.
+
+The case that surfaced it: a **V8 on a traditional magneto**, which is
+electrically compatible with the pickup. Configured honestly — 8
+cylinders, single fire — the device read **an eighth** of the real crank
+speed:
+
+| | pulses/min at 3000 RPM | plan 0003 reads | correct |
+|---|---|---|---|
+| V8, single fire, one plug wire | 1500 | 375 | 3000 |
+| Twin, wasted spark, one plug wire | 3000 | 1500 | 3000 |
+
+Plan 0003 documented its way around this ("`cylinder_count` is cylinders
+the pickup *sees*; a clamp on one plug wire of a twin sees ONE, so leave
+it at 1"), which is the actual defect: a settings field named **Cylinders**
+that must be set to something other than the engine's cylinder count is a
+trap, not a configuration. Every user who reads it literally gets wrong
+RPM, and the docs' answer is that they held it wrong.
+
+## The rule
+
+The count is **dumb** — it is the engine's cylinder count, and it does not
+enter the RPM math:
+
+```
+revs_per_pulse = (spark_mode == wasted ? 1.0 : 2.0)
+```
+
+One clamped wire carries one cylinder's ignition. A 2-stroke or
+wasted-spark plug fires every crank revolution; a 4-stroke single-fire
+(distributor/magneto) plug fires every other one. Cylinder count changes
+neither. 500 pulses/min is 500 RPM on a single and on a twin alike.
+
+The **debounce** loses the term for the same reason — one wire never
+delivers pulses faster than the cylinder it is wrapped around fires — so
+it returns to a flat 3 ms (6 ms single-fire), and the ~20 000 RPM ceiling
+is now the same in both spark modes instead of falling with each
+configured cylinder.
+
+## What cylinder_count is for now
+
+It stays, as the engine descriptor it was always read as, and it drives
+the **warning**: above one cylinder, crank speed is *inferred* from one
+cylinder's firing rate. Between firings the RPM is an assumption, and a
+cylinder that stops firing reads as an engine that stopped.
+
+That is not a defect to fix — it is how every clamp-on inductive tach
+works, and it is known and accepted for this class of pickup. It is
+stated in the settings UI (DovesDataViewer), in the README settings
+table, and on the boot debug line, so nobody has to discover it from a
+trace.
+
+`clampCylinderCount()` and `rpmIsInferred()` are the only consumers.
+Nothing in the RPM path branches on the count, which is enforced by
+`revsPerPulse()`'s signature no longer accepting one.
+
+## Compatibility
+
+- **Single-cylinder devices (the default, and the fleet):** byte-identical.
+ `revsPerPulse(wasted) == revsPerPulse(1, wasted)` for every value the
+ old function could return at one cylinder.
+- **Anyone who set `cylinder_count` > 1:** their RPM changes — it stops
+ being divided. That is the fix, and it is a **breaking behaviour
+ change** for those devices' logs.
+- **`SETTINGS.json`:** unchanged. Same keys, same defaults, so no
+ migration, no file-size movement, and no re-provisioning.
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 1565175..dd5c317 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -20,6 +20,8 @@ add_executable(birdseye_tests
crc32_test.cpp
sd_access_policy_test.cpp
lap_format_test.cpp
+ local_time_test.cpp
+ setting_parse_test.cpp
tach_filter_test.cpp
camera_fsm_test.cpp
idle_policy_test.cpp
@@ -34,6 +36,13 @@ add_executable(birdseye_tests
course_creator_test.cpp
track_json_test.cpp
course_prune_test.cpp
+ led_frame_test.cpp
+ led_modes_test.cpp
+ led_status_test.cpp
+ led_animations_test.cpp
+ sector_purple_test.cpp
+ ble_stream_test.cpp
+ loop_profile_test.cpp
${BIRDSEYE_DIR}/haversine.cpp
${BIRDSEYE_DIR}/gps_stats.cpp
${BIRDSEYE_DIR}/gps_time.cpp
@@ -43,6 +52,8 @@ add_executable(birdseye_tests
${BIRDSEYE_DIR}/crc32.cpp
${BIRDSEYE_DIR}/sd_access_policy.cpp
${BIRDSEYE_DIR}/lap_format.cpp
+ ${BIRDSEYE_DIR}/local_time.cpp
+ ${BIRDSEYE_DIR}/setting_parse.cpp
${BIRDSEYE_DIR}/tach_filter.cpp
${BIRDSEYE_DIR}/camera_fsm.cpp
${BIRDSEYE_DIR}/idle_policy.cpp
@@ -57,6 +68,13 @@ add_executable(birdseye_tests
${BIRDSEYE_DIR}/crossing_pattern.cpp
${BIRDSEYE_DIR}/course_creator.cpp
${BIRDSEYE_DIR}/track_json.cpp
+ ${BIRDSEYE_DIR}/led_frame.cpp
+ ${BIRDSEYE_DIR}/led_modes.cpp
+ ${BIRDSEYE_DIR}/led_status.cpp
+ ${BIRDSEYE_DIR}/led_animations.cpp
+ ${BIRDSEYE_DIR}/sector_purple.cpp
+ ${BIRDSEYE_DIR}/ble_stream.cpp
+ ${BIRDSEYE_DIR}/loop_profile.cpp
)
target_include_directories(birdseye_tests PRIVATE
diff --git a/tests/ble_stream_test.cpp b/tests/ble_stream_test.cpp
new file mode 100644
index 0000000..d3f80a1
--- /dev/null
+++ b/tests/ble_stream_test.cpp
@@ -0,0 +1,247 @@
+// Tests for the BLE file-transfer read-ahead bookkeeping.
+//
+// The point of this unit is that an off-by-one here corrupts a downloaded
+// session file, so the model transfer below actually reassembles bytes and
+// compares them, rather than only asserting on indices.
+
+#include
+
+#include
+
+#include "ble_stream.h"
+#include "doctest.h"
+
+using namespace ble_stream;
+
+TEST_CASE("chunkSize takes the ATT header off the MTU") {
+ CHECK(chunkSize(247) == 244);
+ CHECK(chunkSize(185) == 182); // iOS caps the ATT MTU at 185
+ CHECK(chunkSize(23) == 20); // unnegotiated default
+}
+
+TEST_CASE("chunkSize clamps to the characteristic's max length") {
+ // A generous MTU cannot make the data characteristic send more than it
+ // was configured for.
+ CHECK(chunkSize(512) == kMaxNotifyLen);
+ CHECK(chunkSize(kMaxNotifyLen + 3) == kMaxNotifyLen);
+ CHECK(chunkSize(kMaxNotifyLen + 4) == kMaxNotifyLen);
+}
+
+TEST_CASE("chunkSize never returns zero") {
+ // A degenerate MTU must not produce a notify loop that sends nothing and
+ // never advances the file.
+ CHECK(chunkSize(0) == 20);
+ CHECK(chunkSize(3) == 20);
+ CHECK(chunkSize(4) > 0);
+}
+
+TEST_CASE("rateBytesPerSec") {
+ CHECK(rateBytesPerSec(0, 0) == 0); // no window, no divide by zero
+ CHECK(rateBytesPerSec(1000, 0) == 0);
+ CHECK(rateBytesPerSec(29491, 1000) == 29491);
+ CHECK(rateBytesPerSec(1024, 500) == 2048);
+}
+
+TEST_CASE("rateBytesPerSec does not overflow on a multi-megabyte transfer") {
+ // 3.3 MB * 1000 is far past uint32; the field report that motivated this
+ // work was exactly that size.
+ CHECK(rateBytesPerSec(3300000u, 114000u) == 28947u);
+}
+
+TEST_CASE("a fresh ReadAhead has nothing to send") {
+ ReadAhead ra(4096);
+ CHECK(ra.capacity() == 4096);
+ CHECK(ra.pending() == 0);
+ CHECK(ra.head() == 0);
+ CHECK(ra.needsRefill(244));
+
+ uint32_t off = 0xFFFFFFFFu;
+ CHECK(ra.nextSlice(244, &off) == 0);
+}
+
+TEST_CASE("compact on an empty buffer asks for no move and offers the whole buffer") {
+ ReadAhead ra(4096);
+ Move m = ra.compact();
+ CHECK(m.length == 0);
+ CHECK(ra.fillOffset() == 0);
+ CHECK(ra.fillSpace() == 4096);
+}
+
+TEST_CASE("fill then slice hands out full chunks") {
+ ReadAhead ra(4096);
+ ra.compact();
+ ra.commitFill(4096);
+ CHECK(ra.pending() == 4096);
+ CHECK_FALSE(ra.needsRefill(244));
+
+ uint32_t off = 0;
+ CHECK(ra.nextSlice(244, &off) == 244);
+ CHECK(off == 0);
+ ra.consume(244);
+
+ CHECK(ra.nextSlice(244, &off) == 244);
+ CHECK(off == 244);
+ CHECK(ra.head() == 244);
+ CHECK(ra.pending() == 4096 - 244);
+}
+
+TEST_CASE("a short tail is refilled, not sent") {
+ // 4096 / 244 = 16 full chunks with 192 bytes left over. Sending that 192
+ // would put a runt packet at every buffer boundary.
+ ReadAhead ra(4096);
+ ra.compact();
+ ra.commitFill(4096);
+ for (int i = 0; i < 16; i++) ra.consume(244);
+
+ CHECK(ra.pending() == 192);
+ CHECK(ra.needsRefill(244));
+}
+
+TEST_CASE("compact moves the unsent tail to the front") {
+ ReadAhead ra(4096);
+ ra.compact();
+ ra.commitFill(4096);
+ for (int i = 0; i < 16; i++) ra.consume(244);
+
+ uint32_t tail = ra.pending();
+ Move m = ra.compact();
+ CHECK(m.srcOffset == 16 * 244);
+ CHECK(m.length == tail);
+ CHECK(ra.head() == 0);
+ CHECK(ra.pending() == tail);
+ // The refill lands behind the preserved tail and may not overwrite it.
+ CHECK(ra.fillOffset() == tail);
+ CHECK(ra.fillSpace() == 4096 - tail);
+}
+
+TEST_CASE("compact of an already-front-aligned tail asks for no move") {
+ ReadAhead ra(64);
+ ra.compact();
+ ra.commitFill(10);
+ Move m = ra.compact();
+ CHECK(m.length == 0); // nothing to memmove — it is already at offset 0
+ CHECK(ra.pending() == 10);
+ CHECK(ra.fillOffset() == 10);
+}
+
+TEST_CASE("a failed notify re-sends the same bytes") {
+ // The transport failure model: consume() is simply not called, so the head
+ // does not advance and the next pass hands out the identical slice. There
+ // is no file position to rewind, so a dropped chunk cannot punch a hole in
+ // a transfer that still reports DONE.
+ ReadAhead ra(4096);
+ ra.compact();
+ ra.commitFill(4096);
+
+ uint32_t first = 0, again = 0;
+ CHECK(ra.nextSlice(244, &first) == 244);
+ // ...notify() fails here, nothing is consumed...
+ CHECK(ra.nextSlice(244, &again) == 244);
+ CHECK(again == first);
+ CHECK(ra.head() == 0);
+}
+
+TEST_CASE("commitFill clamps to the free space") {
+ ReadAhead ra(100);
+ ra.compact();
+ ra.commitFill(500); // a read that lies about how much it returned
+ CHECK(ra.pending() == 100);
+ CHECK(ra.fillSpace() == 0);
+}
+
+TEST_CASE("consume clamps to what is buffered") {
+ ReadAhead ra(100);
+ ra.compact();
+ ra.commitFill(30);
+ ra.consume(200);
+ CHECK(ra.pending() == 0);
+ CHECK(ra.head() == 30);
+}
+
+TEST_CASE("reset drops a partially streamed file") {
+ ReadAhead ra(4096);
+ ra.compact();
+ ra.commitFill(4096);
+ ra.consume(244);
+ ra.reset();
+ CHECK(ra.pending() == 0);
+ CHECK(ra.head() == 0);
+ CHECK(ra.fillSpace() == 4096);
+}
+
+// ---------------------------------------------------------------------------
+// End-to-end model: stream a file through the real buffer discipline and
+// check the bytes that come out the far side are the bytes that went in.
+// ---------------------------------------------------------------------------
+
+static std::vector streamFile(size_t fileSize, uint32_t capacity,
+ uint16_t chunk, size_t* fullChunksOut) {
+ std::vector file(fileSize);
+ for (size_t i = 0; i < fileSize; i++) {
+ file[i] = (uint8_t)((i * 31u + (i >> 8)) & 0xFF);
+ }
+
+ std::vector buf(capacity, 0);
+ std::vector sent;
+ ReadAhead ra(capacity);
+ size_t filePos = 0;
+ size_t fullChunks = 0;
+
+ while (true) {
+ if (ra.needsRefill(chunk)) {
+ Move m = ra.compact();
+ if (m.length) memmove(buf.data(), buf.data() + m.srcOffset, m.length);
+ size_t want = ra.fillSpace();
+ size_t got = (filePos + want <= fileSize) ? want : (fileSize - filePos);
+ memcpy(buf.data() + ra.fillOffset(), file.data() + filePos, got);
+ filePos += got;
+ ra.commitFill((uint32_t)got);
+ }
+
+ uint32_t off = 0;
+ uint16_t n = ra.nextSlice(chunk, &off);
+ if (n == 0) break; // buffer drained and the file is exhausted
+ if (n == chunk) fullChunks++;
+ sent.insert(sent.end(), buf.begin() + off, buf.begin() + off + n);
+ ra.consume(n);
+ }
+
+ if (fullChunksOut) *fullChunksOut = fullChunks;
+ return sent;
+}
+
+TEST_CASE("a modelled transfer delivers the file byte-for-byte") {
+ struct Case {
+ size_t size;
+ uint16_t chunk;
+ };
+ // Sizes chosen around the buffer boundary: under one buffer, exactly one,
+ // one past, and a large odd size. Chunks cover the negotiated MTUs a real
+ // central hands us.
+ const Case cases[] = {
+ {0, 244}, {1, 244}, {243, 244}, {244, 244},
+ {4095, 244}, {4096, 244}, {4097, 244}, {100000, 244},
+ {100000, 182}, {100000, 20}, {5000, 182},
+ };
+
+ for (const Case& c : cases) {
+ CAPTURE(c.size);
+ CAPTURE(c.chunk);
+ std::vector sent = streamFile(c.size, 4096, c.chunk, nullptr);
+ REQUIRE(sent.size() == c.size);
+ for (size_t i = 0; i < c.size; i++) {
+ // Regenerate rather than trusting a second copy of the pattern.
+ REQUIRE(sent[i] == (uint8_t)((i * 31u + (i >> 8)) & 0xFF));
+ }
+ }
+}
+
+TEST_CASE("compacting refills keep every packet but the last one full") {
+ // The whole reason compact() exists. 100000 bytes at 244 = 409 full chunks
+ // plus a 236-byte remainder; a non-compacting buffer would additionally emit
+ // a runt at each of the ~24 buffer boundaries.
+ size_t fullChunks = 0;
+ std::vector sent = streamFile(100000, 4096, 244, &fullChunks);
+ CHECK(sent.size() == 100000);
+ CHECK(fullChunks == 100000 / 244);
+}
diff --git a/tests/course_creator_test.cpp b/tests/course_creator_test.cpp
index d75144e..5aa432d 100644
--- a/tests/course_creator_test.cpp
+++ b/tests/course_creator_test.cpp
@@ -71,6 +71,40 @@ TEST_CASE("the track prompt records which track the course lands in") {
CHECK(s.screen == Screen::kTypeSelect);
}
+TEST_CASE("both entry screens can be cancelled out of") {
+ // Every screen deeper in the flow already had a Cancel/Back. Without one
+ // here, the two steps between the main menu and the line menu were a
+ // one-way door: a mis-pressed "Create" could only be escaped by walking
+ // forward into a screen that had one.
+ State s;
+ begin(s, true);
+ CHECK(s.screen == Screen::kTrackPrompt);
+ CHECK(rowCount(s) == 3);
+ CHECK(rowAt(s, 2).row == Row::kCancel);
+ CHECK(select(s, 2) == Action::kExit);
+
+ begin(s, true);
+ select(s, 0); // "Here" -> type picker
+ CHECK(s.screen == Screen::kTypeSelect);
+ CHECK(rowCount(s) == 3);
+ CHECK(rowAt(s, 2).row == Row::kCancel);
+ CHECK(select(s, 2) == Action::kExit);
+
+ // The no-track-nearby entry lands straight on the type picker, so that
+ // screen's Cancel is the ONLY way out of the creator for those users.
+ begin(s, false);
+ CHECK(s.screen == Screen::kTypeSelect);
+ CHECK(select(s, 2) == Action::kExit);
+}
+
+TEST_CASE("a stale index on an entry screen clamps to Cancel") {
+ State s;
+ begin(s, true);
+ CHECK(rowAt(s, 99).row == Row::kCancel);
+ begin(s, false);
+ CHECK(rowAt(s, 99).row == Row::kCancel);
+}
+
TEST_CASE("sprint gets a finish row, circuit does not") {
const State circuit = atLineMenu(CourseKind::kCircuit);
const State sprint = atLineMenu(CourseKind::kSprint);
diff --git a/tests/led_animations_test.cpp b/tests/led_animations_test.cpp
new file mode 100644
index 0000000..557781c
--- /dev/null
+++ b/tests/led_animations_test.cpp
@@ -0,0 +1,226 @@
+#include
+
+#include "doctest.h"
+#include "led_animations.h"
+
+using led_frame::Frame;
+using led_frame::kPixelCount;
+using led_frame::Rgb;
+
+static bool framesEqual(const Frame& a, const Frame& b) {
+ for (int i = 0; i < kPixelCount; i++) {
+ if (a.px[i].r != b.px[i].r || a.px[i].g != b.px[i].g ||
+ a.px[i].b != b.px[i].b) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool frameLit(const Frame& f) {
+ for (int i = 0; i < kPixelCount; i++) {
+ if (f.px[i].r || f.px[i].g || f.px[i].b) {
+ return true;
+ }
+ }
+ return false;
+}
+
+TEST_CASE("mix avalanches consecutive inputs apart") {
+ CHECK(led_animations::mix(1) != led_animations::mix(2));
+ CHECK(led_animations::mix(0) != 0);
+ // Stateless: same input, same output.
+ CHECK(led_animations::mix(12345) == led_animations::mix(12345));
+}
+
+TEST_CASE("hueToRgb: wheel anchors") {
+ Rgb r = led_animations::hueToRgb(0);
+ CHECK(r.r == 255);
+ CHECK(r.g == 0);
+ CHECK(r.b == 0);
+ Rgb g = led_animations::hueToRgb(85);
+ CHECK(g.r == 0);
+ CHECK(g.g == 255);
+ CHECK(g.b == 0);
+ Rgb b = led_animations::hueToRgb(170);
+ CHECK(b.r == 0);
+ CHECK(b.g == 0);
+ CHECK(b.b == 255);
+}
+
+TEST_CASE("renderBoot: deterministic in (t, seed)") {
+ const uint32_t seed = 0xC0FFEE;
+ for (uint32_t t : {0U, 137U, 800U, 1000U, 1499U, 1800U, 2300U, 2599U}) {
+ CAPTURE(t);
+ Frame a{};
+ Frame b{};
+ led_animations::renderBoot(t, seed, a);
+ led_animations::renderBoot(t, seed, b);
+ CHECK(framesEqual(a, b));
+ }
+}
+
+TEST_CASE("renderBoot: running while t < duration, done at duration") {
+ Frame f{};
+ CHECK(led_animations::renderBoot(0, 1, f));
+ CHECK(frameLit(f)); // comet visible immediately
+ CHECK(led_animations::renderBoot(led_animations::kBootDurationMs - 1, 1, f));
+ CHECK(!led_animations::renderBoot(led_animations::kBootDurationMs, 1, f));
+ CHECK(!frameLit(f)); // done clears the frame
+ CHECK(!led_animations::renderBoot(999999, 1, f));
+}
+
+TEST_CASE("renderBoot: comet moves over time") {
+ const uint32_t seed = 7;
+ Frame a{};
+ Frame b{};
+ led_animations::renderBoot(100, seed, a);
+ led_animations::renderBoot(400, seed, b);
+ CHECK(!framesEqual(a, b));
+}
+
+TEST_CASE("renderBoot: different seeds differ in the sparkle phase") {
+ // Pick a time in the sparkle window past the comet fade so only
+ // sparkles are on — seed is the only variable.
+ const uint32_t t = led_animations::kBootFadeEndMs + 100;
+ bool differed = false;
+ for (uint32_t s = 0; s < 8 && !differed; s++) {
+ Frame a{};
+ Frame b{};
+ led_animations::renderBoot(t + (uint32_t)(s * led_animations::kSparkleSlotMs),
+ 0x1111, a);
+ led_animations::renderBoot(t + (uint32_t)(s * led_animations::kSparkleSlotMs),
+ 0x2222, b);
+ if (!framesEqual(a, b)) {
+ differed = true;
+ }
+ }
+ CHECK(differed);
+}
+
+TEST_CASE("renderPurple: deterministic, lifecycle, wave from center") {
+ const uint32_t seed = 42;
+ Frame a{};
+ Frame b{};
+ led_animations::renderPurple(200, seed, a);
+ led_animations::renderPurple(200, seed, b);
+ CHECK(framesEqual(a, b));
+
+ // Early wave: center chain pixel lit, chain ends not yet reached.
+ Frame w{};
+ CHECK(led_animations::renderPurple(60, seed, w));
+ const int center = led_frame::kStripFirst + led_frame::kStripCenter;
+ CHECK((w.px[center].r || w.px[center].b));
+ CHECK(!(w.px[0].r || w.px[0].g || w.px[0].b));
+ CHECK(!(w.px[kPixelCount - 1].r || w.px[kPixelCount - 1].g ||
+ w.px[kPixelCount - 1].b));
+
+ // Hold phase: everything lit (status pixels included — celebration).
+ Frame h{};
+ CHECK(led_animations::renderPurple(800, seed, h));
+ for (int i = 0; i < kPixelCount; i++) {
+ CAPTURE(i);
+ CHECK((h.px[i].r || h.px[i].g || h.px[i].b));
+ }
+
+ // Fade phase dimmer than hold.
+ Frame fade{};
+ CHECK(led_animations::renderPurple(1550, seed, fade));
+ CHECK(fade.px[center].b < h.px[center].b);
+
+ // Done at duration.
+ Frame d{};
+ CHECK(!led_animations::renderPurple(led_animations::kPurpleDurationMs, seed, d));
+ CHECK(!frameLit(d));
+}
+
+TEST_CASE("golden frames: fixed (t, seed) triplets stay stable") {
+ // Locks the deterministic contract — if an intentional visual tweak
+ // changes these, re-record the numbers in the same commit.
+ Frame f{};
+ led_animations::renderBoot(0, 0xBEEF, f);
+ // t=0: comet head at px 0, full red (hue 0), tail wrapping behind.
+ CHECK(f.px[0].r == 255);
+ CHECK(f.px[0].g == 0);
+ CHECK(f.px[0].b == 0);
+
+ Frame p{};
+ led_animations::renderPurple(700, 0xBEEF, p);
+ // Hold phase base color is kPurple where no sparkle landed. At least
+ // the majority of pixels must be exactly base purple.
+ int base = 0;
+ for (int i = 0; i < kPixelCount; i++) {
+ if (p.px[i].r == led_frame::kPurple.r && p.px[i].g == led_frame::kPurple.g &&
+ p.px[i].b == led_frame::kPurple.b) {
+ base++;
+ }
+ }
+ CHECK(base >= kPixelCount / 2);
+}
+
+///////////////////////////////////////////
+// Plan 0013: the session-best-LAP celebration. Two wave passes and a
+// longer run, sharing one renderer with the sector version.
+///////////////////////////////////////////
+
+TEST_CASE("renderPurpleLap: lifecycle, determinism, and it outlasts the sector one") {
+ led_frame::Frame a, b;
+ for (uint32_t t : {0u, 200u, 399u, 500u, 1000u, 1800u, 2300u, 2599u}) {
+ CHECK(led_animations::renderPurpleLap(t, 0xBEEF, a));
+ CHECK(led_animations::renderPurpleLap(t, 0xBEEF, b));
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(a.px[i].r == b.px[i].r);
+ CHECK(a.px[i].g == b.px[i].g);
+ CHECK(a.px[i].b == b.px[i].b);
+ }
+ }
+
+ // Runs past where the sector animation has already finished — that is
+ // the whole point of having two.
+ CHECK(led_animations::renderPurpleLap(led_animations::kPurpleDurationMs,
+ 0xBEEF, a));
+ // ...and stops at its own duration, with the frame cleared.
+ CHECK_FALSE(led_animations::renderPurpleLap(
+ led_animations::kPurpleLapDurationMs, 0xBEEF, a));
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(a.px[i].r == 0);
+ CHECK(a.px[i].g == 0);
+ CHECK(a.px[i].b == 0);
+ }
+ CHECK_FALSE(led_animations::renderPurpleLap(999999, 0xBEEF, a));
+}
+
+TEST_CASE("renderPurpleLap: the second wave really is a second pass") {
+ // Early in a pass the chain ends are dark (the wave has not reached
+ // them); late in a pass they are lit. If the lap version collapsed
+ // into a single long sweep, the end pixel would light once and stay
+ // lit — this catches that.
+ led_frame::Frame f;
+ led_animations::renderPurpleLap(0, 0xBEEF, f);
+ const bool endDarkPass1 = (f.px[0].r == 0 && f.px[0].b == 0);
+ led_animations::renderPurpleLap(led_animations::kPurpleLapWaveMs - 1, 0xBEEF, f);
+ const bool endLitPass1 = (f.px[0].r != 0 || f.px[0].b != 0);
+ led_animations::renderPurpleLap(led_animations::kPurpleLapWaveMs, 0xBEEF, f);
+ const bool endDarkPass2 = (f.px[0].r == 0 && f.px[0].b == 0);
+
+ CHECK(endDarkPass1);
+ CHECK(endLitPass1);
+ CHECK(endDarkPass2); // the sweep restarted
+}
+
+TEST_CASE("both purple animations share one core for the first wave") {
+ // renderPurple() must be untouched by the lap variant's arrival — the
+ // golden case above pins its exact frames, and this pins that the lap
+ // version is genuinely the same renderer rather than a fork that can
+ // drift.
+ for (uint32_t t : {0u, 100u, 250u, 399u}) {
+ led_frame::Frame sector, lap;
+ led_animations::renderPurple(t, 0x1234, sector);
+ led_animations::renderPurpleLap(t, 0x1234, lap);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(sector.px[i].r == lap.px[i].r);
+ CHECK(sector.px[i].g == lap.px[i].g);
+ CHECK(sector.px[i].b == lap.px[i].b);
+ }
+ }
+}
diff --git a/tests/led_frame_test.cpp b/tests/led_frame_test.cpp
new file mode 100644
index 0000000..7a04fc6
--- /dev/null
+++ b/tests/led_frame_test.cpp
@@ -0,0 +1,125 @@
+#include
+
+#include "doctest.h"
+#include "led_frame.h"
+
+using led_frame::Frame;
+using led_frame::Rgb;
+
+TEST_CASE("layout constants describe the 11-px chain") {
+ CHECK(led_frame::kPixelCount == 11);
+ CHECK(led_frame::kStatusLeft == 0);
+ CHECK(led_frame::kStatusRight == 10);
+ CHECK(led_frame::kStripFirst == 1);
+ CHECK(led_frame::kStripCount == 9);
+ // Strip-relative center maps to chain pixel 5, the middle of 1..9.
+ CHECK(led_frame::kStripFirst + led_frame::kStripCenter == 5);
+}
+
+TEST_CASE("clear turns every pixel off") {
+ Frame f{};
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ f.px[i] = Rgb{255, 128, 7};
+ }
+ led_frame::clear(f);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(f.px[i].r == 0);
+ CHECK(f.px[i].g == 0);
+ CHECK(f.px[i].b == 0);
+ }
+}
+
+TEST_CASE("scale: identity at 255, off at 0, rounds to nearest") {
+ Rgb c{255, 100, 1};
+ Rgb id = led_frame::scale(c, 255);
+ CHECK(id.r == 255);
+ CHECK(id.g == 100);
+ CHECK(id.b == 1);
+
+ Rgb off = led_frame::scale(c, 0);
+ CHECK(off.r == 0);
+ CHECK(off.g == 0);
+ CHECK(off.b == 0);
+
+ // 255 * 128 / 255 = 128 exactly; 100 * 128 / 255 = 50.19 -> 50.
+ Rgb half = led_frame::scale(c, 128);
+ CHECK(half.r == 128);
+ CHECK(half.g == 50);
+ // 1 * 128 / 255 = 0.50 -> rounds to 1, not truncated to 0.
+ CHECK(half.b == 1);
+}
+
+TEST_CASE("applyCap: no channel of any pixel ever exceeds the cap") {
+ const uint8_t caps[] = {0, 1, 64, 128, 254, 255};
+ for (uint8_t cap : caps) {
+ CAPTURE((int)cap);
+ Frame f{};
+ // Worst-case saturated frame plus a mixed gradient.
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ f.px[i] = (i % 2 == 0) ? Rgb{255, 255, 255}
+ : Rgb{(uint8_t)(i * 23), 255, (uint8_t)(255 - i)};
+ }
+ led_frame::applyCap(f, cap);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(f.px[i].r <= cap);
+ CHECK(f.px[i].g <= cap);
+ CHECK(f.px[i].b <= cap);
+ }
+ }
+}
+
+TEST_CASE("applyCap: 255 is identity, 0 is all-off") {
+ Frame f{};
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ f.px[i] = Rgb{(uint8_t)(i * 20), (uint8_t)(255 - i * 10), 33};
+ }
+ Frame copy = f;
+ led_frame::applyCap(f, 255);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(f.px[i].r == copy.px[i].r);
+ CHECK(f.px[i].g == copy.px[i].g);
+ CHECK(f.px[i].b == copy.px[i].b);
+ }
+ led_frame::applyCap(f, 0);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(f.px[i].r == 0);
+ CHECK(f.px[i].g == 0);
+ CHECK(f.px[i].b == 0);
+ }
+}
+
+TEST_CASE("applyCap: full-white maps to exactly the cap") {
+ Frame f{};
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ f.px[i] = led_frame::kWhite;
+ }
+ led_frame::applyCap(f, 64);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(f.px[i].r == 64);
+ CHECK(f.px[i].g == 64);
+ CHECK(f.px[i].b == 64);
+ }
+}
+
+TEST_CASE("physicalIndex mirrors the whole chain (data-in wired right)") {
+ // kChainReversed is true for the current hardware: logical left
+ // status (0) sits at the far end of the wire (10), the centerline
+ // stays centered, and the map is an involution (its own inverse).
+ static_assert(led_frame::kChainReversed, "test written for reversed wiring");
+ CHECK(led_frame::physicalIndex(led_frame::kStatusLeft) == 10);
+ CHECK(led_frame::physicalIndex(led_frame::kStatusRight) == 0);
+ CHECK(led_frame::physicalIndex(led_frame::kStripFirst + led_frame::kStripCenter) ==
+ led_frame::kStripFirst + led_frame::kStripCenter);
+ for (int i = 0; i < led_frame::kPixelCount; i++) {
+ CHECK(led_frame::physicalIndex(led_frame::physicalIndex(i)) == i);
+ }
+ // Logical leftmost strip pixel lands one in from the wire's far end.
+ CHECK(led_frame::physicalIndex(led_frame::kStripFirst) == 9);
+}
+
+TEST_CASE("add saturates at 255") {
+ Rgb s = led_frame::add(Rgb{200, 100, 0}, Rgb{100, 100, 5});
+ CHECK(s.r == 255);
+ CHECK(s.g == 200);
+ CHECK(s.b == 5);
+}
diff --git a/tests/led_modes_test.cpp b/tests/led_modes_test.cpp
new file mode 100644
index 0000000..fdc09d0
--- /dev/null
+++ b/tests/led_modes_test.cpp
@@ -0,0 +1,381 @@
+#include
+
+#include "doctest.h"
+#include "led_modes.h"
+
+using led_frame::kStripCenter;
+using led_frame::kStripCount;
+using led_frame::Rgb;
+using led_modes::pacePip;
+using led_modes::PacePip;
+using led_modes::ScaleSpec;
+using led_modes::Source;
+using led_modes::StatusAction;
+using led_modes::StatusState;
+
+static bool isRed(Rgb c) { return c.r > 0 && c.g == 0 && c.b == 0; }
+static bool isGreen(Rgb c) { return c.g > 0 && c.r == 0 && c.b == 0; }
+static bool isOff(Rgb c) { return c.r == 0 && c.g == 0 && c.b == 0; }
+
+TEST_CASE("pace pip: deadband parks on the white centerline") {
+ for (float pace : {0.0f, 0.125f, -0.125f, 0.05f}) {
+ CAPTURE(pace);
+ PacePip p = pacePip(pace);
+ CHECK(p.stripIndex == kStripCenter);
+ CHECK(p.color.r == 255);
+ CHECK(p.color.g == 255);
+ CHECK(p.color.b == 255);
+ }
+}
+
+TEST_CASE("pace pip: slower goes LEFT in red, faster RIGHT in green") {
+ PacePip slow = pacePip(0.3f);
+ CHECK(slow.stripIndex < kStripCenter);
+ CHECK(isRed(slow.color));
+
+ PacePip fast = pacePip(-0.3f);
+ CHECK(fast.stripIndex > kStripCenter);
+ CHECK(isGreen(fast.color));
+}
+
+TEST_CASE("pace pip: full scale clamps to the end pixels") {
+ CHECK(pacePip(1.0f).stripIndex == 0);
+ CHECK(pacePip(5.0f).stripIndex == 0);
+ CHECK(pacePip(-1.0f).stripIndex == kStripCount - 1);
+ CHECK(pacePip(-99.0f).stripIndex == kStripCount - 1);
+}
+
+TEST_CASE("pace pip: monotonic steps, one pixel per 0.25 ms/m") {
+ // Just past the deadband: first pixel out.
+ CHECK(pacePip(0.13f).stripIndex == kStripCenter - 1);
+ CHECK(pacePip(0.25f).stripIndex == kStripCenter - 1);
+ CHECK(pacePip(0.26f).stripIndex == kStripCenter - 2);
+ CHECK(pacePip(0.50f).stripIndex == kStripCenter - 2);
+ CHECK(pacePip(0.75f).stripIndex == kStripCenter - 3);
+ CHECK(pacePip(0.99f).stripIndex == kStripCenter - 4);
+ // Mirrored on the fast side.
+ CHECK(pacePip(-0.26f).stripIndex == kStripCenter + 2);
+ int prev = pacePip(0.0f).stripIndex;
+ for (float pace = 0.05f; pace < 1.3f; pace += 0.05f) {
+ int idx = pacePip(pace).stripIndex;
+ CHECK(idx <= prev); // more positive pace never moves right
+ prev = idx;
+ }
+}
+
+TEST_CASE("renderPace: centerline dim white under an off-center pip") {
+ Rgb out[kStripCount];
+ led_modes::renderPace(0.6f, out);
+ // Centerline present, dimmer than the pip.
+ CHECK(out[kStripCenter].r == out[kStripCenter].g);
+ CHECK(out[kStripCenter].r == out[kStripCenter].b);
+ CHECK(out[kStripCenter].r > 0);
+ CHECK(out[kStripCenter].r < 255);
+ int pipIdx = pacePip(0.6f).stripIndex;
+ CHECK(isRed(out[pipIdx]));
+ for (int i = 0; i < kStripCount; i++) {
+ if (i != pipIdx && i != kStripCenter) {
+ CHECK(isOff(out[i]));
+ }
+ }
+}
+
+static const ScaleSpec kRpmSpec{0.0f, 10000.0f, led_modes::kRpmRedFrac,
+ led_frame::kGreen, led_frame::kRed};
+
+TEST_CASE("renderScale: fill count across the range") {
+ Rgb out[kStripCount];
+
+ led_modes::renderScale(0.0f, kRpmSpec, out);
+ for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i]));
+
+ led_modes::renderScale(10000.0f, kRpmSpec, out);
+ for (int i = 0; i < kStripCount; i++) CHECK(!isOff(out[i]));
+
+ // Clamped outside the range.
+ led_modes::renderScale(-500.0f, kRpmSpec, out);
+ for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i]));
+ led_modes::renderScale(99999.0f, kRpmSpec, out);
+ for (int i = 0; i < kStripCount; i++) CHECK(!isOff(out[i]));
+
+ // Half scale: round(0.5 * 9) = 5 pixels lit (fill is left-to-right,
+ // unlit tail stays off).
+ led_modes::renderScale(5000.0f, kRpmSpec, out);
+ for (int i = 0; i < 5; i++) CHECK(!isOff(out[i]));
+ for (int i = 5; i < kStripCount; i++) CHECK(isOff(out[i]));
+}
+
+TEST_CASE("renderScale: red past the halfway mark, green below") {
+ Rgb out[kStripCount];
+ led_modes::renderScale(10000.0f, kRpmSpec, out);
+ // redFrac 0.5 on 9 px: indices 0..4 green, 5..8 red.
+ for (int i = 0; i < 5; i++) CHECK(isGreen(out[i]));
+ for (int i = 5; i < kStripCount; i++) CHECK(isRed(out[i]));
+}
+
+TEST_CASE("renderScale: degenerate span never divides by zero") {
+ Rgb out[kStripCount];
+ ScaleSpec bad{100.0f, 100.0f, 0.5f, led_frame::kGreen, led_frame::kRed};
+ led_modes::renderScale(100.0f, bad, out);
+ for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i]));
+}
+
+static const StatusAction kRevAction{Source::kRpm, 10000.0f, 9700.0f,
+ led_frame::kRed,
+ led_modes::kRevFlashHalfPeriodMs,
+ led_frame::kOff};
+
+TEST_CASE("evalStatus: threshold fires, hysteresis holds, clear releases") {
+ StatusState st;
+ // Below threshold: off, not latched.
+ CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9000.0f, true, 0)));
+ CHECK(!st.active);
+ // At threshold: latches. nowMs=0 is the ON flash phase.
+ CHECK(isRed(led_modes::evalStatus(kRevAction, st, 10000.0f, true, 0)));
+ CHECK(st.active);
+ // Dips into the hysteresis band: still latched (flash phase ON).
+ CHECK(isRed(led_modes::evalStatus(kRevAction, st, 9800.0f, true, 0)));
+ CHECK(st.active);
+ // Below clearBelow: releases.
+ CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9600.0f, true, 0)));
+ CHECK(!st.active);
+}
+
+TEST_CASE("evalStatus: flash phase follows nowMs") {
+ StatusState st;
+ led_modes::evalStatus(kRevAction, st, 12000.0f, true, 0);
+ REQUIRE(st.active);
+ // Half-period 100 ms: 0..99 on, 100..199 off, 200..299 on...
+ CHECK(isRed(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 50)));
+ CHECK(isOff(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 150)));
+ CHECK(isRed(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 250)));
+ CHECK(st.active); // off-phase is flash, not release
+}
+
+TEST_CASE("evalStatus: invalid input forces off AND releases the latch") {
+ StatusState st;
+ led_modes::evalStatus(kRevAction, st, 12000.0f, true, 0);
+ REQUIRE(st.active);
+ CHECK(isOff(led_modes::evalStatus(kRevAction, st, 12000.0f, false, 0)));
+ CHECK(!st.active);
+ // Recovering validity below threshold stays off.
+ CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9000.0f, true, 0)));
+ CHECK(!st.active);
+}
+
+TEST_CASE("evalStatus: kNone source is always off") {
+ StatusState st;
+ StatusAction none{Source::kNone, 0.0f, 0.0f, led_frame::kRed, 100,
+ led_frame::kBlue}; // invalidColor must NOT leak through
+ CHECK(isOff(led_modes::evalStatus(none, st, 99999.0f, true, 0)));
+ CHECK(isOff(led_modes::evalStatus(none, st, 99999.0f, false, 0)));
+ CHECK(!st.active);
+}
+
+TEST_CASE("evalStatus: zero half-period means solid, not divide-by-zero") {
+ StatusState st;
+ StatusAction solid{Source::kRpm, 100.0f, 90.0f, led_frame::kOrange, 0,
+ led_frame::kOff};
+ Rgb c = led_modes::evalStatus(solid, st, 200.0f, true, 12345);
+ CHECK(c.r == led_frame::kOrange.r);
+ CHECK(c.g == led_frame::kOrange.g);
+ CHECK(c.b == led_frame::kOrange.b);
+}
+
+static bool isBlue(Rgb c) { return c.b > 0 && c.r == 0 && c.g == 0; }
+
+TEST_CASE("temp tri-state: blue when invalid, off when good, red flash hot") {
+ // The temp action as the glue builds it: threshold from the setting,
+ // clear 20 C below, blue as the no-signal color.
+ StatusAction temp{Source::kEgtC, 650.0f, 630.0f, led_frame::kRed,
+ led_modes::kEgtFlashHalfPeriodMs, led_frame::kBlue};
+ StatusState st;
+
+ // No probe signal: solid blue, latch released.
+ CHECK(isBlue(led_modes::evalStatus(temp, st, 0.0f, false, 0)));
+ CHECK(!st.active);
+
+ // Good reading below limit: off.
+ CHECK(isOff(led_modes::evalStatus(temp, st, 500.0f, true, 0)));
+
+ // Hot: red flash (nowMs=0 is the ON phase).
+ CHECK(isRed(led_modes::evalStatus(temp, st, 660.0f, true, 0)));
+ CHECK(st.active);
+
+ // Probe drops out mid-alert: blue AND the latch releases — a stale
+ // value must never keep the alert flashing.
+ CHECK(isBlue(led_modes::evalStatus(temp, st, 660.0f, false, 0)));
+ CHECK(!st.active);
+
+ // Signal returns in the hysteresis band (640): stays off — the latch
+ // was released, and 640 is below the 650 fire threshold.
+ CHECK(isOff(led_modes::evalStatus(temp, st, 640.0f, true, 0)));
+ CHECK(!st.active);
+}
+
+TEST_CASE("overrev-style action: wide hysteresis holds the latch down to clear") {
+ // Overrev fires at 8500 but clears at the NORMAL rev limit's clear
+ // point (7550 * 0.97 ~ 7323) — the whole band between must stay latched.
+ StatusAction over{Source::kRpm, 8500.0f, 7323.0f, led_frame::kRed,
+ led_modes::kRevFlashHalfPeriodMs, led_frame::kOff};
+ StatusState st;
+ CHECK(isOff(led_modes::evalStatus(over, st, 8000.0f, true, 0)));
+ CHECK(!st.active);
+ led_modes::evalStatus(over, st, 8600.0f, true, 0);
+ CHECK(st.active);
+ // Back under the trip point but above clear: still latched.
+ led_modes::evalStatus(over, st, 8000.0f, true, 0);
+ CHECK(st.active);
+ led_modes::evalStatus(over, st, 7400.0f, true, 0);
+ CHECK(st.active);
+ // Below clear: releases.
+ led_modes::evalStatus(over, st, 7300.0f, true, 0);
+ CHECK(!st.active);
+}
+
+TEST_CASE("inverted hysteresis (clearBelow above threshold) never strobes") {
+ // rev_limit and overrev_limit clamp independently, so a user can set
+ // overrev_limit at or below rev_limit * kRevClearFrac. neopixel.ino then
+ // builds the overrev action with clearBelow ABOVE threshold. Before the
+ // clamp in evalStatus, a value inside that inverted band set the latch on
+ // one frame and cleared it on the next — all 11 pixels strobing at the
+ // 30 Hz frame rate instead of flashing at 100 ms.
+ //
+ // Concretely: rev_limit 15000 (clear 14550), overrev_limit 12000.
+ StatusAction over{Source::kRpm, 12000.0f, 14550.0f, led_frame::kRed,
+ led_modes::kRevFlashHalfPeriodMs, led_frame::kOff};
+ StatusState st;
+
+ // At 13000 RPM: above the 12000 trip, below the bogus 14550 release.
+ led_modes::evalStatus(over, st, 13000.0f, true, 0);
+ CHECK(st.active);
+ // The latch must HOLD across successive frames, not alternate.
+ for (uint32_t f = 1; f < 20; f++) {
+ led_modes::evalStatus(over, st, 13000.0f, true, f * 33);
+ CAPTURE(f);
+ CHECK(st.active);
+ }
+ // It still releases below the (collapsed) trip point.
+ led_modes::evalStatus(over, st, 11999.0f, true, 0);
+ CHECK(!st.active);
+ // And re-arms cleanly.
+ led_modes::evalStatus(over, st, 12000.0f, true, 0);
+ CHECK(st.active);
+
+ // A sane configuration is untouched by the clamp: overrev above rev.
+ StatusAction sane{Source::kRpm, 16000.0f, 14550.0f, led_frame::kRed,
+ led_modes::kRevFlashHalfPeriodMs, led_frame::kOff};
+ StatusState st2;
+ led_modes::evalStatus(sane, st2, 16100.0f, true, 0);
+ CHECK(st2.active);
+ led_modes::evalStatus(sane, st2, 15000.0f, true, 0);
+ CHECK(st2.active); // wide band still latched
+ led_modes::evalStatus(sane, st2, 14000.0f, true, 0);
+ CHECK(!st2.active);
+}
+
+TEST_CASE("search pip: one green pixel, bounces to both ends, deterministic") {
+ Rgb out[kStripCount];
+ auto litIndex = [&](uint32_t t) {
+ led_modes::renderSearchPip(t, out);
+ int idx = -1;
+ int lit = 0;
+ for (int i = 0; i < kStripCount; i++) {
+ if (!isOff(out[i])) {
+ lit++;
+ idx = i;
+ CHECK(isGreen(out[i]));
+ }
+ }
+ CHECK(lit == 1); // exactly one pixel, always
+ return idx;
+ };
+
+ // Endpoints: start of the period at px 0, half-period at px 8.
+ CHECK(litIndex(0) == 0);
+ CHECK(litIndex(led_modes::kSearchBouncePeriodMs / 2) == kStripCount - 1);
+ // Periodic: one full period later, same position.
+ for (uint32_t t = 0; t < led_modes::kSearchBouncePeriodMs; t += 37) {
+ CHECK(litIndex(t) == litIndex(t + led_modes::kSearchBouncePeriodMs));
+ }
+ // Deterministic: same t, same frame.
+ CHECK(litIndex(12345) == litIndex(12345));
+ // Sweeps: every pixel is visited somewhere in one period.
+ bool seen[kStripCount] = {};
+ for (uint32_t t = 0; t < led_modes::kSearchBouncePeriodMs; t += 10) {
+ seen[litIndex(t)] = true;
+ }
+ for (int i = 0; i < kStripCount; i++) {
+ CAPTURE(i);
+ CHECK(seen[i]);
+ }
+ // Triangle symmetry: out and back visit mirrored positions.
+ uint32_t const q = led_modes::kSearchBouncePeriodMs / 4;
+ CHECK(litIndex(q) == litIndex(led_modes::kSearchBouncePeriodMs - q));
+}
+
+///////////////////////////////////////////
+// Plan 0013: the speed bar's no-red contract, flashOn(), and the new
+// Source tag.
+///////////////////////////////////////////
+
+TEST_CASE("kSpeedRedFrac lights no red pixel at any fill") {
+ // The RPM bar's red half means "approaching the limiter". Reaching a
+ // TARGET speed is the goal, not a hazard, so the speed bar is green
+ // all the way up — redFrom lands at kStripCount, which no lit index
+ // can reach. If someone lowers kSpeedRedFrac this fails loudly.
+ const led_modes::ScaleSpec spec = {0.0f, 60.0f, led_modes::kSpeedRedFrac,
+ led_frame::kGreen, led_frame::kRed};
+ for (float v = 0.0f; v <= 70.0f; v += 1.0f) {
+ led_frame::Rgb out[led_frame::kStripCount];
+ led_modes::renderScale(v, spec, out);
+ for (int i = 0; i < led_frame::kStripCount; i++) {
+ const bool lit = !(out[i].r == 0 && out[i].g == 0 && out[i].b == 0);
+ if (lit) {
+ CHECK(out[i].g == led_frame::kGreen.g);
+ CHECK(out[i].r == 0);
+ }
+ }
+ }
+ // ...and a full bar really is full, not one pixel short.
+ led_frame::Rgb full[led_frame::kStripCount];
+ led_modes::renderScale(60.0f, spec, full);
+ for (int i = 0; i < led_frame::kStripCount; i++) {
+ CHECK(full[i].g == led_frame::kGreen.g);
+ }
+}
+
+TEST_CASE("flashOn: zero half-period is solid, not a divide by zero") {
+ CHECK(led_modes::flashOn(0, 0));
+ CHECK(led_modes::flashOn(999999, 0));
+}
+
+TEST_CASE("flashOn: phase flips at every half-period boundary") {
+ CHECK(led_modes::flashOn(0, 100));
+ CHECK(led_modes::flashOn(99, 100));
+ CHECK_FALSE(led_modes::flashOn(100, 100));
+ CHECK_FALSE(led_modes::flashOn(199, 100));
+ CHECK(led_modes::flashOn(200, 100));
+ // A different rate is a different phase at the same instant — this is
+ // what lets two LEDs flashing for different reasons be told apart.
+ CHECK(led_modes::flashOn(400, 400) == false);
+ CHECK(led_modes::flashOn(400, 100) == true);
+}
+
+TEST_CASE("evalStatus treats a new Source tag like any other non-kNone") {
+ // Source is a self-description tag: only kNone is special-cased.
+ // Adding kSpeedMph must not change evaluation at all.
+ led_modes::StatusAction rpmA = {led_modes::Source::kRpm, 60.0f, 58.0f,
+ led_frame::kRed, 0, led_frame::kOff};
+ led_modes::StatusAction spdA = {led_modes::Source::kSpeedMph, 60.0f, 58.0f,
+ led_frame::kRed, 0, led_frame::kOff};
+ led_modes::StatusState s1, s2;
+ for (float v : {50.0f, 60.0f, 59.0f, 57.0f, 61.0f}) {
+ const led_frame::Rgb a = led_modes::evalStatus(rpmA, s1, v, true, 0);
+ const led_frame::Rgb b = led_modes::evalStatus(spdA, s2, v, true, 0);
+ CHECK(a.r == b.r);
+ CHECK(a.g == b.g);
+ CHECK(a.b == b.b);
+ CHECK(s1.active == s2.active);
+ }
+}
diff --git a/tests/led_status_test.cpp b/tests/led_status_test.cpp
new file mode 100644
index 0000000..b6db574
--- /dev/null
+++ b/tests/led_status_test.cpp
@@ -0,0 +1,358 @@
+#include
+#include
+
+#include "doctest.h"
+#include "led_status.h"
+
+using led_frame::Rgb;
+using led_status::CamLevel;
+using led_status::GpsLevel;
+using led_status::Inputs;
+using led_status::Mode;
+using led_status::State;
+using led_status::Verdict;
+
+static bool same(Rgb a, Rgb b) {
+ return a.r == b.r && a.g == b.g && a.b == b.b;
+}
+
+// A snapshot with nothing interesting happening: engine off, no fix, no
+// camera, no egg. Tests turn on only what they care about.
+static Inputs base() {
+ Inputs in;
+ in.targetRpm = 15000.0f;
+ in.targetSpeedMph = 60.0f;
+ in.egtAlertC = 650.0f;
+ return in;
+}
+
+///////////////////////////////////////////
+// Mode names: the wire format
+///////////////////////////////////////////
+
+TEST_CASE("every mode's name parses back to itself") {
+ for (int i = 0; i < (int)Mode::kCount; i++) {
+ const Mode m = (Mode)i;
+ Mode out = Mode::kCount;
+ REQUIRE(led_status::parseMode(led_status::modeName(m), &out));
+ CHECK(out == m);
+ }
+}
+
+TEST_CASE("parseMode is case-insensitive and exact") {
+ Mode m = Mode::kOff;
+ CHECK(led_status::parseMode("RPM", &m));
+ CHECK(m == Mode::kRpm);
+ CHECK(led_status::parseMode("Sector", &m));
+ CHECK(m == Mode::kSector);
+
+ // A prefix, a suffix, whitespace and junk must all be REFUSED rather
+ // than resolved to something. False means "keep the compiled-in
+ // default", so a typo leaves the LED doing what it did before instead
+ // of silently going dark or changing job.
+ for (const char* bad : {"", " ", "rp", "rpmm", "off ", " off", "laps",
+ "purple", "1", "OFFF"}) {
+ Mode before = Mode::kCamera;
+ m = before;
+ CHECK_FALSE(led_status::parseMode(bad, &m));
+ CHECK(m == before); // untouched on failure
+ }
+ CHECK_FALSE(led_status::parseMode(nullptr, &m));
+}
+
+TEST_CASE("modeName never returns null, even out of range") {
+ CHECK(led_status::modeName(Mode::kCount) != nullptr);
+ CHECK(strcmp(led_status::modeName(Mode::kCount), "off") == 0);
+ CHECK(strcmp(led_status::modeName((Mode)200), "off") == 0);
+}
+
+TEST_CASE("only gps and camera stay lit outside a session") {
+ CHECK(led_status::activeOutsideRace(Mode::kGps));
+ CHECK(led_status::activeOutsideRace(Mode::kCamera));
+ for (Mode m : {Mode::kOff, Mode::kRpm, Mode::kSpeed, Mode::kLap,
+ Mode::kSector, Mode::kEgt}) {
+ CHECK_FALSE(led_status::activeOutsideRace(m));
+ }
+}
+
+///////////////////////////////////////////
+// off
+///////////////////////////////////////////
+
+TEST_CASE("off mode is dark and holds no latch") {
+ State s;
+ Inputs in = base();
+ in.rpm = 20000.0f; // would be screaming in rpm mode
+ s.threshold.active = true;
+ CHECK(same(led_status::evalMode(Mode::kOff, in, s, 0), led_frame::kOff));
+ CHECK_FALSE(s.threshold.active);
+}
+
+///////////////////////////////////////////
+// Threshold modes
+///////////////////////////////////////////
+
+TEST_CASE("rpm mode reproduces the pre-0013 rev flasher") {
+ State s;
+ Inputs in = base();
+
+ in.rpm = 14000.0f;
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kOff));
+
+ in.rpm = 15000.0f; // at the target
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kRed));
+ CHECK(s.threshold.active);
+ // 100 ms half-period: off phase is a FLASH, not a release.
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 100), led_frame::kOff));
+ CHECK(s.threshold.active);
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 200), led_frame::kRed));
+
+ // Hysteresis: 97 % of 15000 = 14550. Just above holds.
+ in.rpm = 14600.0f;
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kRed));
+ in.rpm = 14000.0f;
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kOff));
+ CHECK_FALSE(s.threshold.active);
+}
+
+TEST_CASE("speed mode flashes at the target and goes dark without a fix") {
+ State s;
+ Inputs in = base();
+ in.speedValid = true;
+
+ in.speedMph = 55.0f;
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 0), led_frame::kOff));
+
+ in.speedMph = 60.0f;
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 0), led_frame::kRed));
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 250), led_frame::kOff));
+ CHECK(s.threshold.active); // still latched, just mid-flash
+
+ // Fixed 2 mph clear delta (GPS speed noise is absolute, not a
+ // percentage): 58.5 holds, 57.9 releases.
+ in.speedMph = 58.5f;
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 0), led_frame::kRed));
+ in.speedMph = 57.9f;
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 0), led_frame::kOff));
+
+ // No fix -> OFF, not a "no signal" colour. The strip's search pip
+ // already reports the missing lock; a second lit pixel saying the same
+ // thing is noise.
+ in.speedMph = 90.0f;
+ in.speedValid = false;
+ CHECK(same(led_status::evalMode(Mode::kSpeed, in, s, 0), led_frame::kOff));
+ CHECK_FALSE(s.threshold.active);
+}
+
+///////////////////////////////////////////
+// GPS
+///////////////////////////////////////////
+
+TEST_CASE("gps ladder classifies sats / fix / time lock") {
+ CHECK(led_status::gpsLevel(0, false, false) == GpsLevel::kNoSats);
+ CHECK(led_status::gpsLevel(4, false, false) == GpsLevel::kSats);
+ CHECK(led_status::gpsLevel(8, true, false) == GpsLevel::kFix);
+ CHECK(led_status::gpsLevel(8, true, true) == GpsLevel::kLocked);
+ // A fix with a stale sat count is still a fix — fix wins over sats.
+ CHECK(led_status::gpsLevel(0, true, true) == GpsLevel::kLocked);
+}
+
+TEST_CASE("gps mode is steady red / yellow / blue / green") {
+ State s;
+ Inputs in = base();
+
+ CHECK(same(led_status::evalMode(Mode::kGps, in, s, 0), led_frame::kRed));
+
+ in.gpsSats = 5;
+ CHECK(same(led_status::evalMode(Mode::kGps, in, s, 0), led_frame::kYellow));
+
+ in.gpsFix = true;
+ CHECK(same(led_status::evalMode(Mode::kGps, in, s, 0), led_frame::kBlue));
+
+ in.gpsTimeValid = true;
+ CHECK(same(led_status::evalMode(Mode::kGps, in, s, 0), led_frame::kGreen));
+
+ // Steady, not flashing: identical at every phase of every flash rate.
+ for (uint32_t t : {0u, 100u, 250u, 400u, 999u, 123456u}) {
+ CHECK(same(led_status::evalMode(Mode::kGps, in, s, t), led_frame::kGreen));
+ }
+}
+
+///////////////////////////////////////////
+// Camera
+///////////////////////////////////////////
+
+TEST_CASE("camera ladder: pairing, session, link, subscribe, recording") {
+ // Unpaired is unpaired no matter what else is true.
+ CHECK(led_status::cameraLevel(false, true, true, true, true) ==
+ CamLevel::kUnpaired);
+ // Paired but parked with no session: nothing is wrong yet.
+ CHECK(led_status::cameraLevel(true, false, false, false, false) ==
+ CamLevel::kUnpaired);
+ // Session running, camera not up: that IS worth flagging.
+ CHECK(led_status::cameraLevel(true, true, false, false, false) ==
+ CamLevel::kIdle);
+ CHECK(led_status::cameraLevel(true, true, true, false, false) ==
+ CamLevel::kLinkPartial);
+ CHECK(led_status::cameraLevel(true, true, true, true, false) ==
+ CamLevel::kLinkReady);
+ // Recording outranks the link ladder — if it is rolling, say so.
+ CHECK(led_status::cameraLevel(true, true, true, false, true) ==
+ CamLevel::kRecording);
+}
+
+TEST_CASE("camera mode: flashing blue means we cannot command it") {
+ State s;
+ Inputs in = base();
+
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 0), led_frame::kOff));
+
+ in.cameraPaired = true;
+ in.sessionActive = true;
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 0),
+ led_frame::kYellow));
+
+ // Linked but the camera never subscribed to ce82: our button frames go
+ // nowhere, so this FLASHES rather than sitting steady.
+ in.cameraLinkUp = true;
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 0), led_frame::kBlue));
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 400),
+ led_frame::kOff));
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 800),
+ led_frame::kBlue));
+
+ // Subscribed: steady blue — identical at both phases above, which is
+ // exactly how a driver tells the two states apart.
+ in.cameraSubscribed = true;
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 0), led_frame::kBlue));
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 400),
+ led_frame::kBlue));
+
+ in.cameraRecording = true;
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 0), led_frame::kRed));
+ CHECK(same(led_status::evalMode(Mode::kCamera, in, s, 400), led_frame::kRed));
+}
+
+///////////////////////////////////////////
+// EGT — including the bug this plan exists to kill
+///////////////////////////////////////////
+
+TEST_CASE("egt mode is the Temp1 tri-state on a SensorEgg build") {
+ State s;
+ Inputs in = base();
+ in.eggSupported = true;
+
+ // No probe signal -> solid blue. A dropout is information.
+ in.egtValid = false;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kBlue));
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 250), led_frame::kBlue));
+
+ in.egtValid = true;
+ in.egtC = 400.0f;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kOff));
+
+ in.egtC = 700.0f;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kRed));
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 250), led_frame::kOff));
+ CHECK(s.threshold.active);
+
+ // A dropout mid-alert releases the latch — never latch stale data.
+ in.egtValid = false;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kBlue));
+ CHECK_FALSE(s.threshold.active);
+}
+
+TEST_CASE("egt mode on a build WITHOUT SensorEgg support is OFF, never blue") {
+ // The regression test for the shipped bug: before plan 0013 the right
+ // status pixel was hardwired to Temp1, so on every master/release
+ // image sensoreggEgtC() answered a permanent NaN and the LED sat
+ // solid blue — "no probe signal" — for the whole of every race. There
+ // is no probe on such a build, so there is no signal to be missing.
+ State s;
+ Inputs in = base();
+ in.eggSupported = false;
+
+ in.egtValid = false;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kOff));
+
+ // Even handed a hot, VALID reading it stays dark — the gate is the
+ // build, not the NaN path, and this proves the two are independent.
+ in.egtValid = true;
+ in.egtC = 900.0f;
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 0), led_frame::kOff));
+ CHECK(same(led_status::evalMode(Mode::kEgt, in, s, 250), led_frame::kOff));
+ CHECK_FALSE(s.threshold.active);
+}
+
+///////////////////////////////////////////
+// Lap / sector verdict colours
+///////////////////////////////////////////
+
+TEST_CASE("lap and sector modes map verdicts and never flash") {
+ State s;
+ Inputs in = base();
+
+ struct { Verdict v; Rgb want; } cases[] = {
+ {Verdict::kNone, led_frame::kOff},
+ {Verdict::kBetter, led_frame::kGreen},
+ {Verdict::kWorse, led_frame::kRed},
+ {Verdict::kBest, led_frame::kPurple},
+ };
+ for (auto c : cases) {
+ in.lapVerdict = c.v;
+ in.sectorVerdict = c.v;
+ for (uint32_t t : {0u, 100u, 250u, 400u, 98765u}) {
+ CHECK(same(led_status::evalMode(Mode::kLap, in, s, t), c.want));
+ CHECK(same(led_status::evalMode(Mode::kSector, in, s, t), c.want));
+ }
+ }
+}
+
+TEST_CASE("lap and sector are independent of each other") {
+ State s;
+ Inputs in = base();
+ in.lapVerdict = Verdict::kWorse;
+ in.sectorVerdict = Verdict::kBetter;
+ CHECK(same(led_status::evalMode(Mode::kLap, in, s, 0), led_frame::kRed));
+ CHECK(same(led_status::evalMode(Mode::kSector, in, s, 0), led_frame::kGreen));
+}
+
+///////////////////////////////////////////
+// Latch lifetime
+///////////////////////////////////////////
+
+TEST_CASE("reset releases the threshold latch") {
+ State s;
+ Inputs in = base();
+ in.rpm = 16000.0f;
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kRed));
+ REQUIRE(s.threshold.active);
+
+ led_status::reset(s);
+ CHECK_FALSE(s.threshold.active);
+
+ // Back under the threshold after the reset: stays off rather than
+ // resuming a flash the previous session started.
+ in.rpm = 14000.0f;
+ CHECK(same(led_status::evalMode(Mode::kRpm, in, s, 0), led_frame::kOff));
+}
+
+TEST_CASE("switching a pixel to a non-threshold mode drops the latch") {
+ State s;
+ Inputs in = base();
+ in.rpm = 16000.0f;
+ led_status::evalMode(Mode::kRpm, in, s, 0);
+ REQUIRE(s.threshold.active);
+
+ in.gpsSats = 6;
+ led_status::evalMode(Mode::kGps, in, s, 0);
+ CHECK_FALSE(s.threshold.active);
+}
+
+TEST_CASE("kYellow is distinguishable from kOrange and kGreen") {
+ // The gps/camera ladders lean on yellow being its own colour on a
+ // WS2812, not "orange, but more so".
+ CHECK(led_frame::kYellow.g > led_frame::kOrange.g);
+ CHECK(led_frame::kYellow.r > led_frame::kYellow.g);
+ CHECK(led_frame::kYellow.b == 0);
+}
diff --git a/tests/local_time_test.cpp b/tests/local_time_test.cpp
new file mode 100644
index 0000000..d4a85c4
--- /dev/null
+++ b/tests/local_time_test.cpp
@@ -0,0 +1,225 @@
+#include "doctest.h"
+#include "local_time.h"
+
+#include
+
+using namespace local_time;
+
+namespace {
+
+DateTime dt(uint16_t y, uint8_t mo, uint8_t d, uint8_t h, uint8_t mi) {
+ return DateTime{y, mo, d, h, mi};
+}
+
+void checkEq(const DateTime& a, const DateTime& b) {
+ CHECK((int)a.year == (int)b.year);
+ CHECK((int)a.month == (int)b.month);
+ CHECK((int)a.day == (int)b.day);
+ CHECK((int)a.hour == (int)b.hour);
+ CHECK((int)a.minute == (int)b.minute);
+}
+
+} // namespace
+
+// ---------------------------------------------------------------------------
+// isValidOffsetMinutes
+// ---------------------------------------------------------------------------
+
+TEST_CASE("isValidOffsetMinutes - real-world extremes are inside the band") {
+ CHECK(isValidOffsetMinutes(0));
+ CHECK(isValidOffsetMinutes(-720)); // Baker Island, UTC-12
+ CHECK(isValidOffsetMinutes(840)); // Kiribati, UTC+14
+ CHECK(isValidOffsetMinutes(330)); // India, UTC+5:30
+ CHECK(isValidOffsetMinutes(-210)); // Newfoundland, UTC-3:30
+ CHECK(isValidOffsetMinutes(765)); // Chatham Islands, UTC+12:45
+}
+
+TEST_CASE("isValidOffsetMinutes - outside ±14h is rejected") {
+ CHECK_FALSE(isValidOffsetMinutes(841));
+ CHECK_FALSE(isValidOffsetMinutes(-841));
+ CHECK_FALSE(isValidOffsetMinutes(100000));
+ CHECK_FALSE(isValidOffsetMinutes(-100000));
+}
+
+// ---------------------------------------------------------------------------
+// daysInMonth
+// ---------------------------------------------------------------------------
+
+TEST_CASE("daysInMonth - ordinary months") {
+ CHECK((int)daysInMonth(2026, 1) == 31);
+ CHECK((int)daysInMonth(2026, 4) == 30);
+ CHECK((int)daysInMonth(2026, 12) == 31);
+}
+
+TEST_CASE("daysInMonth - February follows the Gregorian leap rule") {
+ CHECK((int)daysInMonth(2026, 2) == 28);
+ CHECK((int)daysInMonth(2024, 2) == 29); // divisible by 4
+ CHECK((int)daysInMonth(1900, 2) == 28); // century, not by 400
+ CHECK((int)daysInMonth(2000, 2) == 29); // divisible by 400
+}
+
+TEST_CASE("daysInMonth - out-of-range month returns 0, never indexes off the table") {
+ CHECK((int)daysInMonth(2026, 0) == 0);
+ CHECK((int)daysInMonth(2026, 13) == 0);
+ CHECK((int)daysInMonth(2026, 255) == 0);
+}
+
+// ---------------------------------------------------------------------------
+// applyOffset
+// ---------------------------------------------------------------------------
+
+TEST_CASE("applyOffset - zero offset is identity") {
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 30), 0), dt(2026, 8, 21, 14, 30));
+}
+
+TEST_CASE("applyOffset - whole-hour shift inside the same day") {
+ // 14:30 UTC in US Central (UTC-5) is 09:30 local.
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 30), -300), dt(2026, 8, 21, 9, 30));
+ // ...and in Tokyo (UTC+9) it is 23:30 the same day.
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 30), 540), dt(2026, 8, 21, 23, 30));
+}
+
+TEST_CASE("applyOffset - half-hour and quarter-hour zones") {
+ // India, UTC+5:30.
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 45), 330), dt(2026, 8, 21, 20, 15));
+ // Newfoundland, UTC-3:30.
+ checkEq(applyOffset(dt(2026, 8, 21, 2, 15), -210), dt(2026, 8, 20, 22, 45));
+ // Chatham Islands, UTC+12:45.
+ checkEq(applyOffset(dt(2026, 8, 21, 0, 0), 765), dt(2026, 8, 21, 12, 45));
+}
+
+TEST_CASE("applyOffset - negative offset rolls back to the previous day") {
+ // 02:00 UTC on the 21st is 21:00 on the 20th in UTC-5. This is the
+ // case the day/night gate actually cares about: an evening session
+ // is already "tomorrow" in UTC.
+ checkEq(applyOffset(dt(2026, 8, 21, 2, 0), -300), dt(2026, 8, 20, 21, 0));
+}
+
+TEST_CASE("applyOffset - positive offset rolls forward to the next day") {
+ checkEq(applyOffset(dt(2026, 8, 21, 23, 0), 120), dt(2026, 8, 22, 1, 0));
+}
+
+TEST_CASE("applyOffset - rollback crosses a month boundary") {
+ // 1st at 02:00 UTC, UTC-5 -> last day of the previous month.
+ checkEq(applyOffset(dt(2026, 8, 1, 2, 0), -300), dt(2026, 7, 31, 21, 0));
+ // Previous month with 30 days.
+ checkEq(applyOffset(dt(2026, 7, 1, 2, 0), -300), dt(2026, 6, 30, 21, 0));
+}
+
+TEST_CASE("applyOffset - rollforward crosses a month boundary") {
+ checkEq(applyOffset(dt(2026, 6, 30, 23, 0), 120), dt(2026, 7, 1, 1, 0));
+ checkEq(applyOffset(dt(2026, 7, 31, 23, 0), 120), dt(2026, 8, 1, 1, 0));
+}
+
+TEST_CASE("applyOffset - rollback lands on the leap day, and skips it in a common year") {
+ // 2024 IS a leap year: 1 Mar 02:00 UTC in UTC-5 is 29 Feb 21:00.
+ checkEq(applyOffset(dt(2024, 3, 1, 2, 0), -300), dt(2024, 2, 29, 21, 0));
+ // 2026 is not: the same instant is 28 Feb.
+ checkEq(applyOffset(dt(2026, 3, 1, 2, 0), -300), dt(2026, 2, 28, 21, 0));
+}
+
+TEST_CASE("applyOffset - rollforward over the leap day") {
+ checkEq(applyOffset(dt(2024, 2, 28, 23, 0), 120), dt(2024, 2, 29, 1, 0));
+ checkEq(applyOffset(dt(2026, 2, 28, 23, 0), 120), dt(2026, 3, 1, 1, 0));
+}
+
+TEST_CASE("applyOffset - rollback crosses a year boundary") {
+ checkEq(applyOffset(dt(2026, 1, 1, 2, 0), -300), dt(2025, 12, 31, 21, 0));
+}
+
+TEST_CASE("applyOffset - rollforward crosses a year boundary") {
+ checkEq(applyOffset(dt(2026, 12, 31, 23, 0), 120), dt(2027, 1, 1, 1, 0));
+}
+
+TEST_CASE("applyOffset - the extremes of the band still land on a sane date") {
+ checkEq(applyOffset(dt(2026, 8, 21, 0, 0), -720), dt(2026, 8, 20, 12, 0));
+ checkEq(applyOffset(dt(2026, 8, 21, 23, 59), 840), dt(2026, 8, 22, 13, 59));
+}
+
+TEST_CASE("applyOffset - an out-of-band offset is ignored, not applied") {
+ // A corrupt setting must never be able to move the calendar.
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 30), 1500), dt(2026, 8, 21, 14, 30));
+ checkEq(applyOffset(dt(2026, 8, 21, 14, 30), -1500), dt(2026, 8, 21, 14, 30));
+}
+
+TEST_CASE("applyOffset - year is 4-digit throughout, no 2000s assumption") {
+ checkEq(applyOffset(dt(1999, 12, 31, 23, 30), 60), dt(2000, 1, 1, 0, 30));
+ checkEq(applyOffset(dt(2100, 3, 1, 0, 30), -60), dt(2100, 2, 28, 23, 30));
+}
+
+// ---------------------------------------------------------------------------
+// minuteOfDay
+// ---------------------------------------------------------------------------
+
+TEST_CASE("minuteOfDay - endpoints and a midpoint") {
+ CHECK(minuteOfDay(dt(2026, 8, 21, 0, 0)) == 0u);
+ CHECK(minuteOfDay(dt(2026, 8, 21, 7, 0)) == 420u);
+ CHECK(minuteOfDay(dt(2026, 8, 21, 19, 0)) == 1140u);
+ CHECK(minuteOfDay(dt(2026, 8, 21, 23, 59)) == kMinutesPerDay - 1);
+}
+
+// ---------------------------------------------------------------------------
+// isNight — the window is [nightStart, dayStart) modulo the day
+// ---------------------------------------------------------------------------
+
+TEST_CASE("isNight - the ordinary wrapped window, night 19:00 -> day 07:00") {
+ const uint16_t day = 7 * 60, night = 19 * 60;
+ CHECK_FALSE(isNight(7 * 60, day, night)); // 07:00 exactly = day
+ CHECK_FALSE(isNight(7 * 60 + 1, day, night));
+ CHECK_FALSE(isNight(12 * 60, day, night));
+ CHECK_FALSE(isNight(18 * 60 + 59, day, night));
+ CHECK(isNight(19 * 60, day, night)); // 19:00 exactly = night
+ CHECK(isNight(23 * 60 + 59, day, night));
+ CHECK(isNight(0, day, night)); // midnight
+ CHECK(isNight(6 * 60 + 59, day, night));
+}
+
+TEST_CASE("isNight - boundaries are half-open, so no minute is both") {
+ const uint16_t day = 7 * 60, night = 19 * 60;
+ for (uint16_t m = 0; m < kMinutesPerDay; m++) {
+ const bool night_m = isNight(m, day, night);
+ const bool expect = (m >= night) || (m < day);
+ CHECK(night_m == expect);
+ }
+}
+
+TEST_CASE("isNight - equal bounds disable the swap entirely") {
+ for (uint16_t m = 0; m < kMinutesPerDay; m += 37) {
+ CHECK_FALSE(isNight(m, 7 * 60, 7 * 60));
+ CHECK_FALSE(isNight(m, 0, 0));
+ }
+}
+
+TEST_CASE("isNight - an inverted (non-wrapping) window still works") {
+ // Night 07:00 -> day 19:00: the window sits inside one calendar day.
+ const uint16_t day = 19 * 60, night = 7 * 60;
+ CHECK_FALSE(isNight(6 * 60, day, night));
+ CHECK(isNight(7 * 60, day, night));
+ CHECK(isNight(12 * 60, day, night));
+ CHECK(isNight(18 * 60 + 59, day, night));
+ CHECK_FALSE(isNight(19 * 60, day, night));
+ CHECK_FALSE(isNight(23 * 60, day, night));
+}
+
+// ---------------------------------------------------------------------------
+// End-to-end: the thing the LED gate actually asks
+// ---------------------------------------------------------------------------
+
+TEST_CASE("UTC 02:00 in UTC-5 is 21:00 local, which is night") {
+ // The whole point of the feature. Without the offset this instant
+ // reads as 02:00 and is night by luck; the case that breaks a naive
+ // UTC gate is the one below.
+ const DateTime lt = applyOffset(dt(2026, 8, 21, 2, 0), -300);
+ CHECK(isNight(minuteOfDay(lt), 7 * 60, 19 * 60));
+}
+
+TEST_CASE("UTC 12:30 in UTC-5 is 07:30 local, which is DAY — a UTC gate gets this wrong") {
+ const DateTime lt = applyOffset(dt(2026, 8, 21, 12, 30), -300);
+ CHECK((int)lt.hour == 7);
+ CHECK_FALSE(isNight(minuteOfDay(lt), 7 * 60, 19 * 60));
+}
+
+TEST_CASE("UTC 12:30 in UTC+9 is 21:30 local, which is night") {
+ const DateTime lt = applyOffset(dt(2026, 8, 21, 12, 30), 540);
+ CHECK(isNight(minuteOfDay(lt), 7 * 60, 19 * 60));
+}
diff --git a/tests/loop_profile_test.cpp b/tests/loop_profile_test.cpp
new file mode 100644
index 0000000..454bfae
--- /dev/null
+++ b/tests/loop_profile_test.cpp
@@ -0,0 +1,385 @@
+#include "doctest.h"
+
+#include
+#include
+
+#include "loop_profile.h"
+
+using namespace loop_profile;
+
+namespace {
+
+// The nRF52840's DWT cycle counter: 64 ticks per microsecond at a
+// 64 MHz core clock. This is a DURATION clock, not a wall clock — the
+// distinction is the point of half these tests.
+constexpr uint32_t kTicksPerUs = 64;
+constexpr uint32_t kWindowMs = 1000;
+// Ticks that elapse in one wall second IF the CPU never halts.
+constexpr uint32_t kWindow = 1000000u * kTicksPerUs;
+
+// Drive `loops` identical iterations: each spends `sectionTicks` in
+// `section` and `loopTicks` in the iteration as a whole.
+void feed(State& s, uint32_t loops, uint8_t section, uint32_t sectionTicks,
+ uint32_t loopTicks) {
+ for (uint32_t i = 0; i < loops; i++) {
+ addSection(s, section, sectionTicks);
+ addLoop(s, loopTicks);
+ }
+}
+
+// Sum of every reported slot. The unit's headline invariant: all
+// fourteen are shares of the same wall-clock window.
+uint32_t slotSum(const Report& r) {
+ uint32_t total = 0;
+ for (uint8_t i = 0; i < kReportSlots; i++) total += r.permille[i];
+ return total;
+}
+
+} // namespace
+
+// ---------------------------------------------------------------------------
+// permilleOf
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - permilleOf is tenths of a percent") {
+ CHECK(permilleOf(1, 2) == 500);
+ CHECK(permilleOf(412, 1000) == 412);
+ CHECK(permilleOf(0, 1000) == 0);
+}
+
+TEST_CASE("loop_profile - permilleOf survives a zero window and saturates") {
+ CHECK(permilleOf(100, 0) == 0); // no divide by zero
+ CHECK(permilleOf(2000, 1000) == 1000); // never reports >100%
+}
+
+TEST_CASE("loop_profile - permilleOf does not overflow on full-scale ticks") {
+ // part * 1000 overflows uint32 above ~4.3e6 ticks (67 ms at 64 MHz),
+ // which every real window exceeds. 64-bit intermediate required.
+ CHECK(permilleOf(kWindow / 2, kWindow) == 500);
+ CHECK(permilleOf(UINT32_MAX, UINT32_MAX) == 1000);
+}
+
+// ---------------------------------------------------------------------------
+// Accumulation
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - addSection accumulates total, max and calls") {
+ State s;
+ reset(s, 0, 0);
+ addSection(s, kGps, 100);
+ addSection(s, kGps, 900);
+ addSection(s, kGps, 50);
+ CHECK(s.sec[kGps].totalTicks == 1050);
+ CHECK(s.sec[kGps].maxTicks == 900);
+ CHECK(s.sec[kGps].calls == 3);
+ CHECK(s.sec[kDisplay].calls == 0);
+}
+
+TEST_CASE("loop_profile - addSection ignores an out-of-range section") {
+ State s;
+ reset(s, 0, 0);
+ addSection(s, kSectionCount, 1000); // kOther is a report slot only
+ addSection(s, 200, 1000);
+ for (uint8_t i = 0; i < kSectionCount; i++) {
+ CHECK(s.sec[i].totalTicks == 0);
+ }
+}
+
+TEST_CASE("loop_profile - accumulators saturate instead of wrapping") {
+ State s;
+ reset(s, 0, 0);
+ addSection(s, kGps, UINT32_MAX - 10);
+ addSection(s, kGps, 1000);
+ CHECK(s.sec[kGps].totalTicks == UINT32_MAX);
+ addLoop(s, UINT32_MAX);
+ addLoop(s, 5);
+ CHECK(s.loopTotalTicks == UINT32_MAX);
+}
+
+// ---------------------------------------------------------------------------
+// rollup — windowing
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - rollup withholds an incomplete window") {
+ State s;
+ Report r;
+ r.valid = false;
+ reset(s, 1000, 0);
+ feed(s, 10, kGps, 100, 200);
+ CHECK(rollup(s, 1000 + kWindow - 1, kWindowMs - 1, kWindowMs, kTicksPerUs, r) == false);
+ CHECK(r.valid == false); // untouched
+ CHECK(s.sec[kGps].calls == 10); // accumulators kept accruing
+}
+
+TEST_CASE("loop_profile - rollup restarts the window and clears state") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ feed(s, 250, kGps, 1000, 4000);
+ CHECK(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(s.windowStartTicks == kWindow);
+ CHECK(s.windowStartMs == kWindowMs);
+ CHECK(s.loops == 0);
+ CHECK(s.sec[kGps].totalTicks == 0);
+ CHECK(s.sec[kGps].maxTicks == 0);
+}
+
+TEST_CASE("loop_profile - rollup is wrap-safe across the tick counter") {
+ // DWT->CYCCNT wraps every ~67 s at 64 MHz, mid-session every time.
+ State s;
+ Report r;
+ const uint32_t start = UINT32_MAX - (kWindow / 2);
+ reset(s, start, 0);
+ feed(s, 100, kGps, kWindow / 1000, kWindow / 500);
+ const uint32_t now = start + kWindow; // wraps
+ CHECK(now < start);
+ CHECK(rollup(s, now, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.windowUs == 1000000u);
+ CHECK(r.loops == 100);
+}
+
+TEST_CASE("loop_profile - rollup is wrap-safe across the millis() clock") {
+ // millis() wraps every ~49 days. Rare, but a wrapped window would
+ // either never close or close instantly forever.
+ State s;
+ Report r;
+ const uint32_t startMs = UINT32_MAX - 400u;
+ reset(s, 0, startMs);
+ feed(s, 250, kGps, 1600 * kTicksPerUs, 4000 * kTicksPerUs);
+ const uint32_t nowMs = startMs + kWindowMs; // wraps
+ CHECK(nowMs < startMs);
+ REQUIRE(rollup(s, kWindow, nowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.windowUs == 1000000u);
+ CHECK(r.loopRateHz == 250);
+}
+
+// ---------------------------------------------------------------------------
+// The two clocks — the bug this unit was rebuilt around
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - a stopped duration clock cannot stretch the window") {
+ // THE REGRESSION TEST. The DWT cycle counter stops when the core
+ // halts, so if it were the thing closing the window, a CPU that is
+ // awake 25% of the time would take FOUR wall seconds to accumulate
+ // "one second" of ticks — and 1000 iterations over those 4 s would
+ // be reported as 1000 Hz instead of 250 Hz. The window must close on
+ // wall time regardless of what the tick counter did.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ for (int i = 0; i < 1000; i++) {
+ addSection(s, kGps, 100 * kTicksPerUs);
+ addLoop(s, 250 * kTicksPerUs); // 250 us of execution per iteration
+ }
+ // One wall second elapsed; only 250 ms of it was spent awake.
+ const uint32_t awakeTicks = 250000u * kTicksPerUs;
+ REQUIRE(rollup(s, awakeTicks, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+
+ CHECK(r.windowUs == 1000000u); // wall, not ticks
+ CHECK(r.awakeUs == 250000u); // and the discrepancy is reported
+ CHECK(r.loopRateHz == 1000); // 1000 iterations per WALL second
+ CHECK(r.loopMeanUs == 250); // execution time per iteration
+ CHECK(r.busyPermille == 250); // the CPU ran loop() 25% of the time
+ CHECK(r.permille[kSleep] == 750); // ...and did something else for 75%
+ CHECK(r.permille[kGps] == 100); // 100 ms of 1000 ms, not of 250 ms
+}
+
+TEST_CASE("loop_profile - awakeUs equals the window when nothing sleeps") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ feed(s, 250, kGps, 1600 * kTicksPerUs, 4000 * kTicksPerUs);
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.awakeUs == r.windowUs);
+ CHECK(r.busyPermille == 1000);
+ CHECK(r.permille[kSleep] == 0);
+}
+
+TEST_CASE("loop_profile - every reported slot sums to the whole window") {
+ // The invariant a reader can check on the display page at a glance.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ for (int i = 0; i < 500; i++) {
+ addSection(s, kGps, 200 * kTicksPerUs);
+ addSection(s, kDisplay, 150 * kTicksPerUs);
+ addSection(s, kButtons, 80 * kTicksPerUs);
+ addLoop(s, 600 * kTicksPerUs); // 170 us of unbracketed glue
+ }
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.permille[kGps] == 100);
+ CHECK(r.permille[kDisplay] == 75);
+ CHECK(r.permille[kButtons] == 40);
+ CHECK(r.permille[kOther] == 85); // 170 us x 500 = 85 ms
+ CHECK(r.permille[kSleep] == 700); // busy 300, so 700 elsewhere
+ CHECK(slotSum(r) == 1000);
+}
+
+TEST_CASE("loop_profile - busy over 100% cannot make kSleep negative") {
+ // Preemption and separate clock reads can push the loop total a hair
+ // past the window. kSleep is unsigned; it must clamp, not wrap.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ addLoop(s, kWindow + (1000u * kTicksPerUs));
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.busyPermille == 1000);
+ CHECK(r.permille[kSleep] == 0);
+}
+
+// ---------------------------------------------------------------------------
+// rollup — the reported numbers
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - shares are of window wall time") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ // 250 iterations of 4 ms; 40% of each iteration in GPS, 10% in LED.
+ for (int i = 0; i < 250; i++) {
+ addSection(s, kGps, 1600 * kTicksPerUs);
+ addSection(s, kLed, 400 * kTicksPerUs);
+ addLoop(s, 4000 * kTicksPerUs);
+ }
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.permille[kGps] == 400);
+ CHECK(r.permille[kLed] == 100);
+ CHECK(r.permille[kTach] == 0);
+ CHECK(r.loops == 250);
+ CHECK(r.loopRateHz == 250);
+ CHECK(r.loopMeanUs == 4000);
+ CHECK(r.loopMaxUs == 4000);
+ CHECK(r.windowUs == 1000000u);
+ CHECK(r.saturated == false);
+}
+
+TEST_CASE("loop_profile - unbracketed loop time lands in kOther") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ // Half of each iteration is inside GPS; the rest is loop glue.
+ for (int i = 0; i < 100; i++) {
+ addSection(s, kGps, 5000 * kTicksPerUs);
+ addLoop(s, 10000 * kTicksPerUs);
+ }
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.permille[kGps] == 500);
+ CHECK(r.permille[kOther] == 500);
+ CHECK(r.permille[kSleep] == 0);
+}
+
+TEST_CASE("loop_profile - kOther cannot go negative when spans disagree") {
+ // The whole-iteration span and the section spans are separate reads
+ // and can disagree by a tick or two; that must not underflow.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ addSection(s, kGps, 1000 * kTicksPerUs);
+ addSection(s, kLed, 1000 * kTicksPerUs);
+ addLoop(s, 1990 * kTicksPerUs); // less than the sum of its parts
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.permille[kOther] == 0);
+}
+
+TEST_CASE("loop_profile - time outside loop() is reported, not hidden") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ // Only a quarter of the window is spent inside loop() at all.
+ for (int i = 0; i < 250; i++) {
+ addSection(s, kGps, 500 * kTicksPerUs);
+ addLoop(s, 1000 * kTicksPerUs);
+ }
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.permille[kGps] == 125);
+ CHECK(r.permille[kOther] == 125);
+ CHECK(r.permille[kSleep] == 750);
+}
+
+TEST_CASE("loop_profile - per-section max survives as microseconds") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ // One SD garbage-collection stall inside the GPS section.
+ addSection(s, kGps, 300 * kTicksPerUs);
+ addSection(s, kGps, 1800000u * kTicksPerUs); // 1.8 s
+ addLoop(s, 1800400u * kTicksPerUs);
+ REQUIRE(rollup(s, 2u * kWindow, 2u * kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.maxUs[kGps] == 1800000u);
+ CHECK(r.loopMaxUs == 1800400u);
+ CHECK(r.windowUs == 2000000u);
+ CHECK(r.loopRateHz == 1); // 1 iteration in a 2 s window rounds to 1
+}
+
+TEST_CASE("loop_profile - a pegged accumulator is flagged, not silently wrong") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ addSection(s, kGps, UINT32_MAX);
+ addSection(s, kGps, 1);
+ addLoop(s, UINT32_MAX);
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.saturated == true);
+ CHECK(r.permille[kGps] == 1000);
+}
+
+TEST_CASE("loop_profile - an empty window reports zeros, not garbage") {
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ REQUIRE(rollup(s, kWindow, kWindowMs, kWindowMs, kTicksPerUs, r) == true);
+ CHECK(r.valid == true);
+ CHECK(r.loops == 0);
+ CHECK(r.loopRateHz == 0);
+ CHECK(r.loopMeanUs == 0);
+ CHECK(r.permille[kGps] == 0);
+ CHECK(r.permille[kSleep] == 1000);
+}
+
+TEST_CASE("loop_profile - a micros() timebase reports the same ratios") {
+ // ticksPerUs 1 is the fallback when DWT will not run. Shares must be
+ // identical; only the resolution of the microsecond fields changes.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ for (int i = 0; i < 250; i++) {
+ addSection(s, kGps, 1600);
+ addLoop(s, 4000);
+ }
+ REQUIRE(rollup(s, 1000000u, kWindowMs, kWindowMs, 1, r) == true);
+ CHECK(r.permille[kGps] == 400);
+ CHECK(r.loopMeanUs == 4000);
+ CHECK(r.loopRateHz == 250);
+}
+
+TEST_CASE("loop_profile - ticksPerUs 0 still yields ratios") {
+ // A mis-probed timebase is treated as 1 tick/us rather than dividing
+ // by zero, so the ticks are read as microseconds and the shares stay
+ // meaningful even though the resolution is gone.
+ State s;
+ Report r;
+ reset(s, 0, 0);
+ addSection(s, kGps, 500000);
+ addLoop(s, 1000000);
+ REQUIRE(rollup(s, 1000000, kWindowMs, kWindowMs, 0, r) == true);
+ CHECK(r.permille[kGps] == 500);
+ CHECK(r.busyPermille == 1000);
+}
+
+// ---------------------------------------------------------------------------
+// Tags
+// ---------------------------------------------------------------------------
+
+TEST_CASE("loop_profile - every section has a distinct three-char tag") {
+ for (uint8_t i = 0; i < kSectionCount; i++) {
+ const char* tag = sectionTag(i);
+ CHECK(std::strlen(tag) == 3);
+ CHECK(std::strcmp(tag, "???") != 0);
+ for (uint8_t j = 0; j < i; j++) {
+ CHECK(std::strcmp(tag, sectionTag(j)) != 0);
+ }
+ }
+ CHECK(std::strcmp(sectionTag(kOther), "OTH") == 0);
+ CHECK(std::strcmp(sectionTag(kSleep), "SLP") == 0);
+ CHECK(std::strcmp(sectionTag(200), "???") == 0);
+}
diff --git a/tests/sector_purple_test.cpp b/tests/sector_purple_test.cpp
new file mode 100644
index 0000000..31a00dc
--- /dev/null
+++ b/tests/sector_purple_test.cpp
@@ -0,0 +1,464 @@
+#include "doctest.h"
+#include "sector_purple.h"
+
+using sector_purple::Sample;
+using sector_purple::State;
+using sector_purple::Verdict;
+
+// update() returns an Event since plan 0013. The purple-sector contract
+// these cases pin is unchanged, so they read it through this shim
+// rather than being rewritten around the new struct.
+static int purpleOf(State& st, const Sample& in) {
+ return sector_purple::update(st, in).purpleSector;
+}
+
+// A live circuit race sample with everything zeroed; tests mutate what
+// they need.
+static Sample base() {
+ Sample s{};
+ s.sectorsConfigured = true;
+ s.raceStarted = true;
+ s.currentSector = 1;
+ s.laps = 0;
+ s.lastLapTime = 0;
+ s.bestLapTime = 0;
+ for (int i = 0; i < 3; i++) {
+ s.lapSectorTime[i] = 0;
+ s.bestSectorTime[i] = 0;
+ }
+ return s;
+}
+
+TEST_CASE("no sectors configured: no sector events, but laps still track") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ in.sectorsConfigured = false;
+ in.bestSectorTime[0] = 30000;
+ in.lapSectorTime[0] = 1; // absurdly fast — still must not fire
+ for (int i = 0; i < 5; i++) {
+ in.currentSector = (i % 3) + 1;
+ sector_purple::Event ev = sector_purple::update(st, in);
+ CHECK(ev.purpleSector == 0);
+ CHECK(ev.closedSector == 0);
+ }
+
+ // Lap Anything has no sector lines but absolutely has laps, and the
+ // `lap` status mode has to work there. Before plan 0013 the whole
+ // monitor reset every frame on this input.
+ in.currentSector = 0;
+ in.laps = 1;
+ in.lastLapTime = 78000;
+ in.bestLapTime = 78000; // the library folds it in AT the crossing
+ sector_purple::Event l1 = sector_purple::update(st, in);
+ CHECK(l1.lapClosed);
+ CHECK(l1.lapVerdict == Verdict::kNone); // nothing to compare against
+
+ in.laps = 2;
+ in.lastLapTime = 77000;
+ sector_purple::Event l2 = sector_purple::update(st, in);
+ CHECK(l2.lapClosed);
+ CHECK(l2.lapVerdict == Verdict::kBest);
+ CHECK(l2.purpleLap);
+ CHECK(l2.closedSector == 0); // still no sectors
+}
+
+TEST_CASE("lap 1: no purple anywhere (no prior bests)") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+
+ CHECK(purpleOf(st, in) == 0); // adopt S1 open
+
+ // S1 closes fast — but bestAtOpen was 0.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 25000;
+ CHECK(purpleOf(st, in) == 0);
+
+ in.currentSector = 3;
+ in.lapSectorTime[1] = 26000;
+ CHECK(purpleOf(st, in) == 0);
+
+ // Lap line: S3 derived, but bestAtOpen[2] was 0.
+ in.currentSector = 1;
+ in.laps = 1;
+ in.lastLapTime = 78000;
+ in.bestSectorTime[0] = 25000;
+ in.bestSectorTime[1] = 26000;
+ in.bestSectorTime[2] = 27000;
+ CHECK(purpleOf(st, in) == 0);
+}
+
+// Drive one full lap: closes S1/S2 at the given times, then the lap
+// line with the given total. Returns the OR of fired values per edge in
+// a small struct for assertions.
+struct LapResult {
+ int s1Fired, s2Fired, s3Fired;
+};
+static LapResult runLap(State& st, Sample& in, uint32_t s1, uint32_t s2,
+ uint32_t lapTotal, int lapNum) {
+ LapResult r{0, 0, 0};
+ in.currentSector = 2;
+ in.lapSectorTime[0] = s1;
+ r.s1Fired = purpleOf(st, in);
+ in.currentSector = 3;
+ in.lapSectorTime[1] = s2;
+ r.s2Fired = purpleOf(st, in);
+ // Lap line. The library may have already updated bests — simulate the
+ // worst case by folding this lap's times in BEFORE the edge is seen.
+ in.currentSector = 1;
+ in.laps = lapNum;
+ in.lastLapTime = lapTotal;
+ uint32_t s3 = lapTotal - s1 - s2;
+ if (in.bestSectorTime[0] == 0 || s1 < in.bestSectorTime[0])
+ in.bestSectorTime[0] = s1;
+ if (in.bestSectorTime[1] == 0 || s2 < in.bestSectorTime[1])
+ in.bestSectorTime[1] = s2;
+ if (in.bestSectorTime[2] == 0 || s3 < in.bestSectorTime[2])
+ in.bestSectorTime[2] = s3;
+ // Same worst case one level up: the library folds the finished lap
+ // into getBestLapTime() at the crossing, BEFORE our poll sees the edge.
+ if (in.bestLapTime == 0 || lapTotal < in.bestLapTime)
+ in.bestLapTime = lapTotal;
+ // Current-lap times roll over to the new lap (0 = not yet completed).
+ in.lapSectorTime[0] = 0;
+ in.lapSectorTime[1] = 0;
+ in.lapSectorTime[2] = 0;
+ r.s3Fired = purpleOf(st, in);
+ return r;
+}
+
+TEST_CASE("S1/S2 improvements fire once at the transition; equal does not") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ purpleOf(st, in);
+
+ // Lap 1 establishes bests: 25/26/27 (total 78).
+ LapResult l1 = runLap(st, in, 25000, 26000, 78000, 1);
+ CHECK(l1.s1Fired == 0);
+ CHECK(l1.s2Fired == 0);
+ CHECK(l1.s3Fired == 0);
+
+ // Lap 2: faster S1, equal S2, slower S3.
+ LapResult l2 = runLap(st, in, 24000, 26000, 79000, 2);
+ CHECK(l2.s1Fired == 1);
+ CHECK(l2.s2Fired == 0); // equal time is not an improvement
+ CHECK(l2.s3Fired == 0);
+
+ // Lap 3: S2 improvement fires as 2.
+ LapResult l3 = runLap(st, in, 24500, 25000, 80000, 3);
+ CHECK(l3.s1Fired == 0);
+ CHECK(l3.s2Fired == 2);
+ CHECK(l3.s3Fired == 0);
+}
+
+TEST_CASE("S3 purple survives the library updating bests at the lap line") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ purpleOf(st, in);
+
+ // Lap 1: bests 25/26/27.
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ // Lap 2: S3 = 80000 - 25500 - 26500 = 28000 (slower) — no fire.
+ LapResult l2 = runLap(st, in, 25500, 26500, 80000, 2);
+ CHECK(l2.s3Fired == 0);
+
+ // Lap 3: S3 = 76000 - 25000 - 26000 = 25000, beats the 27000 best.
+ // runLap folds the new best into bestSectorTime[2] BEFORE the edge —
+ // the open-snapshot must still see the old 27000 and fire.
+ LapResult l3 = runLap(st, in, 25000, 26000, 76000, 3);
+ CHECK(l3.s3Fired == 3);
+
+ // Lap 4: same S3 again (25000) — best is now 25000, no fire.
+ LapResult l4 = runLap(st, in, 25000, 26000, 76000, 4);
+ CHECK(l4.s3Fired == 0);
+}
+
+TEST_CASE("degenerate lap (missing S1/S2 closes) fires nothing at the line") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ purpleOf(st, in);
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ // Lap 2 jumps straight from S1 to the lap line (missed transitions —
+ // e.g. GPS dropout): no s2 recorded, so S3 cannot be derived.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24000;
+ CHECK(purpleOf(st, in) == 1); // S1 close still fires
+ in.currentSector = 1;
+ in.laps = 2;
+ in.lastLapTime = 70000; // would be a monster S3 if mis-derived
+ CHECK(purpleOf(st, in) == 0);
+}
+
+TEST_CASE("lastLapTime smaller than s1+s2 cannot underflow") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ purpleOf(st, in);
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 26000;
+ purpleOf(st, in);
+ in.currentSector = 3;
+ in.lapSectorTime[1] = 27000;
+ purpleOf(st, in);
+ in.currentSector = 1;
+ in.laps = 2;
+ in.lastLapTime = 40000; // < s1+s2 (inconsistent library state)
+ CHECK(purpleOf(st, in) == 0);
+}
+
+TEST_CASE("sprint between-runs (sector 0) resets cleanly") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ purpleOf(st, in);
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ // Run ends: currentSector drops to 0 between sprint runs.
+ in.currentSector = 0;
+ CHECK(purpleOf(st, in) == 0);
+
+ // New run opens S1; a fast S1 against the lap-1 best still fires.
+ in.currentSector = 1;
+ CHECK(purpleOf(st, in) == 0);
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24000;
+ CHECK(purpleOf(st, in) == 1);
+}
+
+TEST_CASE("race not started resets; mid-race attach adopts without firing") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ in.raceStarted = false;
+ CHECK(purpleOf(st, in) == 0);
+
+ // Attach mid-lap in sector 2 with bests already on the board.
+ in.raceStarted = true;
+ in.currentSector = 2;
+ in.laps = 3;
+ in.bestSectorTime[0] = 25000;
+ in.bestSectorTime[1] = 26000;
+ in.bestSectorTime[2] = 27000;
+ CHECK(purpleOf(st, in) == 0); // adopt, no fire
+
+ // S2 closes faster than best: fires (snapshot taken at attach).
+ in.currentSector = 3;
+ in.lapSectorTime[1] = 25500;
+ CHECK(purpleOf(st, in) == 2);
+}
+
+///////////////////////////////////////////
+// Plan 0013: lap + sector VERDICTS (better/worse vs the LAST recorded
+// one, not vs the best), the purple LAP, and the hold semantics the
+// status LEDs depend on.
+///////////////////////////////////////////
+
+TEST_CASE("lap verdict: none on lap 1 — nothing to compare, nothing to beat") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 25000;
+ sector_purple::update(st, in);
+ in.currentSector = 3;
+ in.lapSectorTime[1] = 26000;
+ sector_purple::update(st, in);
+
+ in.currentSector = 1;
+ in.laps = 1;
+ in.lastLapTime = 78000;
+ in.bestLapTime = 78000; // the library folds it in at the crossing
+ in.lapSectorTime[0] = 0;
+ in.lapSectorTime[1] = 0;
+ sector_purple::Event l1 = sector_purple::update(st, in);
+ CHECK(l1.lapClosed);
+ CHECK(l1.lapVerdict == Verdict::kNone);
+ CHECK(l1.purpleLap == false); // being the only lap is not an achievement
+}
+
+TEST_CASE("purple lap survives the library updating bestLapTime at the line") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+
+ runLap(st, in, 25000, 26000, 78000, 1); // best lap = 78000
+
+ // Lap 2 slower: worse, not purple.
+ in.currentSector = 2; in.lapSectorTime[0] = 26000;
+ sector_purple::update(st, in);
+ in.currentSector = 3; in.lapSectorTime[1] = 27000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 2; in.lastLapTime = 80000;
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::Event l2 = sector_purple::update(st, in);
+ CHECK(l2.lapClosed);
+ CHECK(l2.lapVerdict == Verdict::kWorse);
+ CHECK(l2.purpleLap == false);
+
+ // Lap 3 beats 78000. runLap's fold puts the new best into
+ // bestLapTime BEFORE the edge is seen — the open snapshot must still
+ // see the old 78000 and fire.
+ in.currentSector = 2; in.lapSectorTime[0] = 24000;
+ sector_purple::update(st, in);
+ in.currentSector = 3; in.lapSectorTime[1] = 25000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 3; in.lastLapTime = 76000;
+ in.bestLapTime = 76000; // library already folded it in
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::Event l3 = sector_purple::update(st, in);
+ CHECK(l3.lapVerdict == Verdict::kBest);
+ CHECK(l3.purpleLap);
+
+ // Lap 4 identical to lap 3: faster than nothing, equal to the best.
+ // Strict improvement — no purple, and equal is not "better".
+ in.currentSector = 2; in.lapSectorTime[0] = 24000;
+ sector_purple::update(st, in);
+ in.currentSector = 3; in.lapSectorTime[1] = 25000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 4; in.lastLapTime = 76000;
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::Event l4 = sector_purple::update(st, in);
+ CHECK(l4.lapVerdict == Verdict::kWorse);
+ CHECK(l4.purpleLap == false);
+}
+
+TEST_CASE("sector verdict compares the SAME sector last lap, not the best") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+
+ // Lap 1: S1 = 25000 (becomes both the last and the best).
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ // Lap 2: S1 = 24000 — beats the 25000 best, so kBest.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24000;
+ sector_purple::Event a = sector_purple::update(st, in);
+ CHECK(a.closedSector == 1);
+ CHECK(a.sectorVerdict == Verdict::kBest);
+ CHECK(a.purpleSector == 1);
+
+ // Re-polling an unchanged sample must not re-fire the edge — the LED
+ // holds the verdict, the monitor does not repeat it.
+ sector_purple::Event again = sector_purple::update(st, in);
+ CHECK(again.closedSector == 0);
+ CHECK(again.purpleSector == 0);
+
+ // Finish lap 2, folding 24000 into the S1 best.
+ in.currentSector = 3; in.lapSectorTime[1] = 26000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 2; in.lastLapTime = 78000;
+ in.bestSectorTime[0] = 24000;
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::update(st, in);
+
+ // Lap 3: S1 = 24500. SLOWER than last lap's 24000 -> kWorse. This is
+ // the whole point of the verdict: against the best it would also be
+ // "not purple", but against the LAST lap it is specifically "you lost
+ // half a second here".
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24500;
+ sector_purple::Event b = sector_purple::update(st, in);
+ CHECK(b.closedSector == 1);
+ CHECK(b.sectorVerdict == Verdict::kWorse);
+ CHECK(b.purpleSector == 0);
+
+ // Finish lap 3 (S1 best stays 24000, last recorded S1 becomes 24500).
+ in.currentSector = 3; in.lapSectorTime[1] = 26000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 3; in.lastLapTime = 79000;
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::update(st, in);
+
+ // Lap 4: S1 = 24200. Still off the 24000 best, but BETTER than last
+ // lap's 24500 -> kBetter (green), which best-based logic could never
+ // report.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24200;
+ sector_purple::Event c = sector_purple::update(st, in);
+ CHECK(c.closedSector == 1);
+ CHECK(c.sectorVerdict == Verdict::kBetter);
+ CHECK(c.purpleSector == 0);
+}
+
+TEST_CASE("first pass through a sector reports kNone, not a guess") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 25000;
+ sector_purple::Event e = sector_purple::update(st, in);
+ CHECK(e.closedSector == 1);
+ CHECK(e.sectorVerdict == Verdict::kNone); // no prior S1, no prior best
+}
+
+TEST_CASE("a sector that never closed emits no event and poisons nothing") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ // Lap 2: the S1->S2 transition arrives with a zero current-lap S1
+ // (dropout across the line). No event, and the recorded S1 must stay
+ // at lap 1's 25000 so the NEXT lap still has something to compare to.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 0;
+ sector_purple::Event bad = sector_purple::update(st, in);
+ CHECK(bad.closedSector == 0);
+ CHECK(bad.sectorVerdict == Verdict::kNone);
+
+ in.currentSector = 3; in.lapSectorTime[1] = 26000;
+ sector_purple::update(st, in);
+ in.currentSector = 1; in.laps = 2; in.lastLapTime = 78000;
+ in.lapSectorTime[0] = 0; in.lapSectorTime[1] = 0;
+ sector_purple::update(st, in);
+
+ // Lap 3's S1 of 24000 compares against lap 1's 25000, not against 0.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24000;
+ sector_purple::Event ok = sector_purple::update(st, in);
+ CHECK(ok.closedSector == 1);
+ CHECK(ok.sectorVerdict == Verdict::kBest); // also beats the 25000 best
+}
+
+TEST_CASE("sprint between-runs holds history; race-not-started clears it") {
+ State st;
+ sector_purple::reset(st);
+ Sample in = base();
+ sector_purple::update(st, in);
+ runLap(st, in, 25000, 26000, 78000, 1);
+
+ in.currentSector = 0; // between runs
+ sector_purple::update(st, in);
+ in.currentSector = 1;
+ sector_purple::update(st, in);
+
+ // Run 2's S1 is compared against run 1's — the queue wait between
+ // them is not a reason to forget.
+ in.currentSector = 2;
+ in.lapSectorTime[0] = 24000;
+ CHECK(sector_purple::update(st, in).sectorVerdict == Verdict::kBest);
+
+ // Race over: everything goes, so the next session starts blank.
+ in.raceStarted = false;
+ sector_purple::update(st, in);
+ CHECK(st.lastSector == -1);
+ CHECK(st.prevLapTime == 0);
+ CHECK(st.lastSectorTime[0] == 0);
+ CHECK(st.bestLapAtOpen == 0);
+}
diff --git a/tests/setting_parse_test.cpp b/tests/setting_parse_test.cpp
new file mode 100644
index 0000000..ba5bd11
--- /dev/null
+++ b/tests/setting_parse_test.cpp
@@ -0,0 +1,96 @@
+#include "doctest.h"
+
+#include "setting_parse.h"
+
+#include
+
+using setting_parse::parseIntSetting;
+
+namespace {
+
+// Helper: parse and return the value, or a sentinel when rejected.
+int parsed(const char* s, int sentinel = -999999) {
+ int v = 0;
+ return parseIntSetting(s, &v) ? v : sentinel;
+}
+
+} // namespace
+
+TEST_CASE("parseIntSetting: accepts plain integers") {
+ CHECK(parsed("0") == 0);
+ CHECK(parsed("7") == 7);
+ CHECK(parsed("64") == 64);
+ CHECK(parsed("255") == 255);
+ CHECK(parsed("15000") == 15000);
+ CHECK(parsed("-360") == -360);
+ CHECK(parsed("+330") == 330);
+ CHECK(parsed("-0") == 0);
+}
+
+TEST_CASE("parseIntSetting: tolerates surrounding whitespace") {
+ CHECK(parsed(" 64") == 64);
+ CHECK(parsed("64 ") == 64);
+ CHECK(parsed("\t 64 \r\n") == 64);
+ CHECK(parsed(" -360 ") == -360);
+}
+
+TEST_CASE("parseIntSetting: rejects everything atoi() would silently zero") {
+ // THE bug this unit exists for: each of these came back as 0 from
+ // atoi(), and 0 is an in-range, destructive value for led_brightness.
+ int v = 12345;
+ CHECK(!parseIntSetting("", &v));
+ CHECK(v == 12345); // out is untouched on rejection
+ CHECK(!parseIntSetting(" ", &v));
+ CHECK(!parseIntSetting("abc", &v));
+ CHECK(!parseIntSetting("null", &v));
+ CHECK(!parseIntSetting("true", &v));
+ CHECK(!parseIntSetting("-", &v));
+ CHECK(!parseIntSetting("+", &v));
+ CHECK(!parseIntSetting(nullptr, &v));
+ CHECK(v == 12345);
+}
+
+TEST_CASE("parseIntSetting: rejects partial numbers rather than truncating") {
+ int v = 0;
+ CHECK(!parseIntSetting("12abc", &v)); // atoi -> 12
+ CHECK(!parseIntSetting("1.5", &v)); // atoi -> 1
+ CHECK(!parseIntSetting("0x10", &v)); // atoi -> 0
+ CHECK(!parseIntSetting("64,", &v)); // atoi -> 64
+ CHECK(!parseIntSetting("6 4", &v)); // atoi -> 6
+ CHECK(!parseIntSetting("--5", &v));
+}
+
+TEST_CASE("parseIntSetting: rejects values that do not fit an int") {
+ int v = 0;
+ CHECK(!parseIntSetting("99999999999", &v));
+ CHECK(!parseIntSetting("-99999999999", &v));
+ CHECK(!parseIntSetting("340282366920938463463374607431768211456", &v));
+ // The boundaries themselves still parse.
+ CHECK(parsed("2147483647") == 2147483647);
+ CHECK(parsed("-2147483648") == INT_MIN);
+}
+
+TEST_CASE("parseIntSetting: null out pointer is rejected, not dereferenced") {
+ CHECK(!parseIntSetting("64", nullptr));
+}
+
+TEST_CASE("the caller contract: a rejected value keeps the compiled-in default") {
+ // Mirrors BirdsEye.ino's read pattern for led_brightness, which is the
+ // one where 0 is both in range and destructive (LEDs off, 5 V boost
+ // rail never enabled). A blank or corrupt value must leave the default
+ // standing rather than reading as "the user asked for 0".
+ auto applyBrightness = [](const char* stored, unsigned char current) {
+ int b = 0;
+ if (parseIntSetting(stored, &b) && b >= 0 && b <= 255) {
+ return (unsigned char)b;
+ }
+ return current;
+ };
+
+ CHECK(applyBrightness("", 64) == 64); // was 0 -> LEDs dead
+ CHECK(applyBrightness("garbage", 64) == 64); // was 0 -> LEDs dead
+ CHECK(applyBrightness("128", 64) == 128); // a real change still lands
+ CHECK(applyBrightness("0", 64) == 0); // a DELIBERATE 0 still works
+ CHECK(applyBrightness("300", 64) == 64); // out of range -> default
+ CHECK(applyBrightness("-5", 64) == 64);
+}
diff --git a/tests/tach_filter_test.cpp b/tests/tach_filter_test.cpp
index 0e79dbf..4922b49 100644
--- a/tests/tach_filter_test.cpp
+++ b/tests/tach_filter_test.cpp
@@ -30,41 +30,176 @@ TEST_CASE("rpmFromMeanPeriodUs - non-positive inputs return 0") {
CHECK(rpmFromMeanPeriodUs(12000.0f, 0.0f) == 0.0f);
}
+// ---------------------------------------------------------------------------
+// Mode parsing — the tach_filter setting
+// ---------------------------------------------------------------------------
+
+TEST_CASE("modeFromSetting - the three tokens, case-insensitively") {
+ CHECK(modeFromSetting("smooth") == Mode::kSmooth);
+ CHECK(modeFromSetting("legacy") == Mode::kLegacy);
+ CHECK(modeFromSetting("raw") == Mode::kRaw);
+ CHECK(modeFromSetting("RAW") == Mode::kRaw);
+ CHECK(modeFromSetting("Legacy") == Mode::kLegacy);
+}
+
+TEST_CASE("modeFromSetting - anything unrecognised degrades to the default") {
+ // House idiom: a blank, garbled or future settings value must never be
+ // the reason a device logs worse data than the shipped default.
+ CHECK(modeFromSetting("") == Mode::kSmooth);
+ CHECK(modeFromSetting(nullptr) == Mode::kSmooth);
+ CHECK(modeFromSetting("rawr") == Mode::kSmooth); // prefix is not a match
+ CHECK(modeFromSetting("ra") == Mode::kSmooth); // nor is a truncation
+ CHECK(modeFromSetting("off") == Mode::kSmooth);
+}
+
+TEST_CASE("modeName / modeTag - round trip and stay distinct") {
+ const Mode all[] = {Mode::kSmooth, Mode::kLegacy, Mode::kRaw};
+ for (Mode m : all) {
+ CHECK(modeFromSetting(modeName(m)) == m);
+ }
+ CHECK(modeTag(Mode::kSmooth) != modeTag(Mode::kLegacy));
+ CHECK(modeTag(Mode::kSmooth) != modeTag(Mode::kRaw));
+ CHECK(modeTag(Mode::kLegacy) != modeTag(Mode::kRaw));
+}
+
+// ---------------------------------------------------------------------------
+// Noise models
+// ---------------------------------------------------------------------------
+
+TEST_CASE("processNoise - legacy is flat per update, smooth is per second") {
+ CHECK(processNoise(Mode::kLegacy, 0.001f) == kProcessNoiseQ);
+ CHECK(processNoise(Mode::kLegacy, 10.0f) == kProcessNoiseQ);
+ // The rate is chosen so smooth matches legacy at a 6000 RPM single's
+ // 10 ms period — the point the old per-update tuning was implicitly
+ // calibrated for.
+ CHECK(processNoise(Mode::kSmooth, 0.010f) == doctest::Approx(kProcessNoiseQ));
+ // …and, unlike legacy, it halves when the period does.
+ CHECK(processNoise(Mode::kSmooth, 0.005f) == doctest::Approx(kProcessNoiseQ / 2));
+}
+
+TEST_CASE("processNoise - clamped at both ends") {
+ // A long coast must not open the uncertainty so wide the gate stops
+ // meaning anything; a degenerate dt must not freeze the filter by
+ // driving the Kalman gain to zero.
+ CHECK(processNoise(Mode::kSmooth, 100.0f) ==
+ doctest::Approx(kProcessNoiseRate * kMaxPredictSeconds));
+ CHECK(processNoise(Mode::kSmooth, 0.0f) == kProcessNoiseFloorQ);
+ CHECK(processNoise(Mode::kSmooth, -5.0f) == kProcessNoiseFloorQ);
+}
+
+TEST_CASE("measurementNoise - legacy is RPM-blind, smooth is not") {
+ CHECK(measurementNoise(Mode::kLegacy, 2000.0f, 1.0f, 1) == kMeasurementNoiseRBase);
+ CHECK(measurementNoise(Mode::kLegacy, 14000.0f, 1.0f, 1) == kMeasurementNoiseRBase);
+ // THE bug this fixes: RPM = K/period, so a fixed timing error costs
+ // RPM^2/K of RPM error. A flat R was far too confident at the top of
+ // the rev range, which is exactly where the plotted trace got noisy.
+ CHECK(measurementNoise(Mode::kSmooth, 14000.0f, 1.0f, 1) >
+ 10.0f * measurementNoise(Mode::kSmooth, 3000.0f, 1.0f, 1));
+}
+
+TEST_CASE("measurementNoise - grows monotonically with RPM") {
+ float prev = 0.0f;
+ for (float rpm = 500.0f; rpm <= 20000.0f; rpm += 500.0f) {
+ const float r = measurementNoise(Mode::kSmooth, rpm, 1.0f, 1);
+ CHECK(r > prev);
+ prev = r;
+ }
+}
+
+TEST_CASE("measurementNoise - a shorter period per pulse is noisier") {
+ // A twin at 6000 RPM fires twice as often, so each period is half as
+ // long and the same timing jitter is worth twice the RPM error.
+ CHECK(measurementNoise(Mode::kSmooth, 6000.0f, 0.5f, 1) >
+ measurementNoise(Mode::kSmooth, 6000.0f, 1.0f, 1));
+}
+
+TEST_CASE("measurementNoise - averaging more periods lowers it, in both modes") {
+ CHECK(measurementNoise(Mode::kSmooth, 6000.0f, 1.0f, 4) ==
+ doctest::Approx(measurementNoise(Mode::kSmooth, 6000.0f, 1.0f, 1) / 4));
+ CHECK(measurementNoise(Mode::kLegacy, 6000.0f, 1.0f, 4) ==
+ doctest::Approx(kMeasurementNoiseRBase / 4));
+}
+
+TEST_CASE("measurementNoise - degenerate inputs stay finite and positive") {
+ CHECK(measurementNoise(Mode::kSmooth, -100.0f, 1.0f, 1) == kMeasurementNoiseRBase);
+ CHECK(std::isfinite(measurementNoise(Mode::kSmooth, 6000.0f, 0.0f, 1)));
+ CHECK(measurementNoise(Mode::kSmooth, 6000.0f, 1.0f, 0) > 0.0f);
+}
+
// ---------------------------------------------------------------------------
// Kalman update behavior
// ---------------------------------------------------------------------------
+namespace {
+
+// A steady stream of identical measurements at one engine speed: the
+// period each pulse spans is the dt the predict step gets.
+void feed(Kalman& k, Mode m, float rpm, int count, float revsPerPulse = 1.0f) {
+ const float dt = 60.0f / rpm * revsPerPulse; // seconds per pulse
+ for (int i = 0; i < count; i++) {
+ update(k, m, rpm, 1, dt, revsPerPulse);
+ }
+}
+
+} // namespace
+
TEST_CASE("Kalman - reset state is rest with high uncertainty") {
Kalman k;
k.x = 4321.0f;
k.p = 5.0f;
+ k.updates = 9;
+ k.strikes = 2;
reset(k);
CHECK(k.x == 0.0f);
CHECK(k.p == kInitialUncertaintyP);
+ CHECK(k.updates == 0);
+ CHECK(k.strikes == 0);
+}
+
+TEST_CASE("Kalman - reset keeps the reject count") {
+ // reset() runs on every engine stop. Clearing the diagnostic there
+ // would zero it at every corner and make the pickup-health number on
+ // the tach debug line meaningless.
+ Kalman k;
+ k.rejected = 37;
+ reset(k);
+ CHECK(k.rejected == 37);
+}
+
+TEST_CASE("Kalman - the first measurement after a reset is adopted whole") {
+ // With no prior there is nothing to filter against, and a slow climb
+ // out of 0 RPM would arm the gate partway up and get the engine's own
+ // speed rejected as an outlier.
+ Kalman k;
+ CHECK(update(k, Mode::kSmooth, 5000.0f, 1, 0.012f, 1.0f));
+ CHECK(k.x == doctest::Approx(5000.0f));
+ CHECK(k.p == kInitialUncertaintyP);
}
-TEST_CASE("Kalman - first update after reset jumps most of the way") {
- // High post-reset uncertainty means the first measurement dominates:
- // gain = (10000+800)/(10000+800+2500) ≈ 0.81.
+TEST_CASE("Kalman - legacy keeps its partial first jump") {
+ // Not adopted: kLegacy exists to reproduce the shipped filter, whose
+ // flat R put the first update's gain at 0.81.
Kalman k;
- update(k, 5000.0f, 1);
+ CHECK(update(k, Mode::kLegacy, 5000.0f, 1, 0.012f, 1.0f));
CHECK(k.x > 4000.0f);
CHECK(k.x < 5000.0f);
}
TEST_CASE("Kalman - converges to a constant measurement") {
Kalman k;
- for (int i = 0; i < 50; i++) {
- update(k, 5000.0f, 3);
- }
+ feed(k, Mode::kSmooth, 5000.0f, 200);
CHECK(k.x == doctest::Approx(5000.0f).epsilon(0.001));
}
TEST_CASE("Kalman - approach to a constant input is monotonic (no overshoot)") {
+ // Started from a settled estimate, not a fresh reset: the first
+ // measurement after a reset is adopted outright, so there is no climb
+ // to watch there. The step is well inside the gate.
Kalman k;
+ feed(k, Mode::kSmooth, 5800.0f, 40);
float prev = k.x;
for (int i = 0; i < 20; i++) {
- update(k, 6000.0f, 2);
+ update(k, Mode::kSmooth, 6000.0f, 1, 0.010f, 1.0f);
CHECK(k.x > prev); // climbing toward the measurement…
CHECK(k.x <= 6000.0f); // …without ever passing it
prev = k.x;
@@ -72,143 +207,309 @@ TEST_CASE("Kalman - approach to a constant input is monotonic (no overshoot)") {
}
TEST_CASE("Kalman - more periods per measurement means faster convergence") {
- // R scales as R_BASE/periodCount, so a batch of 8 periods pulls the
+ // R scales as R/periodCount, so a batch of 8 periods pulls the
// estimate harder than a single period does.
Kalman one, eight;
- update(one, 5000.0f, 1);
- update(eight, 5000.0f, 8);
+ feed(one, Mode::kSmooth, 5800.0f, 40);
+ feed(eight, Mode::kSmooth, 5800.0f, 40);
+ update(one, Mode::kSmooth, 6000.0f, 1, 0.010f, 1.0f);
+ update(eight, Mode::kSmooth, 6000.0f, 8, 0.080f, 1.0f);
CHECK(eight.x > one.x);
}
TEST_CASE("Kalman - uncertainty never collapses below the floor") {
Kalman k;
for (int i = 0; i < 1000; i++) {
- update(k, 5000.0f, 8);
+ update(k, Mode::kSmooth, 5000.0f, 8, 0.096f, 1.0f);
}
CHECK(k.p >= kUncertaintyFloorP);
}
-TEST_CASE("Kalman - non-positive period count is a no-op") {
- Kalman k;
- update(k, 5000.0f, 3);
- const float x = k.x;
- const float p = k.p;
- update(k, 9999.0f, 0);
- update(k, 9999.0f, -1);
- CHECK(k.x == x);
- CHECK(k.p == p);
+TEST_CASE("Kalman - non-positive period count is a no-op in every mode") {
+ const Mode all[] = {Mode::kSmooth, Mode::kLegacy, Mode::kRaw};
+ for (Mode m : all) {
+ Kalman k;
+ update(k, m, 5000.0f, 3, 0.036f, 1.0f);
+ const float x = k.x;
+ const float p = k.p;
+ CHECK_FALSE(update(k, m, 9999.0f, 0, 0.012f, 1.0f));
+ CHECK_FALSE(update(k, m, 9999.0f, -1, 0.012f, 1.0f));
+ CHECK(k.x == x);
+ CHECK(k.p == p);
+ }
}
TEST_CASE("Kalman - tracks a ramp like a real engine pull") {
- // Feed an accelerating input (25 Hz batches, +100 RPM per batch) and
- // require the estimate to lag but follow within the process noise's
- // ability to track crankshaft inertia.
+ // Feed an accelerating input and require the estimate to lag but
+ // follow within the process noise's ability to track crank inertia.
Kalman k;
float rpm = 3000.0f;
- for (int i = 0; i < 40; i++) {
- update(k, rpm, 3);
- rpm += 100.0f;
+ for (int i = 0; i < 120; i++) {
+ update(k, Mode::kSmooth, rpm, 1, 60.0f / rpm, 1.0f);
+ rpm += 40.0f;
}
- // input is now 6900 (last fed 6900-100); estimate must be close behind
CHECK(k.x > 6000.0f);
CHECK(k.x < rpm);
}
// ---------------------------------------------------------------------------
-// revsPerPulse — engine geometry (plan 0003)
+// The outlier gate (plan 0009) — why the plotted RPM used to spike
// ---------------------------------------------------------------------------
-TEST_CASE("revsPerPulse - the defaults reproduce the old hardcoded behaviour") {
- // The single most important case: a device that has never been configured
- // must read exactly as it did before these settings existed.
- CHECK(revsPerPulse(1, true) == doctest::Approx(1.0f));
+TEST_CASE("gate - one spurious edge does not move the estimate") {
+ // THE bug. A ringing edge 4 ms after a real one at 3000 RPM reads as
+ // 15,000 RPM; the ungated filter folded ~43% of that straight into
+ // the output, so a single bad microsecond reading became a 5000 RPM
+ // spike on the graph.
+ Kalman k;
+ feed(k, Mode::kSmooth, 3000.0f, 40);
+ const float settled = k.x;
+
+ CHECK_FALSE(update(k, Mode::kSmooth, 15000.0f, 1, 0.004f, 1.0f));
+ CHECK(k.x == doctest::Approx(settled)); // estimate coasted, untouched
+ CHECK(k.rejected == 1);
}
-TEST_CASE("revsPerPulse - more cylinders means fewer revs per pulse") {
- CHECK(revsPerPulse(2, true) == doctest::Approx(0.5f));
- CHECK(revsPerPulse(4, true) == doctest::Approx(0.25f));
+TEST_CASE("gate - one missed spark does not halve the estimate either") {
+ // The other signature: the pickup drops a spark, the period doubles,
+ // and the measurement reads half the real engine speed.
+ Kalman k;
+ feed(k, Mode::kSmooth, 6000.0f, 40);
+ const float settled = k.x;
+
+ CHECK_FALSE(update(k, Mode::kSmooth, 3000.0f, 1, 0.020f, 1.0f));
+ CHECK(k.x == doctest::Approx(settled));
+ CHECK(k.rejected == 1);
}
-TEST_CASE("revsPerPulse - single-fire sees one spark per two revolutions") {
- // A 4-stroke without wasted spark fires half as often, so each pulse
- // accounts for two revolutions rather than one.
- CHECK(revsPerPulse(1, false) == doctest::Approx(2.0f));
- CHECK(revsPerPulse(2, false) == doctest::Approx(1.0f));
- CHECK(revsPerPulse(4, false) == doctest::Approx(0.5f));
+TEST_CASE("gate - the ungated filter really did pass those spikes through") {
+ // Same insult, legacy mode: the contrast IS the bug report, so assert
+ // it rather than leaving it to the plan document.
+ Kalman k;
+ feed(k, Mode::kLegacy, 3000.0f, 40);
+ const float settled = k.x;
+ CHECK(update(k, Mode::kLegacy, 15000.0f, 1, 0.004f, 1.0f));
+ CHECK(k.x > settled + 4000.0f);
+ CHECK(k.rejected == 0); // legacy has no gate to count with
}
-TEST_CASE("revsPerPulse - a nonsensical cylinder count degrades, never divides by zero") {
- // A corrupt or hand-edited SETTINGS.json must not produce inf/NaN RPM.
- CHECK(revsPerPulse(0, true) == doctest::Approx(1.0f));
- CHECK(revsPerPulse(-3, true) == doctest::Approx(1.0f));
- CHECK(revsPerPulse(9999, true) == doctest::Approx(1.0f / (float)kMaxCylinders));
- CHECK(std::isfinite(revsPerPulse(0, false)));
+TEST_CASE("gate - three consecutive outliers are a real step change") {
+ // A clutch dump or a spin is not three coincident bad edges. The
+ // third measurement is adopted outright, not filtered toward.
+ Kalman k;
+ feed(k, Mode::kSmooth, 11000.0f, 60);
+
+ CHECK_FALSE(update(k, Mode::kSmooth, 4000.0f, 1, 0.015f, 1.0f));
+ CHECK_FALSE(update(k, Mode::kSmooth, 4000.0f, 1, 0.015f, 1.0f));
+ CHECK(update(k, Mode::kSmooth, 4000.0f, 1, 0.015f, 1.0f));
+ CHECK(k.x == doctest::Approx(4000.0f));
+ CHECK(k.strikes == 0); // strike run cleared on adoption
}
-TEST_CASE("revsPerPulse - end to end, a twin reads half a single's RPM") {
- // 6000 pulse-RPM measured at the pickup: on a single that IS 6000 rev/min,
- // on a wasted-spark twin the crank is only turning 3000.
- const float periodUs = 60.0e6f / 6000.0f;
- CHECK(rpmFromMeanPeriodUs(periodUs, revsPerPulse(1, true)) == doctest::Approx(6000.0f));
- CHECK(rpmFromMeanPeriodUs(periodUs, revsPerPulse(2, true)) == doctest::Approx(3000.0f));
- // …and a 4-stroke single-fire is turning twice as fast as the pulses suggest.
- CHECK(rpmFromMeanPeriodUs(periodUs, revsPerPulse(1, false)) == doctest::Approx(12000.0f));
+TEST_CASE("gate - an isolated outlier does not accumulate strikes") {
+ // Rejections have to be CONSECUTIVE. Otherwise a pickup emitting one
+ // bad edge every few seconds would eventually trip the escape hatch
+ // on three unrelated spikes and adopt one of them.
+ Kalman k;
+ feed(k, Mode::kSmooth, 6000.0f, 40);
+ for (int i = 0; i < 3; i++) {
+ CHECK_FALSE(update(k, Mode::kSmooth, 15000.0f, 1, 0.004f, 1.0f));
+ feed(k, Mode::kSmooth, 6000.0f, 1); // a good period in between
+ }
+ CHECK(k.x == doctest::Approx(6000.0f).epsilon(0.02));
+ CHECK(k.rejected == 3);
+}
+
+TEST_CASE("gate - stays shut until the estimate means something") {
+ // Straight out of reset the estimate is 0 RPM. Gating against that
+ // would reject the engine starting, which is the one measurement the
+ // filter absolutely must accept.
+ Kalman k;
+ CHECK(update(k, Mode::kSmooth, 4000.0f, 1, 0.015f, 1.0f));
+ CHECK(k.rejected == 0);
+ CHECK(k.x > 0.0f);
+}
+
+TEST_CASE("gate - a whole engine start is never rejected") {
+ Kalman k;
+ for (int i = 0; i < 20; i++) {
+ CHECK(update(k, Mode::kSmooth, 4000.0f, 1, 0.015f, 1.0f));
+ }
+ CHECK(k.rejected == 0);
+ CHECK(k.x == doctest::Approx(4000.0f).epsilon(0.01));
+}
+
+TEST_CASE("gate - real acceleration never trips it") {
+ // A 5500 RPM/s pull moves the crank only ~25 RPM between pulses —
+ // orders of magnitude inside the gate. If this ever fails, the gate
+ // has been tightened into something that fights the engine.
+ Kalman k;
+ float rpm = 2000.0f;
+ feed(k, Mode::kSmooth, rpm, 20);
+ for (int i = 0; i < 400 && rpm < 13000.0f; i++) {
+ const float dt = 60.0f / rpm;
+ rpm += 5500.0f * dt;
+ update(k, Mode::kSmooth, rpm, 1, dt, 1.0f);
+ }
+ CHECK(k.rejected == 0);
+ CHECK(k.x > 11000.0f);
+}
+
+TEST_CASE("gate - hard deceleration is not held up either") {
+ // 10,000 RPM/s off the throttle. What is left is ordinary tracking
+ // lag, bounded here at 600 RPM — 60 ms at that rate — not the gate
+ // fighting the engine, which would look like the estimate parking
+ // near 13,000 while the measurements sat at 3000.
+ Kalman k;
+ float rpm = 13000.0f;
+ feed(k, Mode::kSmooth, rpm, 40);
+ for (int i = 0; i < 400 && rpm > 3000.0f; i++) {
+ const float dt = 60.0f / rpm;
+ rpm -= 10000.0f * dt;
+ update(k, Mode::kSmooth, rpm, 1, dt, 1.0f);
+ }
+ CHECK(k.x > rpm - 600.0f);
+ CHECK(k.x < rpm + 600.0f);
}
// ---------------------------------------------------------------------------
-// minPulseGapUs — debounce that keeps the true-RPM ceiling constant
+// Raw mode — the estimator switched off for a track A/B
// ---------------------------------------------------------------------------
-TEST_CASE("minPulseGapUs - unchanged for the historical single-cylinder case") {
- CHECK(minPulseGapUs(1, true) == kBasePulseGapUs);
+TEST_CASE("raw - publishes the measurement verbatim, spikes included") {
+ Kalman k;
+ feed(k, Mode::kRaw, 6000.0f, 10);
+ CHECK(k.x == doctest::Approx(6000.0f));
+ // The whole point: no gate, no smoothing, so what the pickup delivers
+ // is what the DOVEX column gets.
+ CHECK(update(k, Mode::kRaw, 15000.0f, 1, 0.004f, 1.0f));
+ CHECK(k.x == doctest::Approx(15000.0f));
+ CHECK(k.rejected == 0);
}
-TEST_CASE("minPulseGapUs - tightens as pulses per rev rise") {
- // A twin firing every rev produces twice the pulses, so the gap has to
- // halve or the debounce itself becomes the RPM ceiling.
- CHECK(minPulseGapUs(2, true) == 1500u);
- CHECK(minPulseGapUs(3, true) == 1000u);
- CHECK(minPulseGapUs(4, true) == kMinPulseGapFloorUs);
+TEST_CASE("raw - leaves the uncertainty wide open") {
+ // So the first filtered update after a mode change (which means a
+ // reboot) re-acquires immediately instead of inheriting false
+ // confidence from a run of unfiltered values.
+ Kalman k;
+ feed(k, Mode::kRaw, 6000.0f, 50);
+ CHECK(k.p == kInitialUncertaintyP);
}
-TEST_CASE("minPulseGapUs - never goes below the floor") {
- CHECK(minPulseGapUs(8, true) == kMinPulseGapFloorUs);
- CHECK(minPulseGapUs(16, true) == kMinPulseGapFloorUs);
+// ---------------------------------------------------------------------------
+// Legacy mode — must reproduce the pre-0009 filter exactly, or the
+// track-side A/B against existing logs is comparing against a fiction.
+// ---------------------------------------------------------------------------
+
+TEST_CASE("legacy - is the old fixed-Q, fixed-R, ungated arithmetic") {
+ Kalman k;
+ // Hand-rolled reference: the shipped filter, verbatim.
+ float x = 0.0f, p = kInitialUncertaintyP;
+ for (int i = 0; i < 25; i++) {
+ const float rpm = 5000.0f + 100.0f * (float)i;
+ p += kProcessNoiseQ;
+ const float r = kMeasurementNoiseRBase / 3.0f;
+ const float gain = p / (p + r);
+ x += gain * (rpm - x);
+ p *= (1.0f - gain);
+ if (p < kUncertaintyFloorP) p = kUncertaintyFloorP;
+
+ CHECK(update(k, Mode::kLegacy, rpm, 3, 0.036f, 1.0f));
+ CHECK(k.x == doctest::Approx(x));
+ CHECK(k.p == doctest::Approx(p));
+ }
}
-TEST_CASE("minPulseGapUs - widens when the engine fires less often") {
- // Fewer edges to catch, so the extra margin is free ringing rejection.
- CHECK(minPulseGapUs(1, false) == 6000u);
-}
-
-TEST_CASE("minPulseGapUs - holds the old true-RPM ceiling up to four cylinders") {
- // The old fixed 3 ms allowed 20,000 pulses/min, which on a single IS
- // 20,000 RPM. Deriving the gap is what keeps that ceiling meaningful on
- // every engine instead of quietly halving it per added cylinder.
- const float oldCeilingRpm = 60.0e6f / (float)kBasePulseGapUs; // 20,000
- struct { int cyl; bool wasted; } cases[] = {
- {1, true}, {2, true}, {3, true}, {4, true},
- {1, false}, {2, false}, {4, false}, {8, false},
- };
- for (const auto& c : cases) {
- const float gap = (float)minPulseGapUs(c.cyl, c.wasted);
- const float ceiling = rpmFromMeanPeriodUs(gap, revsPerPulse(c.cyl, c.wasted));
- CHECK(ceiling >= oldCeilingRpm);
+TEST_CASE("legacy - ignores dt entirely") {
+ Kalman fast, slow;
+ for (int i = 0; i < 20; i++) {
+ update(fast, Mode::kLegacy, 6000.0f, 1, 0.001f, 1.0f);
+ update(slow, Mode::kLegacy, 6000.0f, 1, 0.400f, 1.0f);
}
+ CHECK(fast.x == doctest::Approx(slow.x));
}
-TEST_CASE("minPulseGapUs - past four cylinders the floor binds, and that is fine") {
- // Documented consequence, asserted so it can't drift silently: the gap
- // stops shrinking, so the ceiling falls. It stays far above anything this
- // logger is pointed at.
- const float ceiling8 =
- rpmFromMeanPeriodUs((float)minPulseGapUs(8, true), revsPerPulse(8, true));
- CHECK(ceiling8 == doctest::Approx(10000.0f));
- CHECK(ceiling8 < 60.0e6f / (float)kBasePulseGapUs);
+// ---------------------------------------------------------------------------
+// revsPerPulse — engine geometry (plans 0003, 0014)
+//
+// One sense wire, one clamp, one cylinder's ignition. The cylinder count is
+// NOT a term — it was, and on the shipped hardware that divided a V8's RPM
+// by eight.
+// ---------------------------------------------------------------------------
+
+TEST_CASE("revsPerPulse - a plug firing every rev is one rev per pulse") {
+ // 2-stroke, or 4-stroke wasted spark: unchanged from the original
+ // hardcoded behaviour, which is what an unconfigured device must read.
+ CHECK(revsPerPulse(true) == doctest::Approx(1.0f));
+}
+
+TEST_CASE("revsPerPulse - single-fire sees one spark per two revolutions") {
+ // A 4-stroke without wasted spark (traditional distributor/magneto) fires
+ // half as often, so each pulse accounts for two revolutions.
+ CHECK(revsPerPulse(false) == doctest::Approx(2.0f));
+}
+
+TEST_CASE("revsPerPulse - the V8 case the cylinder divider used to break") {
+ // THE regression this exists for. A V8 on a traditional magneto, clamped
+ // on ONE plug wire: that plug fires once per two crank revolutions, so
+ // 1500 pulses/min is 3000 RPM — on a V8 exactly as on a single. The old
+ // `pulses_per_rev = cylinders x sparkFactor` answered 375.
+ const float periodUs = 60.0e6f / 1500.0f; // 1500 pulses per minute
+ CHECK(rpmFromMeanPeriodUs(periodUs, revsPerPulse(false)) == doctest::Approx(3000.0f));
+}
+
+TEST_CASE("revsPerPulse - cylinder count cannot reach the RPM math at all") {
+ // Enforced by the signature, asserted here so re-introducing a cylinder
+ // term has to delete a test that says why it was removed: the pickup sees
+ // ONE wire, so 500 pulses/min is 500 RPM on a single and on a twin alike.
+ const float periodUs = 60.0e6f / 500.0f;
+ CHECK(rpmFromMeanPeriodUs(periodUs, revsPerPulse(true)) == doctest::Approx(500.0f));
+}
+
+// ---------------------------------------------------------------------------
+// cylinder_count — descriptive only, drives the inferred-RPM warning
+// ---------------------------------------------------------------------------
+
+TEST_CASE("clampCylinderCount - a corrupt setting is bounded, never trusted") {
+ CHECK(clampCylinderCount(0) == kMinCylinders);
+ CHECK(clampCylinderCount(-3) == kMinCylinders);
+ CHECK(clampCylinderCount(9999) == kMaxCylinders);
+ CHECK(clampCylinderCount(8) == 8);
+}
+
+TEST_CASE("rpmIsInferred - true on anything but a single") {
+ // A single's every firing IS the crank turning; on anything else the
+ // crank speed between firings is an assumption, which is what the user
+ // is warned about.
+ CHECK_FALSE(rpmIsInferred(1));
+ CHECK(rpmIsInferred(2));
+ CHECK(rpmIsInferred(8));
+ // A garbage value must not silently claim to be a single.
+ CHECK_FALSE(rpmIsInferred(0));
+ CHECK(rpmIsInferred(9999));
}
-TEST_CASE("minPulseGapUs - a clamped cylinder count still yields a usable gap") {
- CHECK(minPulseGapUs(0, true) == kBasePulseGapUs);
- CHECK(minPulseGapUs(-1, true) == kBasePulseGapUs);
- CHECK(minPulseGapUs(9999, true) >= kMinPulseGapFloorUs);
+// ---------------------------------------------------------------------------
+// minPulseGapUs — debounce holding the same ceiling in both spark modes
+// ---------------------------------------------------------------------------
+
+TEST_CASE("minPulseGapUs - unchanged historical gap for a plug firing every rev") {
+ CHECK(minPulseGapUs(true) == kBasePulseGapUs);
+}
+
+TEST_CASE("minPulseGapUs - widens when the engine fires less often") {
+ // Half the edges to catch, so the extra margin is free ringing rejection.
+ CHECK(minPulseGapUs(false) == 2u * kBasePulseGapUs);
+}
+
+TEST_CASE("minPulseGapUs - the same ~20,000 RPM ceiling in both spark modes") {
+ // The historical 3 ms allowed 20,000 pulses/min, which on a plug firing
+ // every rev IS 20,000 RPM. Doubling the gap for a plug firing half as
+ // often keeps the ceiling where it has always been.
+ const float ceilingRpm = 60.0e6f / (float)kBasePulseGapUs; // 20,000
+ const bool modes[] = {true, false};
+ for (bool wasted : modes) {
+ const float gap = (float)minPulseGapUs(wasted);
+ CHECK(rpmFromMeanPeriodUs(gap, revsPerPulse(wasted)) == doctest::Approx(ceilingRpm));
+ }
}