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)); + } }