diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c9ce380..46af1be 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -103,7 +103,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 -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" \ --output-dir dist \ --warnings none \ BirdsEye diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index 5abfeb3..7b29ed2 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -13,18 +13,31 @@ jobs: name: ${{ matrix.board.name }} runs-on: ubuntu-latest env: - # DovesLapTimer ref for this build: anything targeting (or running on) - # the BETA branch tracks the library's own BETA branch, so the two beta - # channels move together; everything else pins the known-good release - # tag (bump deliberately). base_ref is only set on pull_request events; - # ref_name covers push / workflow_dispatch. - LAPTIMER_REF: ${{ (github.base_ref == 'BETA' || github.ref_name == 'BETA') && 'BETA' || 'v4.2.0' }} - # Feature flags for this build, matched to the channel the code is - # headed for (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 (all flags off). - FEATURE_FLAGS: ${{ (github.base_ref == 'BETA' || github.ref_name == 'BETA') && '-DBIRDSEYE_ENABLE_SENSOREGG=1' || '' }} + # Channel this build belongs to. The channel follows the SOURCE tree + # being compiled, not just where it is headed: + # base_ref == BETA -> a feature PR into BETA (compiling BETA-era code) + # head_ref == BETA -> the long-lived BETA -> master integration PR + # (#113). Its source is still BETA's, so it must + # build with BETA's library + flags; without this + # it compiled BETA code against master's pins and + # failed on the sprint headers forever. + # ref_name == BETA -> push / workflow_dispatch on BETA itself + # Anything else is master/release. NOTE: merging BETA into master is + # what promotes the channel — at that point the fallbacks below must be + # bumped deliberately (a DovesLapTimer release tag carrying + # CrossingEngine/SprintTimer, plus a decision on the SensorEgg flag), + # here and in sim-build.yml / release.yml. (The condition is repeated + # rather than hoisted into its own env var because a job-level `env:` + # entry cannot reference its siblings.) + # DovesLapTimer ref: the beta channel tracks the library's own BETA + # branch so the two move together; everything else pins the known-good + # release tag. + LAPTIMER_REF: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && 'BETA' || 'v4.2.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' || '' }} # 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. @@ -74,7 +87,7 @@ jobs: # project.h static_asserts on it — keep all three workflows in sync. cli-compile-flags: | - --build-property - - compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256 ${{ env.FEATURE_FLAGS }} + - compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG ${{ env.FEATURE_FLAGS }} enable-deltas-report: true # Hard flash-size gate. The XIAO nRF52840's app flash is shared @@ -126,10 +139,21 @@ jobs: # bootloader is exactly that size. The program's flash usage equals the # app .bin that gets streamed/flashed, so gate it against that cap: warn # as it approaches, fail if it crosses (the image would be un-OTA-able). + # + # There are TWO caps, because the cap lives in the *installed* firmware, + # not in the image being delivered: + # OTA_IMAGE_MAX_BYTES — this layout's cap. Hard failure past it. + # OTA_LEGACY_MAX_BYTES — the cap on units still running <= 3.0.x, which + # stage at the old 0xA4000. Those units reject a larger image with + # FWERR:SIZE and would need the USB UF2 path (FWDFU) instead, so + # crossing it is a warning: it means the fleet has split. + # Drop the legacy warning once every unit you care about has taken a + # build carrying the 0x8E000 layout. - name: OTA image size gate env: # Keep in sync with FW_MAX_IMAGE_SIZE in BirdsEye/firmware_ota.ino. - OTA_IMAGE_MAX_BYTES: 327680 # 320 KiB + OTA_IMAGE_MAX_BYTES: 417792 # 408 KiB + OTA_LEGACY_MAX_BYTES: 327680 # 320 KiB — pre-3.1 staging layout OTA_WARN_PERCENT: 90 run: | report="$(find sketches-reports -name '*.json' | head -1)" @@ -149,13 +173,18 @@ jobs: pct="$(awk "BEGIN { printf \"%.1f\", ($flash_abs / $OTA_IMAGE_MAX_BYTES) * 100 }")" echo "OTA image: ${flash_abs} / ${OTA_IMAGE_MAX_BYTES} bytes (${pct}% of the OTA cap)" if [ "$flash_abs" -gt "$OTA_IMAGE_MAX_BYTES" ]; then - echo "::error::Image ${flash_abs} B exceeds the OTA cap ${OTA_IMAGE_MAX_BYTES} B — FWBEGIN would reject it (FWERR:SIZE). Shrink the image or raise FW_MAX_IMAGE_SIZE (which moves the staging base down, costing app space)." + echo "::error::Image ${flash_abs} B exceeds the OTA cap ${OTA_IMAGE_MAX_BYTES} B — FWBEGIN would reject it (FWERR:SIZE). Shrink the image, or finish plan 0004's SD-direct apply (which removes the staging region and the cap with it)." exit 1 fi warn="$(awk "BEGIN { print (($flash_abs / $OTA_IMAGE_MAX_BYTES) * 100 >= ${OTA_WARN_PERCENT}) ? 1 : 0 }")" if [ "$warn" = "1" ]; then echo "::warning::OTA image at ${pct}% of the ${OTA_IMAGE_MAX_BYTES} B cap (>= ${OTA_WARN_PERCENT}%) — getting close to un-OTA-able; plan to shrink or raise the cap." fi + # Fleet-split check: an image past the legacy cap can no longer be + # installed by units still running the pre-3.1 staging layout. + if [ "$flash_abs" -gt "$OTA_LEGACY_MAX_BYTES" ]; then + echo "::warning::Image ${flash_abs} B exceeds the legacy ${OTA_LEGACY_MAX_BYTES} B cap — units still on <= 3.0.x will answer FWERR:SIZE and can only take this build over USB (FWDFU -> UF2). Ship a <= ${OTA_LEGACY_MAX_BYTES} B build first if any such unit still needs to migrate." + fi - name: Upload size deltas report if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7a5ba4..6a14de1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: run: | arduino-cli compile \ --fqbn "${{ matrix.board.fqbn }}" \ - --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256" \ + --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG" \ --output-dir dist \ --warnings none \ BirdsEye diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b772d6a..a5776d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +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`) deliberately avoid Arduino headers. +`dovex_header`, `filename_validator`, `course_creator`, `track_json`, …) +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. @@ -97,16 +98,25 @@ to the matching `*_LOOP()`. remote-button notifications. - **SensorEgg** (`sensoregg` + the `sensoregg_protocol` pure unit) — wireless EGT proof of concept: a passive BLE observer receives the - DovesSensorEgg pod's `PW-ADV-1` advertising broadcasts (14-byte - manufacturer data: EGT + cold junction as int16 deci-degC, fault flags, - sequence counter) and feeds the `Temp1`/`Junction1` DOVEX columns and - the Temp1 race page. Observer + peripheral coexist natively on S140; - passive scanning never transmits, so the camera link is untouched. - Readings older than 1 s go NaN — never held across a dropout. Gated on - the `BIRDSEYE_ENABLE_SENSOREGG` build flag: on in the beta channel, off - in master/release, where the scanner and Temp1 page are compiled out, - BLE returns to lazy init, and the DOVEX `Temp1`/`Junction1` columns are - written as `nan` so the log format stays identical across channels. + DovesSensorEgg pod's `PW-ADV` advertising broadcasts, v1 (14-byte: + EGT + cold junction as int16 deci-degC, fault flags, sequence counter) + and v2 (16-byte: + aux intake-air thermistor, real battery percent), + and feeds the `Temp1`/`Junction1`/`Temp2` DOVEX columns and the + Temp1/Temp2 race pages. Observer + peripheral coexist natively on + S140; passive scanning never transmits, so the camera link is + untouched. Readings older than 1 s go NaN — never held across a + dropout. Gated on the `BIRDSEYE_ENABLE_SENSOREGG` build flag: on in + the beta channel, off in master/release, where the scanner and temp + pages are compiled out, BLE returns to lazy init, and the DOVEX + `Temp1`/`Junction1`/`Temp2` columns are written as `nan` so the log + format stays identical across channels. +- **Course creator** (`course_creator` + `track_json` pure units, glued + into the menu/pages/SD modules) — authors a track course on the device + by walking to each cone and holding for a 3 s GPS average. Autocross + venues re-lay their course every event, so the alternative was a laptop + in a paddock. No text is ever entered on-device: names come from the GPS + clock and are renamed later in the web app. This is also the firmware's + only track-JSON *writer* — everywhere else the format is read-only. - **Replay** (`replay`) — instant DOVEX header replay. - **Settings** (`settings`) — JSON key/value store on the SD card. - **CourseManager** (external library) — owns course detection, sector @@ -181,7 +191,12 @@ the last resort. ### Shutdown is System OFF, wake is a reboot There is no power switch (deliberately — the next hardware revision drops it), so "off" is nRF52 **System OFF** at ~µA with GPIO SENSE armed on the -tach pin and the three buttons, plus VBUS. Waking is a full chip reset: +tach pin and the three buttons, plus VBUS. The tach's SENSE polarity is +not hardcoded: the pickup circuit's output stage idles high or low +depending on the build, so shutdown samples the parked line and arms +SENSE for the opposite level (majority vote in the host-tested +`wake_cause` unit) — arming toward the idle level satisfied DETECT +immediately and battery sleep reboot-looped. Waking is a full chip reset: `setup()` runs fresh, and the very first thing it does is read (then clear) the sticky `RESETREAS` + GPIO `LATCH` registers to decode *why* it booted (the host-tested `wake_cause` unit). An engine-start (tach) wake diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 4c60c4d..3865458 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -1,1788 +1,2289 @@ -/////////////////////////////////////////// -// DovesDataLogger - BirdsEye Main Sketch -// -// This is the main sketch file containing global state, includes, -// setup(), and loop(). All function implementations are split into -// separate module files (Arduino concatenates .ino files automatically): -// -// accelerometer.ino - LSM6DS3 IMU accelerometer reads (g-force) -// bluetooth.ino - BLE file transfer service -// 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 -// replay.ino - Session replay system -// sd_functions.ino - SD card setup, track parsing, access management -// settings.ino - Persistent JSON settings on SD (/SETTINGS.json) -// tachometer.ino - Tachometer ISR and loop processing -// -/////////////////////////////////////////// - -#include -#include -#include // SENSE-wake pin config for System OFF shutdown -#include - -// #define SIM -// #define HAS_DEBUG - -// Hides a couple pages and changes some behavior -// todo: make dynamic in next UI version -// #define ENDURANCE_MODE - -// Project-wide types and macros - MUST be included before Arduino -// auto-generates function prototypes from the other .ino files, -// otherwise custom types (ButtonState, TrackLayout, etc.) won't -// be resolved in function signatures. -#include "project.h" - -// SparkFun GPS library must be included here (in the top include block) -// so that UBX_NAV_PVT_data_t is in scope when Arduino auto-generates -// function prototypes for the onPVTReceived() callback. -#include -#include "gps_config.h" -#include -#include - -// SdFat configuration. SD_FAT_TYPE must be defined BEFORE SdFat.h is -// processed for the first time, which means before any module header -// that pulls it in (e.g. replay.h). -// 0 = bare SdFat/File (SIM only) -// 1 = SdFat32/File32 (real hardware — FAT16/FAT32) -// 2 = SdExFat/ExFile -// 3 = SdFs/FsFile -#ifdef SIM -#define SD_FAT_TYPE 0 -#define PIN_SPI_CS -1 -#else -#define SD_FAT_TYPE 1 -#define PIN_SPI_CS -1 // CS is grounded on the gry-box revision -#endif -// 2 MHz SPI for EMI tolerance in ignition environments — 12.5x below -// the SdFat default (25 MHz) but still fast enough for 25 Hz logging -// and the BLE-2M file transfer ceiling. -#define SPI_SPEED SD_SCK_MHZ(2) - -// Parked-transfer SPI clock. File transfers (BLE / USB mass storage) only -// happen with the motor off, so the ignition-EMI rationale for the slow 2 MHz -// clock doesn't apply — sdSetTransferSpeed(true) bumps to this for the session -// and reverts to SPI_SPEED afterward. Bump to SD_SCK_MHZ(16) if the board -// proves it can sustain it (the nRF52840 standard SPIM may clamp 16 to 8 MHz). -#define SD_SPI_SPEED_FAST SD_SCK_MHZ(8) - -#include "SdFat.h" -#include "sdios.h" - -// TinyUSB — provides Adafruit_USBD_MSC / TinyUSBDevice for the USB -// mass-storage transfer mode (usb_msc module). Must precede usb_msc.h. -#include - -// Module interfaces. Each header documents its module's public -// surface and pulls in any library types those signatures need. -#include "accelerometer.h" -#include "bluetooth.h" -#include "camera_ble.h" -#include "display_pages.h" -#include "display_ui.h" -#include "dovex_header.h" -#include "gps_functions.h" -#include "gps_status_page.h" -#include "haversine.h" -#include "replay.h" -#include "sat_bars.h" -#include "sd_format_page.h" -#include "sd_functions.h" -#include "sensoregg.h" -#include "settings.h" -#include "tachometer.h" -#include "usb_msc.h" -#include "wake_cause.h" - -/////////////////////////////////////////// -// BATTERY CONFIGURATION -/////////////////////////////////////////// - -// designed for seeed NRF52840 which comes with a charge circut -#define VREF 3.6 -#define ADC_MAX 4096 - -unsigned long lastBatteryCheck; -int batteryUpdateInterval = 5000; -float lastBatteryVoltage; - -float getBatteryVoltage() { - #ifdef SIM - return 3.75; - #else - unsigned int adcCount = analogRead(PIN_VBAT); - float adcVoltage = adcCount * VREF / ADC_MAX; - // Nominal divider: (1000+510)/510 = 2.9608, but reads ~2% low due to - // resistor/VREF tolerances (4.11V observed at true 4.20V full charge). - // Calibrated: 2.9608 * (4.20/4.11) = 3.024 - return adcVoltage * 3.024; - #endif -} - -int getBatteryPercent(float voltage) { - // LiPo range: 3.3V (cutoff) to 4.2V (full charge) - return constrain((int)((voltage - 3.3) / 0.9 * 100), 0, 100); -} - -/////////////////////////////////////////// -// LAP TIMER / SESSION STATE -/////////////////////////////////////////// - -double crossingThresholdMeters = 7.0; -unsigned long gpsFrameStartTime; -unsigned long gpsFrameEndTime; -unsigned long gpsFrameCounter; -float gpsFrameRate = 0.0; - -CourseManager* courseManager = nullptr; -TrackConfig activeTrackConfig; -bool trackDetected = false; -int detectedTrackIndex = -1; -unsigned long idleStartTime = 0; -bool idleTimerRunning = false; -bool raceActive = false; -unsigned long raceSessionStartedAt = 0; // For auto-idle grace period after RPM wake - -// Runtime settings (loaded from SD in setup) -float settingLapDetectionDistance = 7.0; -float settingWaypointDetectionDistance = 30.0; -float settingWaypointSpeed = 30.0; -char settingDriverName[32] = "Driver"; -char settingDeviceName[32] = "BirdsEye"; - -// Track manifest for proximity detection -TrackManifestEntry trackManifest[MAX_LOCATIONS]; -int trackManifestCount = 0; - -// DOVEX replay globals (populated by parseDovexHeader in replay.ino) -char dovexReplayDatetime[24]; -char dovexReplayDriver[32]; -char dovexReplayCourseName[32]; -char dovexReplayShortName[16]; -char dovexReplayBestLap[16]; -char dovexReplayOptimal[16]; - -// Main-menu idle tracking (drives auto-shutdown and the USB charge-mode -// entry — see the trigger block at the end of loop()). -unsigned long menuIdleStartTime = 0; -bool menuIdleTimerRunning = false; - -// Button hold tracking (for long-press combos) -unsigned long btn1HoldStart = 0; -unsigned long btn2HoldStart = 0; -unsigned long btn3HoldStart = 0; -bool btn1Held = false; -bool btn2Held = false; -bool btn3Held = false; - -/////////////////////////////////////////// -// PROJECT DEFINES -/////////////////////////////////////////// - -#define SD_CARD_LOGGING_ENABLED -// MAX_LOCATIONS, MAX_LOCATION_LENGTH, MAX_LAYOUTS, MAX_LAYOUT_LENGTH -// are now defined in project.h for use by project-wide structs -#define FILEPATH_MAX 50 // "/TRACKS/" (8) + name (13) + ".json" (5) + null = 27, using 50 for safety -#include - -/////////////////////////////////////////// -// BUTTON CONFIGURATION -// -// HARDWARE EMI RECOMMENDATIONS FOR BUTTONS: -// Phantom button presses can occur from EMI coupling, especially from -// the tachometer signal. To improve button reliability: -// -// 1. RC FILTER: Add 10K resistor + 100nF cap from each button pin to GND -// This creates a ~160Hz low-pass filter that eliminates high-freq noise -// 2. WIRE ROUTING: Keep button wires away from tach/ignition wiring -// 3. SHIELDING: If buttons are on a ribbon cable, add ground wire between signals -// 4. FERRITE: Add ferrite bead on button cable near MCU for extra HF rejection -// -// The software debouncing below uses multi-sample verification to reject -// transient noise spikes that get through hardware filtering. -/////////////////////////////////////////// - -// ButtonState, TrackLayout structs defined in project.h -// debug/debugln macros defined in project.h - -/////////////////////////////////////////// -// BLUETOOTH (BLE) GLOBALS -/////////////////////////////////////////// -#include - -// BLE Service & Characteristics -BLEService fileService = BLEService(0x1820); -BLECharacteristic fileListChar = BLECharacteristic(0x2A3D); -BLECharacteristic fileRequestChar = BLECharacteristic(0x2A3E); -BLECharacteristic fileDataChar = BLECharacteristic(0x2A3F); -BLECharacteristic fileStatusChar = BLECharacteristic(0x2A40); - -// OTA + version reporting services (set up in BLE_SETUP()): -// bledfu - buttonless Secure DFU; a write reboots the board into the -// bootloader's OTA mode so a companion can flash new firmware. -// bledis - Device Information Service; publishes FIRMWARE_VERSION so the -// companion can tell whether an update is available. -BLEDfu bledfu; -BLEDis bledis; - -// BLE state variables -bool bleInitialized = false; -bool bleActive = false; -bool bleConnected = false; -// Which subsystem owns the BLE radio (advert set + peripheral slot): -// transfer service vs camera remote. Transitions only on the main loop — -// see the ownership model in bluetooth.h. volatile: read from Bluefruit -// task callbacks for routing decisions. -volatile BleOwner bleOwner = BLE_OWNER_NONE; -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 - -/////////////////////////////////////////// -// TACHOMETER CONFIGURATION -// -// HARDWARE EMI RECOMMENDATIONS: -// The tach input (D0) picks up inductive kickback from ignition systems. -// To reduce phantom readings and noise coupling to other GPIO (buttons): -// -// 1. SHIELDING: Run tach signal wire in shielded cable, ground shield at MCU end only -// 2. FILTERING: Add RC low-pass filter at input: 1K resistor + 100nF cap to GND -// This creates ~1.6kHz cutoff, plenty fast for 20,000 RPM (333Hz) -// 3. CLAMPING: Add TVS diode or zener (5.1V) from D0 to GND for spike protection -// 4. SEPARATION: Keep tach wiring physically away from button wires -// 5. PULL-DOWN: Ensure 10K pull-down on D0 to prevent floating when no signal -// -// Signal characteristics: Magneto/CDI typically produces sharp negative-going -// pulses with significant ringing. The debounce timing below filters this. -/////////////////////////////////////////// - -const int tachInputPin = D0; -volatile int tachLastReported = 0; // Volatile: written by TACH_LOOP, read by display/logging/sleep -int topTachReported = 0; - -// Debounce timing: ignore pulses faster than this (filters ignition ringing) -// 3000us = 3ms minimum gap, allows up to 20,000 RPM max (333Hz) -static const uint32_t tachMinPulseGapUs = 3000; -volatile uint32_t tachLastPulseUs = 0; - -// Ring buffer: ISR writes pulse timestamps, TACH_LOOP reads and computes periods. -// Single-producer (ISR writes head), single-consumer (TACH_LOOP writes tail). -// The ISR checks full before publishing (one slot sacrificed so head==tail -// means empty) and drops + flags instead of lapping the consumer: SD GC -// stalls can block the main loop for 100 ms–2 s, far past what any sane -// ring size covers at racing RPM. tachRingTail is volatile because the ISR -// reads it for the full check. -static const uint8_t TACH_RING_SIZE = 16; -volatile uint32_t tachRingBuf[TACH_RING_SIZE]; -volatile uint8_t tachRingHead = 0; // ISR write index (only ISR writes) -volatile uint8_t tachRingTail = 0; // Main-loop read index (only TACH_LOOP writes) -volatile bool tachRingOverflow = false; // ISR sets on drop; TACH_LOOP clears - -// Tunable constants -static const float tachRevsPerPulse = 1.0f; // Wasted spark = 1 pulse/rev -static const uint32_t tachStopTimeoutUs = 500000; // 500ms = engine stopped - -/////////////////////////////////////////// -// ACCELEROMETER GLOBALS -/////////////////////////////////////////// -#include -LSM6DS3 accelIMU(I2C_MODE, 0x6A); -bool accelAvailable = false; -float accelX = 0.0f; -float accelY = 0.0f; -float accelZ = 0.0f; - -/////////////////////////////////////////// -// GPS GLOBALS -/////////////////////////////////////////// -SFE_UBLOX_GNSS_SERIAL myGNSS; -bool gpsInitialized = false; // Safety flag - true only after successful GPS init - -// Cached PVT data — updated by onPVTReceived() callback from checkCallbacks() -// lat/lng stay double: 1e-7 deg resolution over ±180° needs ~2^31 steps, -// beyond float's 24-bit mantissa. Everything else is float — well within -// 7 significant digits, and double math is SOFTWARE-emulated on the -// M4F's single-precision FPU (consumers that take double promote fine). -struct GpsData { - double latitudeDegrees; - double longitudeDegrees; - float altitude; // meters - float speed; // knots (for DovesLapTimer compatibility) - float HDOP; - float heading; // degrees (0-360), heading of motion - float horizontalAccuracy; // meters, horizontal accuracy estimate - int satellites; - bool fix; - bool timeValid; // true only when the module reports validDate+validTime+fullyResolved - uint16_t year; // 2-digit (e.g. 25 for 2025) for compat with existing code - uint8_t month; - uint8_t day; - uint8_t hour; - uint8_t minute; - uint8_t seconds; - uint16_t milliseconds; -} gpsData = {}; - -volatile bool gpsDataFresh = false; // Set by PVT callback, cleared by GPS_LOOP() - -// GPS nav-rate target: the rate GPS_RECONFIGURE() (and every wake/recovery -// path that calls it) re-asserts. Boot starts in status mode (5 Hz + -// NAV-SAT for the GPS status page); gpsEnterRaceMode() moves it to 25 Hz -// PVT-only when the page exits. Owned by gps_functions.ino. -uint8_t gpsNavRateTarget = GPS_NAV_RATE_STATUS_HZ; -bool gpsNavSatWanted = true; - -// Why this boot happened — decoded from RESETREAS + GPIO LATCH first thing -// in setup(). Routes the GPS status page's exit (tach wake -> race mode) -// and the USB-wake charging shortcut once sleep is a full System OFF. -wake_cause::Cause bootWakeCause = wake_cause::Cause::kColdBoot; - -// GPS status boot page hold/auto-close state (host-tested pure unit). -gps_status_page::State gpsStatusState; - -// Per-satellite CNO snapshot for the GPS status page's signal bars. -// Written by onNAVSATReceived() (main-loop context via checkCallbacks()), -// read by displayPage_gps_status(). Selection/ordering rules live in the -// host-tested sat_bars unit. -uint8_t gpsSatCnos[sat_bars::kMaxSats]; -uint8_t gpsSatCnoCount = 0; // entries in gpsSatCnos (display-capped) -uint8_t gpsSatUsedCount = 0; // satellites participating in the nav solution -uint8_t gpsSatTrackedCount = 0; // satellites tracked with a measurable signal - // (CNO > 0) — the bars' population, NOT capped. - // Note NAV-PVT's numSV is used-in-solution too, - // so it can't serve as the "in view" figure. - -// GPS PVT-arrival validation: tracks whether GPS is producing data after -// GPS_SETUP() / GPS_WAKE(). Both set gpsWakeTime and clear gpsWakeValidated; -// GPS_LOOP() sets gpsWakeValidated=true on first PVT arrival. If 5 seconds -// pass without PVT, GPS_LOOP() triggers baud recovery and reconfiguration — -// this is how a module that silently lost its config (V_BCKP drop, full -// power cycle) gets caught even when the begin() probe succeeded. -unsigned long gpsWakeTime = 0; -bool gpsWakeValidated = true; // Armed (set false) by GPS_SETUP at boot - -float gps_speed_mph = 0.0; - -// GPS-lock hold: when a race session is running with the engine turning but -// the GPS has no valid time/position lock yet, we cannot name or open the -// log file (doing so produced garbage-dated files that corrupted on reboot). -// Instead of faulting, we pin the user to the tachometer page and keep -// waiting. Cleared automatically once the log file is created (lock acquired). -bool gpsLockHoldActive = false; - -/////////////////////////////////////////// -// LAP HISTORY -/////////////////////////////////////////// -const int lapHistoryMaxLaps = 1000; -unsigned long lastLap = 0; -unsigned long lapHistory[lapHistoryMaxLaps]; -int lapHistoryCount = 0; - -void checkForNewLapData() { - // Read from active timer (CourseManager owns either the course timer - // or the Lap Anything waypoint timer). - unsigned long activeLapTime = 0; - if (courseManager != nullptr) { - if (courseManager->isLapAnythingActive()) { - activeLapTime = courseManager->getLapAnythingTimer()->getLastLapTime(); - } else if (courseManager->getActiveTimer() != nullptr) { - activeLapTime = courseManager->getActiveTimer()->getLastLapTime(); - } - } - if (lapHistoryCount < lapHistoryMaxLaps && activeLapTime != 0 && activeLapTime != lastLap) { - lastLap = activeLapTime; - lapHistory[lapHistoryCount] = lastLap; - lapHistoryCount++; - debugln(F("New lap added to history...")); - } -} - -/////////////////////////////////////////// -// REPLAY SYSTEM GLOBALS -/////////////////////////////////////////// - -// Replay file list - reduced sizes for memory constraints -#define MAX_REPLAY_FILES 20 -#define MAX_REPLAY_FILENAME_LENGTH 48 -char replayFiles[MAX_REPLAY_FILES][MAX_REPLAY_FILENAME_LENGTH]; -int numReplayFiles = 0; -int selectedReplayFile = -1; - -// Replay state (DOVEX instant-replay; populated by parseDovexHeader) -bool replayProcessingComplete = false; - -/////////////////////////////////////////// -// SD CARD GLOBALS -// SD_FAT_TYPE / PIN_SPI_CS / SPI_SPEED and the SdFat.h include moved -// to the top of this file so module headers (replay.h) see them. -/////////////////////////////////////////// - -SdFat SD; -File file; //buffer -File trackDir; -File trackFile; -File dataFile; -File replayFile; - -/////////////////////////////////////////// -// SD CARD ACCESS STATE MANAGEMENT -// Prevents race conditions between logging, replay, and BLE file transfers. -// The SD_ACCESS_* modes come from sd_functions.h (aliases of the host-tested -// sd_access_policy constants); transitions are made atomically by -// acquireSDAccess() / releaseSDAccess() in sd_functions.ino. -/////////////////////////////////////////// -volatile int currentSDAccess = SD_ACCESS_NONE; - -// Replay function prototypes (must be after SdFat include for File type) -bool buildReplayFileList(); -bool readReplayLine(File& file, char* buffer, int bufferSize); -double haversineDistanceMiles(double lat1, double lng1, double lat2, double lng2); -void resetReplayState(); -bool parseDovexHeader(const char* filename); - -// SD state flags -bool sdSetupSuccess = false; -bool sdCardUnformatted = false; // card answers but FAT won't mount (see SD_SETUP) -bool sdTrackSuccess = false; -bool sdDataLogInitComplete = false; -bool enableLogging = false; - -// Boot format-confirm page (PAGE_SD_FORMAT): the hold-to-confirm state -// machine lives in the host-tested sd_format_page unit. displayLoop() -// only ever renders the confirm screen; the running/done screens are -// painted directly by sdPerformFormat() (which blocks the main loop). -// A failed attempt returns to the confirm page with sdFormatLastFailed -// set so the renderer can say so. -sd_format_page::State sdFormatState; -bool sdFormatLastFailed = false; - -unsigned long lastCardFlush = 0; -unsigned long lastLogCreateAttempt = 0; // Throttles log-file open retries (ms) -const char trackFolder[8] = "/TRACKS"; - -char locations[MAX_LOCATIONS][MAX_LOCATION_LENGTH]; // 13-char FAT16 name limit -int numOfLocations = 0; - -/////////////////////////////////////////// -// JSON PARSING GLOBALS -/////////////////////////////////////////// -#include -// 4 KB handles tracks with up to 10 courses with full sector data. -// The sim uses the same size: a smaller buffer silently truncated real -// track files (the old Wokwi target's RAM constraint doesn't apply). -#define JSON_BUFFER_SIZE 4096 - -// extern matches the forward declaration in sd_functions.h so the -// constants have external linkage; otherwise their default internal -// linkage would mismatch the header. -extern const int PARSE_STATUS_GOOD = 0; -extern const int PARSE_STATUS_LOAD_FAILED = 5; -extern const int PARSE_STATUS_PARSE_FAILED = 10; - -char tracks[MAX_LAYOUTS][MAX_LAYOUT_LENGTH]; -TrackLayout trackLayouts[MAX_LAYOUTS]; -int numOfTracks = 0; - -// Track metadata (parsed from new JSON object format) -TrackMetadata activeTrackMetadata; - -// trackManifest is declared with the session-state globals above - -/////////////////////////////////////////// -// BLE FILE HANDLE (after SdFat include) -/////////////////////////////////////////// -File32 bleCurrentFile; - -/////////////////////////////////////////// -// BUTTON GLOBALS -/////////////////////////////////////////// -ButtonState button1; -ButtonState *btn1 = &button1; -ButtonState button2; -ButtonState *btn2 = &button2; -ButtonState button3; -ButtonState *btn3 = &button3; - -float epsilonPrecision = 0.001; - -// Debounce settings - tuned for EMI rejection while maintaining responsiveness -// 200ms allows ~5 presses/sec which is plenty fast for menu navigation -// Edge detection ensures button must be released before registering again -int buttonPressIntv = 500; -int buttonHoldIntv = 1000; -int antiBounceIntv = 200; -const int BUTTON_SAMPLE_COUNT = 3; // Number of samples to take -const int BUTTON_SAMPLE_DELAY_US = 500; // Microseconds between samples - -bool recentlyChanged = false; - -/////////////////////////////////////////// -// DISPLAY GLOBALS -/////////////////////////////////////////// - -// uses adafruit display libraries -#include - -#ifdef SIM -// #define USE_1306_DISPLAY // remove to use SH110X oled -#endif -// #define USE_1306_DISPLAY // remove to use SH110X oled - -#include "images.h" -#include "display_config.h" -int displayUpdateRateHz = 3; -unsigned long displayLastUpdate; - -// Page constants -const int PAGE_BOOT = 999; -const int PAGE_TEST = 995; -const int PAGE_RC_ERROR = 990; -// GPS status boot page — every boot lands here after the splash. Outside -// the ENDURANCE_MODE-reshuffled 3-12 running block and not arrow-navigable. -const int PAGE_GPS_STATUS = 900; - -// main menu (shown after boot) -const int PAGE_MAIN_MENU = -1; -const int PAGE_BLUETOOTH = -2; -const int PAGE_REPLAY_FILE_SELECT = -3; -const int PAGE_TRANSFER_MENU = -4; // Bluetooth-vs-USB submenu -const int PAGE_USB_STORAGE = -5; // USB mass-storage active screen -const int PAGE_PAIR_CAMERA = -6; // Insta360 pairing / paired-status screen -const int PAGE_CAMERA_SERIAL_ENTRY = -7; // manual 6-char camera serial entry -const int PAGE_REPLAY_RESULTS = -8; -const int PAGE_REPLAY_EXIT = -9; -const int PAGE_CAMERA_TEST = -10; // bench test menu (paired camera controls) - -// running menu (these must be in order) -const int GPS_DEBUG = 3; -const int GPS_STATS = 4; - -#ifdef ENDURANCE_MODE - const int GPS_SPEED = 5; - const int GPS_LAP_TIME = 6; - const int GPS_LAP_PACE = 7; - const int GPS_LAP_BEST = 8; - const int LOGGING_STOP = 9; - - const int GPS_LAP_LIST = 1002; -#else - const int GPS_SPEED = 5; - const int TACHOMETER = 6; - // The Temp1 page only exists when the SensorEgg POC is compiled in - // (BIRDSEYE_ENABLE_SENSOREGG, see project.h) — otherwise the running - // block closes up behind the tachometer rather than leaving a dead page - // in the rotation. Same reshuffle idea as ENDURANCE_MODE above. - #if BIRDSEYE_ENABLE_SENSOREGG - const int SENSOR_TEMP = 7; // SensorEgg wireless EGT (Temp1) - const int GPS_LAP_TIME = 8; - const int GPS_LAP_PACE = 9; - const int GPS_LAP_BEST = 10; - const int OPTIMAL_LAP = 11; - const int GPS_LAP_LIST = 12; - const int LOGGING_STOP = 13; - #else - const int GPS_LAP_TIME = 7; - const int GPS_LAP_PACE = 8; - const int GPS_LAP_BEST = 9; - const int OPTIMAL_LAP = 10; - const int GPS_LAP_LIST = 11; - const int LOGGING_STOP = 12; - #endif -#endif - -// end menu -const int LOGGING_STOP_CONFIRM = 90; -const int PAGE_INTERNAL_WARNING = 100; -const int PAGE_INTERNAL_FAULT = 105; -// Boot page when the SD card responds but has no mountable FAT volume -// (soldered-in module: factory-blank or corrupted). Unlike FAULT, its -// buttons stay live — driven by sdFormatPageLoop(). -const int PAGE_SD_FORMAT = 106; - -int currentPage = PAGE_BOOT; -int lastPage = 0; - -// "pageStart" defines where the UI starts, you cannot backup beyond this -#ifdef ENDURANCE_MODE - const int runningPageStart = GPS_SPEED; -#else - const int runningPageStart = GPS_DEBUG; // debug page carries the GPS pipeline counters -#endif - -int runningPageEnd = LOGGING_STOP; // only changes if sd:/tracks not found - -// Display state -int menuSelectionIndex = 0; -// Manual camera-serial entry state (PAGE_CAMERA_SERIAL_ENTRY): edited by the -// button handler in display_ui.ino, rendered by display_pages.ino. Cursor -// 0-5 = characters, 6 = OK, 7 = CANCEL. Reset when the page is entered. -char cameraSerialEntryBuf[7] = "AAAAAA"; -int cameraSerialEntryCursor = 0; -bool paceFlashStatus = false; -bool notificationFlash = false; -char internalNotification[64] = "N/A"; -bool calculatingFlip = false; -const int lapsPerPage = 3; -int current_lap_list_page = 0; -int lap_list_pages = 1; - -/////////////////////////////////////////// -// WATCHDOG TIMER -// nRF52840 hardware WDT - recovers from any lockup within ~4 seconds. -// Primary defense against I2C bus hangs, SD card stalls, etc. -/////////////////////////////////////////// - -void wdtSetup() { - NRF_WDT->CONFIG = WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos; // Keep running in sleep - NRF_WDT->CRV = 4 * 32768; // ~4 second timeout (32768 Hz clock) - NRF_WDT->RREN = WDT_RREN_RR0_Enabled << WDT_RREN_RR0_Pos; // Enable reload register 0 - NRF_WDT->TASKS_START = 1; // Start WDT (cannot be stopped once started) -} - -void wdtPet() { - NRF_WDT->RR[0] = WDT_RR_RR_Reload; // Feed the watchdog -} - -/////////////////////////////////////////// -// BOOT WAKE CAUSE -/////////////////////////////////////////// - -// Bit mask for an Arduino pin on the given GPIO port (0/1), or 0 if the -// pin lives on the other port. Uses the board variant's pin map so raw -// P-numbers never get hardcoded. -static uint32_t pinPortMask(uint32_t arduinoPin, int port) { - const uint32_t p = g_ADigitalPinMap[arduinoPin]; - if ((int)(p >> 5) != port) return 0; - return 1u << (p & 31); -} - -// Per-port masks of the System OFF wake pins for the wake_cause decoder. -// The button pin literals mirror setupButtons() in display_ui.ino (kept in -// sync by hand) — buttons aren't assigned to the ButtonState structs until -// displaySetup(), which runs after the boot decode needs these. -static wake_cause::PinMasks shutdownWakePinMasks() { - #ifndef SIM - const uint32_t buttonPins[3] = {1, 2, 3}; - #else - const uint32_t buttonPins[3] = {4, 5, 6}; - #endif - wake_cause::PinMasks m = {}; - m.tach0 = pinPortMask(tachInputPin, 0); - m.tach1 = pinPortMask(tachInputPin, 1); - for (int i = 0; i < 3; i++) { - m.buttons0 |= pinPortMask(buttonPins[i], 0); - m.buttons1 |= pinPortMask(buttonPins[i], 1); - } - return m; -} - -// Read (then clear) the sticky boot registers and decode why we booted. -// Must run before anything else touches them; RESETREAS is cumulative and -// LATCH survives System OFF, so stale bits would corrupt the next decode. -// The SoftDevice is never enabled this early (BLE init is lazy), so raw -// register access is safe. -static void captureBootWakeCause() { - wake_cause::Regs regs; - regs.resetreas = NRF_POWER->RESETREAS; - regs.latch0 = NRF_P0->LATCH; - regs.latch1 = NRF_P1->LATCH; - NRF_POWER->RESETREAS = 0xFFFFFFFF; // write-1-to-clear - NRF_P0->LATCH = 0xFFFFFFFF; - NRF_P1->LATCH = 0xFFFFFFFF; - bootWakeCause = wake_cause::decode(regs, shutdownWakePinMasks()); -} - -/////////////////////////////////////////// -// SETUP -/////////////////////////////////////////// - -void setup() { - captureBootWakeCause(); - -#ifdef HAS_DEBUG - Serial.begin(9600); - while (!Serial); -#endif - - #ifndef SIM - analogReadResolution(ADC_RESOLUTION); - pinMode(PIN_VBAT, INPUT); - pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, LOW); - #if BIRDSEYE_ENABLE_ONBOARD_CHARGING - // Enable fast charging (~100mA vs default ~50mA) - // PIN_CHARGING_CURRENT = P0.13 = HICHG pin on BQ25100 charge IC - pinMode(PIN_CHARGING_CURRENT, OUTPUT); - digitalWrite(PIN_CHARGING_CURRENT, HIGH); - #else - // Onboard charging disabled (default): leave HICHG alone entirely. - // The pin stays an input, the BQ25100 runs at its ~50 mA default, and - // an external charging circuit owns the battery. See project.h. - #endif - lastBatteryCheck = millis(); - lastBatteryVoltage = getBatteryVoltage(); - #endif - - displaySetup(); - - // setup sd card and confirm we can read track list - sdSetupSuccess = SD_SETUP(); - sdTrackSuccess = buildTrackList(); - if(sdSetupSuccess && sdTrackSuccess) { - debugln(F("Obtained Track List")); - for (int i = 0; i < numOfLocations; i++) { - char filepath[FILEPATH_MAX]; - makeFullTrackPath(locations[i], filepath); - debugln(filepath); - } - } - - // Load settings from SD (creates defaults on first boot) - SETTINGS_SETUP(); - - // Register the USB mass-storage callbacks (no drive presented until the - // user enters USB transfer mode). Needs a working SD card for block I/O. - if (sdSetupSuccess) { - USB_MSC_SETUP(); - } - - ACCEL_SETUP(); - - GPS_SETUP(); - - // Read settings into runtime variables - { - char buf[48]; - if (getSetting("lap_detection_distance", buf, sizeof(buf))) { - settingLapDetectionDistance = atof(buf); - if (settingLapDetectionDistance <= 0) settingLapDetectionDistance = 7.0; - } - if (getSetting("waypoint_detection_distance", buf, sizeof(buf))) { - settingWaypointDetectionDistance = atof(buf); - if (settingWaypointDetectionDistance <= 0) settingWaypointDetectionDistance = 30.0; - } - if (getSetting("waypoint_speed", buf, sizeof(buf))) { - settingWaypointSpeed = atof(buf); - if (settingWaypointSpeed <= 0) settingWaypointSpeed = 30.0; - } - if (getSetting("driver_name", buf, sizeof(buf))) { - strncpy(settingDriverName, buf, sizeof(settingDriverName) - 1); - settingDriverName[sizeof(settingDriverName) - 1] = '\0'; - } - if (getSetting("device_name", buf, sizeof(buf))) { - strncpy(settingDeviceName, buf, sizeof(settingDeviceName) - 1); - settingDeviceName[sizeof(settingDeviceName) - 1] = '\0'; - } - crossingThresholdMeters = settingLapDetectionDistance; - debug(F("Settings loaded: lap_dist=")); - debug(settingLapDetectionDistance); - debug(F(" wp_dist=")); - debug(settingWaypointDetectionDistance); - debug(F(" wp_speed=")); - debug(settingWaypointSpeed); - debug(F(" driver=")); - debug(settingDriverName); - debug(F(" device=")); - debugln(settingDeviceName); - } - - // Camera auto-record: load the persisted Insta360 serial + init the FSM - CAMERA_SETUP(); - - // SensorEgg wireless EGT: bring the BLE core up and start the passive - // scanner (after CAMERA_SETUP so every GATT service is registered by - // bleCoreEnsureInit before anything advertises). A no-op — and BLE stays - // lazy — unless BIRDSEYE_ENABLE_SENSOREGG is set (beta channel only). - SENSOREGG_SETUP(); - - if (!sdSetupSuccess && sdCardUnformatted) { - // Card answers but no FAT volume mounts: soldered-in module out of the - // factory, or a corrupted filesystem. The card can't be pulled to fix - // it on a PC, so offer the on-device format (hold Select to confirm). - // Deliberately outranks the USB-wake charging branch — a device with an - // unusable card should say so; the page's idle timeout still lands in - // enterShutdown(), whose VBUS handling enters the charging loop anyway. - sd_format_page::begin(sdFormatState, millis()); - switchToDisplayPage(PAGE_SD_FORMAT); - } else if (!sdSetupSuccess) { - strncpy(internalNotification, "SD Init failed!\n\nlogging not possible!", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_FAULT); -#if BIRDSEYE_ENABLE_ONBOARD_CHARGING - } else if (bootWakeCause == wake_cause::Cause::kUsbWake) { - // Plugged in while off: VBUS woke the chip so software can hold the - // fast-charge pin. Skip the GPS status page and drop straight into - // the charging loop; a button press there resumes to the main menu - // (in which case setup() continues below), unplugging powers back off. - // - // Only worth doing when we actually manage the charge current. With - // onboard charging disabled (default) a VBUS wake boots normally — the - // cable means "host connected", and the idle timeout still lands in - // enterShutdown(), which parks on VBUS anyway. - enterShutdown(); -#endif - } else { - // A missing TRACKS folder is auto-created by buildTrackList(); a - // false here means even that failed — Lap Anything will handle it. - if (!sdTrackSuccess) { - debugln(F("No usable TRACKS folder — Lap Anything will activate")); - } - // Every boot lands on the GPS status page (MyChron-style): hold until - // a stable lock (or a button press), then continue to the menu — or - // straight into race mode when the tach woke us / the engine runs. - gps_status_page::begin(gpsStatusState, millis()); - switchToDisplayPage(PAGE_GPS_STATUS); - } - - // tachometer - pinMode(tachInputPin, INPUT_PULLUP); - attachInterrupt(digitalPinToInterrupt(tachInputPin), TACH_COUNT_PULSE, FALLING); - - // Start hardware watchdog LAST - everything above must complete before - // the 4-second timeout starts counting. If setup itself hangs, the - // device won't boot-loop because WDT hasn't started yet. - #ifndef SIM - wdtSetup(); - debugln(F("Watchdog timer started (~4s timeout)")); - #endif -} - -/////////////////////////////////////////// -// COURSE / TIMER HELPER FUNCTIONS -/////////////////////////////////////////// - -/** - * @brief Get the active timer pointer for display/lap-history reads. - * Returns whichever timer is active: course timer, lap anything, or nullptr. - */ -DovesLapTimer* getActiveTimerDLT() { - if (courseManager == nullptr) return nullptr; - if (courseManager->isLapAnythingActive()) return nullptr; - return courseManager->getActiveTimer(); -} - -WaypointLapTimer* getActiveTimerWLT() { - if (courseManager == nullptr) return nullptr; - if (courseManager->isLapAnythingActive()) return courseManager->getLapAnythingTimer(); - return nullptr; -} - -// Unified getter helpers for display pages -bool activeTimerRaceStarted() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getRaceStarted(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getRaceStarted(); - return false; -} - -bool activeTimerCrossing() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getCrossing(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getCrossing(); - return false; -} - -int activeTimerLaps() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getLaps(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getLaps(); - return 0; -} - -unsigned long activeTimerCurrentLapTime() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getCurrentLapTime(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getCurrentLapTime(); - return 0; -} - -unsigned long activeTimerLastLapTime() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getLastLapTime(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getLastLapTime(); - return 0; -} - -unsigned long activeTimerBestLapTime() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getBestLapTime(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getBestLapTime(); - return 0; -} - -int activeTimerBestLapNumber() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getBestLapNumber(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getBestLapNumber(); - return 0; -} - -float activeTimerPaceDifference() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getPaceDifference(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getPaceDifference(); - return 0.0; -} - -float activeTimerTotalDistance() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getTotalDistanceTraveled(); - WaypointLapTimer* wlt = getActiveTimerWLT(); - if (wlt) return wlt->getTotalDistanceTraveled(); - return 0.0; -} - -unsigned long activeTimerOptimalLapTime() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->getOptimalLapTime(); - return 0; -} - -bool activeTimerSectorsConfigured() { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) return dlt->areSectorLinesConfigured(); - return false; -} - -/** - * @brief Scan track manifest for closest match to current GPS position - * Creates CourseManager when a match is found within 5 miles - */ -void trackDetectionLoop() { - if (trackDetected || !gpsData.fix || trackManifestCount == 0) return; - - // Throttle the scan to 1 Hz. Each haversineDistanceMiles() is several - // software-emulated double libm calls (the M4F FPU is single-precision - // only); at the 200-entry manifest ceiling a full scan costs multiple - // milliseconds. gpsData.fix stays true BETWEEN PVT updates, so without - // this gate the scan ran every ~250 Hz loop iteration — collapsing the - // loop rate — for an answer that changes at driving pace. - static unsigned long lastManifestScan = 0; - if (millis() - lastManifestScan < 1000) return; - lastManifestScan = millis(); - - double bestDist = 999999.0; - int bestIndex = -1; - - for (int i = 0; i < trackManifestCount; i++) { - double dist = haversineDistanceMiles( - gpsData.latitudeDegrees, gpsData.longitudeDegrees, - trackManifest[i].lat, trackManifest[i].lon - ); - if (dist < bestDist) { - bestDist = dist; - bestIndex = i; - } - } - - if (bestIndex >= 0 && bestDist <= TRACK_DETECT_RADIUS_MILES) { - debug(F("Track detected: ")); - debug(trackManifest[bestIndex].filename); - debug(F(" (")); - debug(bestDist, 2); - debugln(F(" miles)")); - - detectedTrackIndex = bestIndex; - - // Use manifest filename directly to build filepath — locations[] may - // truncate names longer than MAX_LOCATION_LENGTH (12 chars, old FAT16 - // 8.3 limit), causing strcmp mismatches that silently skip real tracks. - { - char filepath[FILEPATH_MAX]; - makeFullTrackPath(trackManifest[bestIndex].filename, filepath); - int parseStatus = parseTrackFile(filepath); - - if (parseStatus == PARSE_STATUS_GOOD && numOfTracks > 0) { - // Build TrackConfig from parsed data - activeTrackConfig.longName = activeTrackMetadata.longName[0] ? activeTrackMetadata.longName : trackManifest[bestIndex].filename; - activeTrackConfig.shortName = activeTrackMetadata.shortName[0] ? activeTrackMetadata.shortName : trackManifest[bestIndex].filename; - - // Populate CourseConfig entries - // Check if any course has lengthFt > 0. CourseDetector uses - // distance matching to identify which course the driver is on. - // Without lengthFt, distance can never match and detection gets - // stuck permanently — no candidates, no rejections, no fallback. - bool anyHasLength = false; - activeTrackConfig.courseCount = numOfTracks; - for (int i = 0; i < numOfTracks && i < MAX_COURSES; i++) { - activeTrackConfig.courses[i].name = tracks[i]; - activeTrackConfig.courses[i].lengthFt = activeTrackMetadata.courseLengthFt[i]; - activeTrackConfig.courses[i].startALat = trackLayouts[i].start_a_lat; - activeTrackConfig.courses[i].startALng = trackLayouts[i].start_a_lng; - activeTrackConfig.courses[i].startBLat = trackLayouts[i].start_b_lat; - activeTrackConfig.courses[i].startBLng = trackLayouts[i].start_b_lng; - activeTrackConfig.courses[i].sector2ALat = trackLayouts[i].sector_2_a_lat; - activeTrackConfig.courses[i].sector2ALng = trackLayouts[i].sector_2_a_lng; - activeTrackConfig.courses[i].sector2BLat = trackLayouts[i].sector_2_b_lat; - activeTrackConfig.courses[i].sector2BLng = trackLayouts[i].sector_2_b_lng; - activeTrackConfig.courses[i].sector3ALat = trackLayouts[i].sector_3_a_lat; - activeTrackConfig.courses[i].sector3ALng = trackLayouts[i].sector_3_a_lng; - activeTrackConfig.courses[i].sector3BLat = trackLayouts[i].sector_3_b_lat; - activeTrackConfig.courses[i].sector3BLng = trackLayouts[i].sector_3_b_lng; - activeTrackConfig.courses[i].hasSector2 = trackLayouts[i].hasSector2; - activeTrackConfig.courses[i].hasSector3 = trackLayouts[i].hasSector3; - if (activeTrackMetadata.courseLengthFt[i] > 0) anyHasLength = true; - } - - // If no courses have lengthFt, CourseDetector cannot match by - // distance and will be stuck forever. Force courseCount=0 so - // CourseManager activates Lap Anything immediately. Track name - // metadata is preserved so the display still shows which track. - if (!anyHasLength) { - debugln(F("WARNING: No courses have lengthFt — forcing Lap Anything")); - activeTrackConfig.courseCount = 0; - } - - // Create CourseManager (delete existing Lap Anything one if RPM-wake created it) - if (courseManager != nullptr) { - delete courseManager; - courseManager = nullptr; - } - courseManager = new CourseManager(activeTrackConfig, crossingThresholdMeters); - courseManager->setSpeedThresholdMph(settingWaypointSpeed); - courseManager->setWaypointProximityMeters(settingWaypointDetectionDistance); - courseManager->setDetectionProximityMeters(settingWaypointDetectionDistance); - - trackDetected = true; - debugln(F("CourseManager created with track data")); - } - } - - // If parsing failed or no tracks, create Lap Anything fallback - if (!trackDetected) { - createLapAnythingCourseManager(); - trackDetected = true; - } - } -} - -/** - * @brief End the current race session: write DOVEX header, close file, - * clean up CourseManager, reset state. Used by both checkAutoIdle() - * and LOGGING_STOP_CONFIRM in display_ui.ino. - */ -void endRaceSession() { - // Deliberately NO camera notification here: checkAutoIdle() ends the - // log session on speed alone (engine ignored), but the camera must - // keep recording through a stationary grid idle — its own - // stationary-AND-engine-off rule decides the recording stop. The - // camera is stopped explicitly where the user means "I'm done": - // the manual stop confirm (display_ui.ino) and shutdown entry - // (CAMERA_SLEEP() in enterShutdown()). - - // Write DOVEX metadata header into the reserved region - if (sdDataLogInitComplete && dataFile.isOpen()) { - writeDovexHeader(); - } - - // Close log file - if (dataFile.isOpen()) { - dataFile.flush(); - dataFile.close(); - } - releaseSDAccess(SD_ACCESS_LOGGING); - enableLogging = false; - sdDataLogInitComplete = false; - - // Clean up CourseManager - if (courseManager != nullptr) { - delete courseManager; - courseManager = nullptr; - } - trackDetected = false; - detectedTrackIndex = -1; - raceActive = false; - idleTimerRunning = false; - idleStartTime = 0; - - // Reset lap history - lapHistoryCount = 0; - lastLap = 0; - memset(lapHistory, 0, sizeof(lapHistory)); - topTachReported = 0; - - debugln(F("Race session ended")); -} - -/** - * @brief Create a fallback CourseManager with no courses (Lap Anything mode). - * Used when no track is detected, parsing fails, or user enters race manually. - */ -void createLapAnythingCourseManager() { - if (courseManager != nullptr) return; // Already exists - activeTrackConfig.longName = "Unknown"; - activeTrackConfig.shortName = ""; - activeTrackConfig.courseCount = 0; - courseManager = new CourseManager(activeTrackConfig, crossingThresholdMeters); - courseManager->setSpeedThresholdMph(settingWaypointSpeed); - courseManager->setWaypointProximityMeters(settingWaypointDetectionDistance); - debugln(F("CourseManager created (Lap Anything)")); -} - -/** - * @brief Check for auto-idle: 60s at <2mph ends the session - */ -void checkAutoIdle() { - if (!raceActive) return; - - // Yield to an active camera recording: while the camera is recording, IT owns - // the end (30 s engine-off -> cameraConsumeAutoStop() above), so the - // speed-based idle must not cut the log out from under it during a stationary - // but engine-running stint (grid/paddock). No camera, or not recording, keeps - // the original speed-only behavior below. - // - // EXCEPTION — GPS-lock hold: while the session is still waiting for its GPS - // time lock, no log file exists and the hold pins the UI with navigation - // disabled (displayLoop). If the camera is also recording, this yield was - // the ONLY session-ender left, so a lock that never arrives left the device - // looking bricked until a power cycle (2026-07-19 pull-start incident). - // There is no log to protect yet — let the idle timer end the fileless - // session, which releases the UI and stops the camera. - if (cameraActivelyRecording() && !gpsLockHoldActive) return; - - // Grace period: don't auto-idle within first 3 minutes of a session. - // After RPM wake the car is often stationary (warming up, waiting for - // track session) and GPS needs time to reacquire. Without this, the - // 60s idle timer kills the session before the driver even moves. - if (millis() - raceSessionStartedAt < 180000UL) return; - - if (gps_speed_mph >= 2.0) { - idleTimerRunning = false; - idleStartTime = 0; - return; - } - - if (!idleTimerRunning) { - idleTimerRunning = true; - idleStartTime = millis(); - return; - } - - if (millis() - idleStartTime >= 60000) { - debugln(F("Auto-idle: 60s at <2mph — ending session")); - endRaceSession(); - switchToDisplayPage(PAGE_MAIN_MENU); - } -} - -/** - * @brief Auto-enter race mode from main menu when driving - */ -void autoRaceModeCheck() { - if (currentPage != PAGE_MAIN_MENU) return; - if (currentPage == PAGE_BLUETOOTH || bleConnected) return; - - bool rpmTriggered = tachLastReported > 500; - bool speedTriggered = gps_speed_mph >= 10.0; - - if (rpmTriggered || speedTriggered) { - debugln(F("Auto-entering race mode")); - raceActive = true; - enableLogging = true; - raceSessionStartedAt = millis(); - - // Create a minimal CourseManager if none exists yet (no track detected) - createLapAnythingCourseManager(); - - // Show tach page if RPM triggered first, otherwise speed page - switchToDisplayPage(rpmTriggered ? TACHOMETER : GPS_SPEED); - } -} - -/** - * @brief Drive the GPS status boot page: bounded GPS re-detect, then step - * the hold/auto-close state machine (host-tested gps_status_page - * unit) and act on its exit verdict. Runs between readButtons() - * and displayLoop() so it consumes this frame's presses — the page - * has an explicit no-op branch in displayLoop()'s button handling. - */ -void gpsStatusPageLoop() { - if (currentPage != PAGE_GPS_STATUS) return; - - // GPS missing at boot? Re-probe a few times while the page is up. - GPS_STATUS_RETRY_LOOP(); - - gps_status_page::Inputs in; - in.fix = gpsData.fix; - in.timeValid = gpsData.timeValid; - in.buttonPressed = btn1->pressed || btn2->pressed || btn3->pressed; - in.tachWakeBoot = (bootWakeCause == wake_cause::Cause::kTachWake); - in.engineRunning = tachLastReported > 500; // autoRaceModeCheck threshold - in.nowMs = millis(); - - const gps_status_page::Exit verdict = gps_status_page::step(gpsStatusState, in); - if (verdict != gps_status_page::Exit::kStay) { - // The press that skipped this page must not also drive the - // destination page's button handling in displayLoop() this frame. - resetButtons(); - } - - switch (verdict) { - case gps_status_page::Exit::kToMenu: - gpsEnterRaceMode(); // 25 Hz PVT-only is the steady state off this page - switchToDisplayPage(PAGE_MAIN_MENU); - break; - case gps_status_page::Exit::kToRace: - // Engine is (or was, at wake) running — straight into race mode with - // logging, mirroring the old RPM-wake path. - gpsEnterRaceMode(); - debugln(F("GPS status page -> race mode")); - raceActive = true; - enableLogging = true; - raceSessionStartedAt = millis(); - createLapAnythingCourseManager(); - switchToDisplayPage(TACHOMETER); - break; - case gps_status_page::Exit::kToShutdown: - // Nothing locked, engine silent for the idle timeout — a spurious - // wake or a shelf queen. Power back down. - enterShutdown(); - break; - case gps_status_page::Exit::kStay: - break; - } -} - -/** - * @brief Drive the SD format-confirm boot page: step the hold-to-confirm - * state machine (host-tested sd_format_page unit) and act on its - * exit verdict. Runs between readButtons() and displayLoop(), like - * gpsStatusPageLoop() — the page has an explicit no-op branch in - * displayLoop()'s button handling. - */ -void sdFormatPageLoop() { - if (currentPage != PAGE_SD_FORMAT) return; - - sd_format_page::Inputs in; - in.selectHeld = isButtonHeld(2, 0); // live level, updated by updateButtonHoldState() - // A held side button disarms the confirm — the user is going for the - // global Select+side reboot combo (5 s), which must outrank a 3 s erase. - in.otherButtonHeld = isButtonHeld(1, 0) || isButtonHeld(3, 0); - in.otherButtonPressed = btn1->pressed || btn3->pressed; - in.engineRunning = tachLastReported > 500; // autoRaceModeCheck threshold - in.nowMs = millis(); - - const sd_format_page::Exit verdict = sd_format_page::step(sdFormatState, in); - if (verdict != sd_format_page::Exit::kStay) { - resetButtons(); - } - - switch (verdict) { - case sd_format_page::Exit::kFormat: - // Blocking. Reboots the device on success; returns to this page's - // confirm screen on failure (fresh full hold required to retry). - sdPerformFormat(); - break; - case sd_format_page::Exit::kToShutdown: - // Unformatted card and nobody home — don't drain the pack. - enterShutdown(); - break; - case sd_format_page::Exit::kStay: - break; - } -} - -/** - * @brief Maintain the GPS-lock hold state (see gpsLockHoldActive). - * - * While a race session wants to log but has no valid GPS time lock yet — so - * the log file cannot be named/created — and the engine is turning, we pin - * the user to the tachometer instead of trying to log with a garbage date. - * The hold latches on once the engine is seen running and clears the moment - * the log file is created (lock acquired) or the session ends. Logging then - * begins automatically and normal race-mode navigation resumes. - */ -void updateGpsLockHold() { - // Engine-off release window: the pin only makes sense while the engine is - // (recently) turning. Killing the motor with no lock used to leave the hold - // latched — tach page, navigation dead — until the session ended, which the - // camera's recording suppressed indefinitely (2026-07-19 field incident). - static uint32_t lastEngineActivityMs = 0; - const uint32_t kEngineOffReleaseMs = 10000; - - if (raceActive && enableLogging && !sdDataLogInitComplete) { - if (tachLastReported > 0) { - lastEngineActivityMs = millis(); - gpsLockHoldActive = true; - } else if (gpsLockHoldActive && - (uint32_t)(millis() - lastEngineActivityMs) >= kEngineOffReleaseMs) { - // Engine has been silent long enough — give the UI back. The session - // stays active (the user can end it from the END RACE page), and a - // restart re-latches the hold. - gpsLockHoldActive = false; - } - } else { - gpsLockHoldActive = false; // lock acquired (file created) or race ended - } -} - -/** - * @brief Write DOVEX header metadata into the reserved 1 KB at the - * start of the open log file. Layout + padding live in - * dovex_header::format(); this just wires up the I/O. - */ -void writeDovexHeader() { - if (!dataFile.isOpen()) return; - - char datetime[24]; - snprintf(datetime, sizeof(datetime), "20%02d-%02d-%02d %02d:%02d:%02d", - gpsData.year, gpsData.month, gpsData.day, - gpsData.hour, gpsData.minute, gpsData.seconds); - - const char* courseName = "Lap Anything"; - const char* shortName = ""; - if (courseManager != nullptr) { - const char* cn = courseManager->getActiveCourseName(); - if (cn) courseName = cn; - shortName = courseManager->getShortName(); - } - - const dovex_header::Metadata meta = { - datetime, - settingDriverName, - courseName, - shortName, - activeTimerBestLapTime(), - activeTimerOptimalLapTime(), - settingDeviceName, - }; - - static char headerBuf[dovex_header::kHeaderSize]; - dovex_header::format(headerBuf, sizeof(headerBuf), meta, - lapHistory, lapHistoryCount); - - dataFile.seekSet(0); - dataFile.write(reinterpret_cast(headerBuf), sizeof(headerBuf)); - dataFile.flush(); - debugln(F("DOVEX header written")); -} - -/////////////////////////////////////////// -// SHUTDOWN (System OFF) -// -// "Sleep" is a full power-down: tear everything down, arm GPIO SENSE on -// the tach + buttons as wake sources, and enter nRF52 System OFF (~µA). -// Wake is a chip reset — setup() runs fresh and captureBootWakeCause() -// reads why (tach pulse -> the GPS status page exits into race mode). -// -// The ONE soft exception is VBUS: while a cable is present the device -// stays alive and parks in the charging loop instead, only dropping to -// System OFF once the cable is pulled. Two reasons, and only the first is -// gated by BIRDSEYE_ENABLE_ONBOARD_CHARGING: -// 1. With onboard charging enabled, the HICHG fast-charge pin is -// software-held, so powering down would drop the charge rate. -// 2. Always: VBUS is an always-armed System OFF wake source on the -// nRF52840, so entering System OFF with the cable still in risks an -// immediate wake-reset loop. The park is a hardware constraint. -// Plugging in a dark device therefore boots it straight back into that -// loop (with charging disabled the boot is a normal one that idles its -// way back here, rather than a direct shortcut). -/////////////////////////////////////////// - -bool isUsbConnected() { - return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; -} - -// Raw multi-sampled poll until every button is released (or timeout). -// A held button at System OFF entry = SENSE already satisfied = instant -// wake-reset, so the entry combo must be released before we commit. -static void waitAllButtonsReleased(unsigned long timeoutMs) { - unsigned long start = millis(); - while (anyButtonPressed() && millis() - start < timeoutMs) { - wdtPet(); - delay(10); - } -} - -// CPU idle for the charging loop. sd_app_evt_wait() is an SVC into the -// SoftDevice and hard-faults when it isn't enabled — BLE is lazily -// initialized, so ask the SoftDevice itself (the old sleep loop called -// it unconditionally, a latent fault). -static void shutdownIdleWait() { - uint8_t sdEnabled = 0; - (void)sd_softdevice_is_enabled(&sdEnabled); - if (sdEnabled) { - sd_app_evt_wait(); - } else { - __WFE(); - } -} - -// Configure wake sources and enter System OFF. Does not return: wake is -// a full reset (RESETREAS.OFF + the pin's LATCH bit record the cause), -// and the WDT stops with every other clock — wdtSetup() re-arms on the -// fresh boot (nRF52840 PS: System OFF halts all clocks/peripherals). -static void shutdownSystemOff() { - #ifdef SIM - // Simulator has no System OFF emulation worth trusting — plain reset. - NVIC_SystemReset(); - #else - waitAllButtonsReleased(3000); - delay(50); // contact settle - wdtPet(); - - // Wake sources: SENSE-LOW with pull-up on the tach (idle-high, pulse = - // falling) and all three buttons (active-low). Pull + SENSE config is - // retained in System OFF. P-numbers via the board variant's pin map. - nrf_gpio_cfg_sense_input(g_ADigitalPinMap[tachInputPin], - NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); - nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn1->pin], - NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); - nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn2->pin], - NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); - nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn3->pin], - NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); - // VBUS wake needs no configuration on the nRF52840 — always armed. - - // A set LATCH bit is a pending DETECT = instant re-wake; clear last, - // right before entering OFF. (Boot already cleared RESETREAS, but the - // SoftDevice path below clears again to be unambiguous.) - NRF_P0->LATCH = 0xFFFFFFFF; - NRF_P1->LATCH = 0xFFFFFFFF; - - // Cortex-M4F: a pending FPU exception can inhibit low-power entry — - // clear lazily-stacked FP state before OFF (standard Nordic guidance). - __set_FPSCR(__get_FPSCR() & ~0x9F); - NVIC_ClearPendingIRQ(FPU_IRQn); - - // NRF_POWER is a restricted peripheral while the SoftDevice is enabled - // (BLE is lazy — it may or may not be up). GPREGRET is deliberately - // untouched: register 0 belongs to the OTA/bootloader handoff. - uint8_t sdEnabled = 0; - (void)sd_softdevice_is_enabled(&sdEnabled); - if (sdEnabled) { - sd_power_reset_reason_clr(0xFFFFFFFF); - (void)sd_power_system_off(); - } else { - NRF_POWER->RESETREAS = 0xFFFFFFFF; - NRF_POWER->SYSTEMOFF = 1; - } - // Only reachable in emulated System OFF (debugger attached). - while (true) { __WFE(); } - #endif -} - -// The charging loop — runs after full teardown whenever VBUS is present -// at shutdown (or woke us). Shows the charging screen for 10 s, then -// display off; ANY button press is a full wake back to the main menu -// (with onboard charging enabled the USB-on-menu trigger waits -// USB_MENU_CHARGE_IDLE_MS before pulling the device back here, so wake -// doesn't bounce; with it disabled nothing pulls the menu down early at -// all). Returns true to resume to the menu, false when the cable was -// pulled (caller enters System OFF). -static bool runChargingShutdownLoop() { - debugln(F("Charging loop (VBUS present)")); - DISPLAY_WAKE(); - waitAllButtonsReleased(2000); // shutdown entry combo may still be held - unsigned long shownAt = millis(); - if (shownAt == 0) shownAt = 1; // 0 is the display-off sentinel - - while (true) { - wdtPet(); - - if (!isUsbConnected()) { - if (shownAt != 0) DISPLAY_SLEEP(); - return false; // unplugged — fall through to System OFF - } - - if (anyButtonPressed()) { - waitAllButtonsReleased(2000); - return true; // full wake to the main menu - } - - if (shownAt != 0 && millis() - shownAt >= CHARGE_DISPLAY_TIMEOUT_MS) { - DISPLAY_SLEEP(); - shownAt = 0; - } - if (shownAt != 0 && - millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { - displayLastUpdate = millis(); - displayPage_sleep_charging(); - } - - shutdownIdleWait(); - } -} - -// Bring the subsystems back after the charging loop — the chip never -// powered down, so this is the minimal mirror of the cold-boot bring-up. -// BLE and the camera stay down (both come up lazily on demand). -static void softResumeFromCharging() { - if (accelAvailable) { - digitalWrite(PIN_LSM6DS3TR_C_POWER, LOW); - delay(50); - if (accelIMU.begin() != 0) { - debugln(F("IMU failed to reinitialize after charging")); - accelAvailable = false; - } - } - - // The menu's steady state is race config. Set the targets directly so - // GPS_WAKE()'s GPS_RECONFIGURE() applies them (a shutdown from the GPS - // status page would otherwise resume at 5 Hz with NAV-SAT on). - gpsNavRateTarget = GPS_NAV_RATE_HZ; - gpsNavSatWanted = false; - gpsSatCnoCount = 0; - gpsSatUsedCount = 0; - GPS_WAKE(); - - DISPLAY_WAKE(); - menuIdleTimerRunning = false; - if (!sdSetupSuccess && sdCardUnformatted) { - // The format page's idle timeout landed us in the charging loop; the - // card still has no FAT, so resume back to the format offer — the main - // menu is useless (and misleading) when nothing can mount. - sd_format_page::begin(sdFormatState, millis()); - switchToDisplayPage(PAGE_SD_FORMAT); - return; - } - switchToDisplayPage(PAGE_MAIN_MENU); -} - -// Full shutdown: tear down every subsystem, then System OFF — or the -// charging loop when VBUS is present. May return (charging resume); -// callers in loop() must `return` right after so the frame restarts. -void enterShutdown() { - debugln(F("Shutdown")); - - // End race session if active (safety net — may write the DOVEX header) - if (raceActive) endRaceSession(); - wdtPet(); - - // Camera power-off streams a synchronous 3 s ce82 hold — the longest - // single teardown step, bracketed by pets against the ~4 s WDT. - CAMERA_SLEEP(); - wdtPet(); - - // Stop BLE if active (advertising/connection teardown) - if (bleActive) BLE_STOP(); - - // Display off (I2C command, ~10 µA panel sleep) - DISPLAY_SLEEP(); - - // GPS to software backup mode (µA; config retained while powered) and - // TIMER3 serial-drain stopped - GPS_SLEEP(); - - // IMU rail off (HIGH = power disabled) - if (accelAvailable) { - pinMode(PIN_LSM6DS3TR_C_POWER, OUTPUT); - digitalWrite(PIN_LSM6DS3TR_C_POWER, HIGH); - } - wdtPet(); - - // VBUS exception: never System OFF while a cable is present. Powering - // down would drop the software-held HICHG charge rate (when onboard - // charging is enabled) and, either way, VBUS is an always-armed System - // OFF wake source — entering OFF with the cable in risks an immediate - // wake-reset loop. - if (isUsbConnected()) { - if (runChargingShutdownLoop()) { - softResumeFromCharging(); - return; - } - // Cable pulled during the charging loop — power down for real. - } - - shutdownSystemOff(); // does not return -} - -/////////////////////////////////////////// -// MAIN LOOP -/////////////////////////////////////////// - -#ifdef HAS_DEBUG -unsigned long loopMaxTime = 0; -#endif - -void loop() { - #ifndef SIM - wdtPet(); - #endif - - #ifdef HAS_DEBUG - unsigned long loopStart = millis(); - #endif - - // When BLE is active, skip GPS/tach/lap processing for better throughput - if (bleActive) { - BLUETOOTH_LOOP(); - - // Keep battery voltage fresh for BLE BATT command and display - if (millis() - lastBatteryCheck > batteryUpdateInterval) { - lastBatteryCheck = millis(); - lastBatteryVoltage = getBatteryVoltage(); - } - - // Minimal button check for exit - readButtons(); - if (btn2->pressed) { - BLE_STOP(); - switchToDisplayPage(PAGE_MAIN_MENU); - } - resetButtons(); - - // Reduced display rate: 5s during transfer, normal otherwise - unsigned long displayInterval = bleTransferInProgress ? 5000 : (1000 / displayUpdateRateHz); - if (millis() - displayLastUpdate > displayInterval) { - displayLastUpdate = millis(); - displayPage_bluetooth(); - } - - return; // Skip GPS, tach, lap checks while BLE is active - } - - // When USB mass-storage is active the host PC owns the SD card over MSC. - // Park the loop exactly like the BLE branch so the firmware never drives - // the FAT concurrently with the host — GPS/tach/lap/SD processing is all - // skipped. (The SD-access policy would deny those acquires anyway, but - // parking here is the real guarantee rather than a side effect.) - if (usbMscActive) { - // Cable pulled without using the on-device Exit (the natural way to - // "finish") — tear down so the SD lock, the fast SPI clock, and the - // media-ready state don't leak into a later driving session and - // silently refuse to log. USB_MSC_DISABLE() reboots and does not return. - if (!isUsbConnected()) { - USB_MSC_DISABLE(); - } - - // Minimal button check for the on-device Exit (Select). - readButtons(); - if (btn2->pressed) { - USB_MSC_DISABLE(); // does not return (NVIC_SystemReset) - } - resetButtons(); - - // Refresh the status page at the normal rate. - if (millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { - displayLastUpdate = millis(); - displayPage_usb_storage(); - } - - 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) - - // Camera auto-stopped recording (30 s engine-off): end + save the race - // session and return to the menu — the camera stays connected in WATCHING, - // ready to re-record if the engine restarts. endRaceSession() is idempotent - // and does not switch pages itself, so we do (mirrors checkAutoIdle). A - // manual logging-stop does not set this — that path already ended the log. - if (raceActive && cameraConsumeAutoStop()) { - endRaceSession(); - switchToDisplayPage(PAGE_MAIN_MENU); - } - - // Button hold detection for shutdown/reboot combos - updateButtonHoldState(); - - // Long-press left+right (5s) on main menu -> shutdown - if (currentPage == PAGE_MAIN_MENU && - isButtonHeld(1, SLEEP_LONG_PRESS_MS) && - isButtonHeld(3, SLEEP_LONG_PRESS_MS)) { - enterShutdown(); - return; - } - - // Reboot combo: select + either side button held 5s (any page) - if (isButtonHeld(2, SLEEP_LONG_PRESS_MS) && - (isButtonHeld(1, SLEEP_LONG_PRESS_MS) || isButtonHeld(3, SLEEP_LONG_PRESS_MS))) { - NVIC_SystemReset(); - } - - // Main-menu idle tracking. Consumers: - // - USB present, onboard charging ENABLED: enter the charging loop after - // USB_MENU_CHARGE_IDLE_MS of no button activity. Not immediate — a - // charging-loop button wake lands here, and an instant re-entry would - // bounce it right back. The window is what lets replay/transfer be - // used while plugged in. Compiled out when charging is disabled: the - // firmware isn't managing the charge current, so a plugged-in cable is - // no reason to cut the menu short (the plain idle timeout still fires, - // and enterShutdown() parks on VBUS as always). - // - Always: full shutdown after SLEEP_IDLE_TIMEOUT_MS. - if (currentPage == PAGE_MAIN_MENU) { - if (!menuIdleTimerRunning) { - menuIdleTimerRunning = true; - menuIdleStartTime = millis(); - } - // Button activity resets the idle clock. The `pressed` flags can't be - // used here: readButtons() sets them AFTER this check runs and - // resetButtons() clears them before the next iteration, so they always - // read false at this point (menu navigation never reset the timer and - // the device slept 5 min after menu entry regardless of activity — - // 2026-07-19 field incident). The debouncer's lastPressed stamps - // persist across iterations, so anchor on the newest of those. - unsigned long lastBtn = btn1->lastPressed; - if ((long)(btn2->lastPressed - lastBtn) > 0) lastBtn = btn2->lastPressed; - if ((long)(btn3->lastPressed - lastBtn) > 0) lastBtn = btn3->lastPressed; - if ((long)(lastBtn - menuIdleStartTime) > 0) { - menuIdleStartTime = lastBtn; - } - unsigned long idleFor = millis() - menuIdleStartTime; -#if BIRDSEYE_ENABLE_ONBOARD_CHARGING - if ((isUsbConnected() && idleFor >= USB_MENU_CHARGE_IDLE_MS) || - idleFor >= SLEEP_IDLE_TIMEOUT_MS) { -#else - if (idleFor >= SLEEP_IDLE_TIMEOUT_MS) { -#endif - enterShutdown(); - return; - } - } else { - menuIdleTimerRunning = false; - } - - calculateGPSFrameRate(); - - readButtons(); - gpsStatusPageLoop(); // boot status page: consume presses, hold/auto-close - sdFormatPageLoop(); // boot format-confirm page: hold Select 3s to format - displayLoop(); - resetButtons(); - - if (tachLastReported > topTachReported) { - topTachReported = tachLastReported; - } - - #ifdef HAS_DEBUG - unsigned long loopElapsed = millis() - loopStart; - if (loopElapsed > loopMaxTime) { - loopMaxTime = loopElapsed; - } - if (loopElapsed > 100) { - debug(F("SLOW LOOP: ")); - debug(loopElapsed); - debug(F("ms (max: ")); - debug(loopMaxTime); - debugln(F("ms)")); - } - #endif -} +/////////////////////////////////////////// +// DovesDataLogger - BirdsEye Main Sketch +// +// This is the main sketch file containing global state, includes, +// setup(), and loop(). All function implementations are split into +// separate module files (Arduino concatenates .ino files automatically): +// +// accelerometer.ino - LSM6DS3 IMU accelerometer reads (g-force) +// bluetooth.ino - BLE file transfer service +// 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 +// replay.ino - Session replay system +// sd_functions.ino - SD card setup, track parsing, access management +// settings.ino - Persistent JSON settings on SD (/SETTINGS.json) +// tachometer.ino - Tachometer ISR and loop processing +// +/////////////////////////////////////////// + +#include +#include +#include // SENSE-wake pin config for System OFF shutdown +#include + +// #define SIM +// #define HAS_DEBUG + +// Hides a couple pages and changes some behavior +// todo: make dynamic in next UI version +// #define ENDURANCE_MODE + +// Project-wide types and macros - MUST be included before Arduino +// auto-generates function prototypes from the other .ino files, +// otherwise custom types (ButtonState, TrackLayout, etc.) won't +// be resolved in function signatures. +#include "project.h" + +// SparkFun GPS library must be included here (in the top include block) +// so that UBX_NAV_PVT_data_t is in scope when Arduino auto-generates +// function prototypes for the onPVTReceived() callback. +#include +#include "gps_config.h" +#include +#include +#include + +// SdFat configuration. SD_FAT_TYPE must be defined BEFORE SdFat.h is +// processed for the first time, which means before any module header +// that pulls it in (e.g. replay.h). +// 0 = bare SdFat/File (SIM only) +// 1 = SdFat32/File32 (real hardware — FAT16/FAT32) +// 2 = SdExFat/ExFile +// 3 = SdFs/FsFile +#ifdef SIM +#define SD_FAT_TYPE 0 +#define PIN_SPI_CS -1 +#else +#define SD_FAT_TYPE 1 +#define PIN_SPI_CS -1 // CS is grounded on the gry-box revision +#endif +// 2 MHz SPI for EMI tolerance in ignition environments — 12.5x below +// the SdFat default (25 MHz) but still fast enough for 25 Hz logging +// and the BLE-2M file transfer ceiling. +#define SPI_SPEED SD_SCK_MHZ(2) + +// Parked-transfer SPI clock. File transfers (BLE / USB mass storage) only +// happen with the motor off, so the ignition-EMI rationale for the slow 2 MHz +// clock doesn't apply — sdSetTransferSpeed(true) bumps to this for the session +// and reverts to SPI_SPEED afterward. Bump to SD_SCK_MHZ(16) if the board +// proves it can sustain it (the nRF52840 standard SPIM may clamp 16 to 8 MHz). +#define SD_SPI_SPEED_FAST SD_SCK_MHZ(8) + +#include "SdFat.h" +#include "sdios.h" + +// TinyUSB — provides Adafruit_USBD_MSC / TinyUSBDevice for the USB +// mass-storage transfer mode (usb_msc module). Must precede usb_msc.h. +#include + +// Module interfaces. Each header documents its module's public +// surface and pulls in any library types those signatures need. +#include "accelerometer.h" +#include "bluetooth.h" +#include "camera_ble.h" +#include "course_creator.h" +#include "display_pages.h" +#include "display_ui.h" +#include "dovex_header.h" +#include "gps_functions.h" +#include "gps_status_page.h" +#include "haversine.h" +#include "replay.h" +#include "sat_bars.h" +#include "sd_format_page.h" +#include "sd_functions.h" +#include "sensoregg.h" +#include "settings.h" +#include "sprint_select.h" +#include "tach_filter.h" +#include "tachometer.h" +#include "usb_msc.h" +#include "wake_cause.h" + +/////////////////////////////////////////// +// BATTERY CONFIGURATION +/////////////////////////////////////////// + +// designed for seeed NRF52840 which comes with a charge circut +#define VREF 3.6 +#define ADC_MAX 4096 + +unsigned long lastBatteryCheck; +int batteryUpdateInterval = 5000; +float lastBatteryVoltage; + +float getBatteryVoltage() { + #ifdef SIM + return 3.75; + #else + unsigned int adcCount = analogRead(PIN_VBAT); + float adcVoltage = adcCount * VREF / ADC_MAX; + // Nominal divider: (1000+510)/510 = 2.9608, but reads ~2% low due to + // resistor/VREF tolerances (4.11V observed at true 4.20V full charge). + // Calibrated: 2.9608 * (4.20/4.11) = 3.024 + return adcVoltage * 3.024; + #endif +} + +int getBatteryPercent(float voltage) { + // LiPo range: 3.3V (cutoff) to 4.2V (full charge) + return constrain((int)((voltage - 3.3) / 0.9 * 100), 0, 100); +} + +/////////////////////////////////////////// +// LAP TIMER / SESSION STATE +/////////////////////////////////////////// + +double crossingThresholdMeters = 7.0; +unsigned long gpsFrameStartTime; +unsigned long gpsFrameEndTime; +unsigned long gpsFrameCounter; +float gpsFrameRate = 0.0; + +CourseManager* courseManager = nullptr; +TrackConfig activeTrackConfig; +bool trackDetected = false; +int detectedTrackIndex = -1; + +// Sprint mode (plan 0002): point-to-point runs instead of laps. The mode +// follows the detected track's folder — a non-null sprintTimer IS sprint +// mode (courseManager stays null for the session, and vice versa). +SprintTimer* sprintTimer = nullptr; +char sprintCourseName[MAX_LAYOUT_LENGTH] = ""; +int sprintLastRunCount = 0; // run-complete edge for lap history capture +unsigned long idleStartTime = 0; +bool idleTimerRunning = false; +bool raceActive = false; +unsigned long raceSessionStartedAt = 0; // For auto-idle grace period after RPM wake + +// Runtime settings (loaded from SD in setup) +float settingLapDetectionDistance = 7.0; +float settingWaypointDetectionDistance = 30.0; +float settingWaypointSpeed = 30.0; +char settingDriverName[32] = "Driver"; +char settingDeviceName[32] = "BirdsEye"; +// race_mode preference — ONLY the tiebreak when both a circuit and a +// sprint track are within detection range (sprint_select::chooseKind). +// It never overrides what is actually detected. +bool settingRaceModePrefSprint = false; + +// Track manifest for proximity detection +TrackManifestEntry trackManifest[MAX_LOCATIONS]; +int trackManifestCount = 0; + +// DOVEX replay globals (populated by parseDovexHeader in replay.ino) +char dovexReplayDatetime[24]; +char dovexReplayDriver[32]; +char dovexReplayCourseName[32]; +char dovexReplayShortName[16]; +char dovexReplayBestLap[16]; +char dovexReplayOptimal[16]; + +// Main-menu idle tracking (drives auto-shutdown and the USB charge-mode +// entry — see the trigger block at the end of loop()). +unsigned long menuIdleStartTime = 0; +bool menuIdleTimerRunning = false; + +// When the main menu was last ARRIVED at, stamped by switchToDisplayPage(). +// autoRaceModeCheck() uses it (with the button stamps) to tell "the user just +// landed here" from "the device has been sitting on the menu" — see +// AUTO_RACE_MENU_GRACE_MS. +unsigned long mainMenuEnteredAtMs = 0; + +// Button hold tracking (for long-press combos) +unsigned long btn1HoldStart = 0; +unsigned long btn2HoldStart = 0; +unsigned long btn3HoldStart = 0; +bool btn1Held = false; +bool btn2Held = false; +bool btn3Held = false; + +/////////////////////////////////////////// +// PROJECT DEFINES +/////////////////////////////////////////// + +#define SD_CARD_LOGGING_ENABLED +// MAX_LOCATIONS, MAX_LOCATION_LENGTH, MAX_LAYOUTS, MAX_LAYOUT_LENGTH +// are now defined in project.h for use by project-wide structs +#define FILEPATH_MAX 64 // "/TRACKS/SPRINT/" (15) + manifest name (31) + ".json" (5) + null = 52, using 64 for safety +#include + +/////////////////////////////////////////// +// BUTTON CONFIGURATION +// +// HARDWARE EMI RECOMMENDATIONS FOR BUTTONS: +// Phantom button presses can occur from EMI coupling, especially from +// the tachometer signal. To improve button reliability: +// +// 1. RC FILTER: Add 10K resistor + 100nF cap from each button pin to GND +// This creates a ~160Hz low-pass filter that eliminates high-freq noise +// 2. WIRE ROUTING: Keep button wires away from tach/ignition wiring +// 3. SHIELDING: If buttons are on a ribbon cable, add ground wire between signals +// 4. FERRITE: Add ferrite bead on button cable near MCU for extra HF rejection +// +// The software debouncing below uses multi-sample verification to reject +// transient noise spikes that get through hardware filtering. +/////////////////////////////////////////// + +// ButtonState, TrackLayout structs defined in project.h +// debug/debugln macros defined in project.h + +/////////////////////////////////////////// +// BLUETOOTH (BLE) GLOBALS +/////////////////////////////////////////// +#include + +// BLE Service & Characteristics +BLEService fileService = BLEService(0x1820); +BLECharacteristic fileListChar = BLECharacteristic(0x2A3D); +BLECharacteristic fileRequestChar = BLECharacteristic(0x2A3E); +BLECharacteristic fileDataChar = BLECharacteristic(0x2A3F); +BLECharacteristic fileStatusChar = BLECharacteristic(0x2A40); + +// OTA + version reporting services (set up in BLE_SETUP()): +// bledfu - buttonless Secure DFU; a write reboots the board into the +// bootloader's OTA mode so a companion can flash new firmware. +// bledis - Device Information Service; publishes FIRMWARE_VERSION so the +// companion can tell whether an update is available. +BLEDfu bledfu; +BLEDis bledis; + +// BLE state variables +bool bleInitialized = false; +bool bleActive = false; +bool bleConnected = false; +// Which subsystem owns the BLE radio (advert set + peripheral slot): +// transfer service vs camera remote. Transitions only on the main loop — +// see the ownership model in bluetooth.h. volatile: read from Bluefruit +// task callbacks for routing decisions. +volatile BleOwner bleOwner = BLE_OWNER_NONE; +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 + +/////////////////////////////////////////// +// TACHOMETER CONFIGURATION +// +// HARDWARE EMI RECOMMENDATIONS: +// The tach input (D0) picks up inductive kickback from ignition systems. +// To reduce phantom readings and noise coupling to other GPIO (buttons): +// +// 1. SHIELDING: Run tach signal wire in shielded cable, ground shield at MCU end only +// 2. FILTERING: Add RC low-pass filter at input: 1K resistor + 100nF cap to GND +// This creates ~1.6kHz cutoff, plenty fast for 20,000 RPM (333Hz) +// 3. CLAMPING: Add TVS diode or zener (5.1V) from D0 to GND for spike protection +// 4. SEPARATION: Keep tach wiring physically away from button wires +// 5. PULL-DOWN: Ensure 10K pull-down on D0 to prevent floating when no signal +// +// Signal characteristics: Magneto/CDI typically produces sharp negative-going +// pulses with significant ringing. The debounce timing below filters this. +/////////////////////////////////////////// + +const int tachInputPin = D0; +volatile int tachLastReported = 0; // Volatile: written by TACH_LOOP, read by display/logging/sleep +int topTachReported = 0; + +// Debounce timing: ignore pulses faster than this (filters ignition ringing). +// Derived at boot from the engine settings by tach_filter::minPulseGapUs() so +// the true-RPM ceiling is the same 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. Defaults to the historical 3 ms (1 cyl, wasted spark). +// Volatile: the ISR reads it; setup() writes it once before the interrupt is +// attached, so there is no race, only a visibility guarantee. +static volatile uint32_t tachMinPulseGapUs = tach_filter::kBasePulseGapUs; +volatile uint32_t tachLastPulseUs = 0; + +// Ring buffer: ISR writes pulse timestamps, TACH_LOOP reads and computes periods. +// Single-producer (ISR writes head), single-consumer (TACH_LOOP writes tail). +// The ISR checks full before publishing (one slot sacrificed so head==tail +// means empty) and drops + flags instead of lapping the consumer: SD GC +// stalls can block the main loop for 100 ms–2 s, far past what any sane +// ring size covers at racing RPM. tachRingTail is volatile because the ISR +// reads it for the full check. +static const uint8_t TACH_RING_SIZE = 16; +volatile uint32_t tachRingBuf[TACH_RING_SIZE]; +volatile uint8_t tachRingHead = 0; // ISR write index (only ISR writes) +volatile uint8_t tachRingTail = 0; // Main-loop read index (only TACH_LOOP writes) +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. +// 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; +static const uint32_t tachStopTimeoutUs = 500000; // 500ms = engine stopped + +/////////////////////////////////////////// +// ACCELEROMETER GLOBALS +/////////////////////////////////////////// +#include +LSM6DS3 accelIMU(I2C_MODE, 0x6A); +bool accelAvailable = false; +float accelX = 0.0f; +float accelY = 0.0f; +float accelZ = 0.0f; + +/////////////////////////////////////////// +// GPS GLOBALS +/////////////////////////////////////////// +SFE_UBLOX_GNSS_SERIAL myGNSS; +bool gpsInitialized = false; // Safety flag - true only after successful GPS init + +// Cached PVT data — updated by onPVTReceived() callback from checkCallbacks() +// lat/lng stay double: 1e-7 deg resolution over ±180° needs ~2^31 steps, +// beyond float's 24-bit mantissa. Everything else is float — well within +// 7 significant digits, and double math is SOFTWARE-emulated on the +// M4F's single-precision FPU (consumers that take double promote fine). +struct GpsData { + double latitudeDegrees; + double longitudeDegrees; + float altitude; // meters + float speed; // knots (for DovesLapTimer compatibility) + float HDOP; + float heading; // degrees (0-360), heading of motion + float horizontalAccuracy; // meters, horizontal accuracy estimate + int satellites; + bool fix; + bool timeValid; // true only when the module reports validDate+validTime+fullyResolved + // The two halves of timeValid, kept separately so the status page can say + // WHICH milestone is outstanding. fullyResolved is the slow one — it needs + // the UTC/leap-second parameters decoded off the nav message (~12.5 min + // worst case from a cold start), long after a position fix is up. + bool timeDateValid; // validDate && validTime + bool timeResolved; // fullyResolved + uint16_t year; // 2-digit (e.g. 25 for 2025) for compat with existing code + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t seconds; + uint16_t milliseconds; +} gpsData = {}; + +volatile bool gpsDataFresh = false; // Set by PVT callback, cleared by GPS_LOOP() + +// Monotonic count of PVT samples delivered, bumped by the callback and +// never reset. gpsDataFresh is a one-shot that GPS_LOOP() consumes, so a +// consumer running LATER in the same iteration can't use it to tell a new +// sample from a repeat — gpsData holds its last value between updates. +// Compare this against a remembered value instead. (gpsFrameCounter is no +// help: it zeroes every second for the frame-rate maths.) +volatile uint32_t gpsPvtSequence = 0; + +// GPS nav-rate target: the rate GPS_RECONFIGURE() (and every wake/recovery +// path that calls it) re-asserts. Boot starts in status mode (5 Hz + +// NAV-SAT for the GPS status page); gpsEnterRaceMode() moves it to 25 Hz +// PVT-only when the page exits. Owned by gps_functions.ino. +uint8_t gpsNavRateTarget = GPS_NAV_RATE_STATUS_HZ; +bool gpsNavSatWanted = true; + +// Why this boot happened — decoded from RESETREAS + GPIO LATCH first thing +// in setup(). Routes the GPS status page's exit (tach wake -> race mode) +// and the USB-wake charging shortcut once sleep is a full System OFF. +wake_cause::Cause bootWakeCause = wake_cause::Cause::kColdBoot; + +// GPS status boot page hold/auto-close state (host-tested pure unit). +gps_status_page::State gpsStatusState; + +// Per-satellite CNO snapshot for the GPS status page's signal bars. +// Written by onNAVSATReceived() (main-loop context via checkCallbacks()), +// read by displayPage_gps_status(). Selection/ordering rules live in the +// host-tested sat_bars unit. +uint8_t gpsSatCnos[sat_bars::kMaxSats]; +uint8_t gpsSatCnoCount = 0; // entries in gpsSatCnos (display-capped) +uint8_t gpsSatUsedCount = 0; // satellites participating in the nav solution +uint8_t gpsSatTrackedCount = 0; // satellites tracked with a measurable signal + // (CNO > 0) — the bars' population, NOT capped. + // Note NAV-PVT's numSV is used-in-solution too, + // so it can't serve as the "in view" figure. + +// GPS PVT-arrival validation: tracks whether GPS is producing data after +// GPS_SETUP() / GPS_WAKE(). Both set gpsWakeTime and clear gpsWakeValidated; +// GPS_LOOP() sets gpsWakeValidated=true on first PVT arrival. If 5 seconds +// pass without PVT, GPS_LOOP() triggers baud recovery and reconfiguration — +// this is how a module that silently lost its config (V_BCKP drop, full +// power cycle) gets caught even when the begin() probe succeeded. +unsigned long gpsWakeTime = 0; +bool gpsWakeValidated = true; // Armed (set false) by GPS_SETUP at boot + +float gps_speed_mph = 0.0; + +// GPS-lock hold: when a race session is running with the engine turning but +// the GPS has no valid time/position lock yet, we cannot name or open the +// log file (doing so produced garbage-dated files that corrupted on reboot). +// Instead of faulting, we pin the user to the tachometer page and keep +// waiting. Cleared automatically once the log file is created (lock acquired). +bool gpsLockHoldActive = false; + +/////////////////////////////////////////// +// LAP HISTORY +/////////////////////////////////////////// +const int lapHistoryMaxLaps = 1000; +unsigned long lastLap = 0; +unsigned long lapHistory[lapHistoryMaxLaps]; +int lapHistoryCount = 0; + +void checkForNewLapData() { + // Sprint mode: capture on the RUN-COMPLETE EDGE (run count increment), + // not on value change — two identical run times in a row are normal at + // autocross and the value-change dedupe below would silently drop the + // second. Each completed run also re-arms the auto-idle grace period: + // the between-run queue wait always starts fresh (plan 0002). + if (sprintTimer != nullptr) { + int runs = sprintTimer->getRuns(); + if (runs > sprintLastRunCount) { + sprintLastRunCount = runs; + raceSessionStartedAt = millis(); + if (lapHistoryCount < lapHistoryMaxLaps) { + lastLap = sprintTimer->getLastRunTime(); + lapHistory[lapHistoryCount] = lastLap; + lapHistoryCount++; + debugln(F("New run added to history...")); + } + } + return; + } + + // Read from active timer (CourseManager owns either the course timer + // or the Lap Anything waypoint timer). + unsigned long activeLapTime = 0; + if (courseManager != nullptr) { + if (courseManager->isLapAnythingActive()) { + activeLapTime = courseManager->getLapAnythingTimer()->getLastLapTime(); + } else if (courseManager->getActiveTimer() != nullptr) { + activeLapTime = courseManager->getActiveTimer()->getLastLapTime(); + } + } + if (lapHistoryCount < lapHistoryMaxLaps && activeLapTime != 0 && activeLapTime != lastLap) { + lastLap = activeLapTime; + lapHistory[lapHistoryCount] = lastLap; + lapHistoryCount++; + debugln(F("New lap added to history...")); + } +} + +/////////////////////////////////////////// +// REPLAY SYSTEM GLOBALS +/////////////////////////////////////////// + +// Replay file list - reduced sizes for memory constraints +#define MAX_REPLAY_FILES 20 +#define MAX_REPLAY_FILENAME_LENGTH 48 +char replayFiles[MAX_REPLAY_FILES][MAX_REPLAY_FILENAME_LENGTH]; +int numReplayFiles = 0; +int selectedReplayFile = -1; + +// Replay state (DOVEX instant-replay; populated by parseDovexHeader) +bool replayProcessingComplete = false; + +/////////////////////////////////////////// +// SD CARD GLOBALS +// SD_FAT_TYPE / PIN_SPI_CS / SPI_SPEED and the SdFat.h include moved +// to the top of this file so module headers (replay.h) see them. +/////////////////////////////////////////// + +SdFat SD; +File file; //buffer +File trackDir; +File trackFile; +File dataFile; +File replayFile; + +/////////////////////////////////////////// +// SD CARD ACCESS STATE MANAGEMENT +// Prevents race conditions between logging, replay, and BLE file transfers. +// The SD_ACCESS_* modes come from sd_functions.h (aliases of the host-tested +// sd_access_policy constants); transitions are made atomically by +// acquireSDAccess() / releaseSDAccess() in sd_functions.ino. +/////////////////////////////////////////// +volatile int currentSDAccess = SD_ACCESS_NONE; + +// Replay function prototypes (must be after SdFat include for File type) +bool buildReplayFileList(); +bool readReplayLine(File& file, char* buffer, int bufferSize); +double haversineDistanceMiles(double lat1, double lng1, double lat2, double lng2); +void resetReplayState(); +bool parseDovexHeader(const char* filename); + +// SD state flags +bool sdSetupSuccess = false; +bool sdCardUnformatted = false; // card answers but FAT won't mount (see SD_SETUP) +bool sdTrackSuccess = false; +bool sdDataLogInitComplete = false; +bool enableLogging = false; + +// Boot format-confirm page (PAGE_SD_FORMAT): the hold-to-confirm state +// machine lives in the host-tested sd_format_page unit. displayLoop() +// only ever renders the confirm screen; the running/done screens are +// painted directly by sdPerformFormat() (which blocks the main loop). +// A failed attempt returns to the confirm page with sdFormatLastFailed +// set so the renderer can say so. +sd_format_page::State sdFormatState; +bool sdFormatLastFailed = false; + +/////////////////////////////////////////// +// ON-DEVICE COURSE CREATOR (plan 0002 §5) +// +// Walk the cones, capture the lines, write a track file. The model, +// validation, point averaging and name generation all live in the +// host-tested course_creator unit; these globals are the live instance +// plus what the renderer needs to show. Menu input rides the sketch's +// existing menuSelectionIndex/menuLimit machinery. +/////////////////////////////////////////// +course_creator::State courseCreator; +// Track this course attaches to when the user picked "Here" — captured at +// entry from the proximity scan so the prompt can name it. +char courseCreatorTrackName[MAX_LOCATION_LENGTH] = ""; +// Result of the last save attempt, for the renderer. SD_COURSE_WRITE_OK +// doubles as "nothing has failed". +SdCourseWriteResult courseCreatorLastError = SD_COURSE_WRITE_OK; +// Last PVT sample folded into an averaging hold — see courseCreatorLoop(). +uint32_t courseCreatorLastPvtSeq = 0; + +unsigned long lastCardFlush = 0; +unsigned long lastLogCreateAttempt = 0; // Throttles log-file open retries (ms) +const char trackFolder[8] = "/TRACKS"; +// Sprint (point-to-point) tracks live in their own folder so everything +// existing stays untouched — the folder IS the track kind (plan 0002). +const char trackFolderSprint[15] = "/TRACKS/SPRINT"; + +char locations[MAX_LOCATIONS][MAX_LOCATION_LENGTH]; // 13-char FAT16 name limit +int numOfLocations = 0; + +/////////////////////////////////////////// +// JSON PARSING GLOBALS +/////////////////////////////////////////// +#include +// 4 KB handles tracks with up to 10 courses with full sector data. +// The sim uses the same size: a smaller buffer silently truncated real +// track files (the old Wokwi target's RAM constraint doesn't apply). +#define JSON_BUFFER_SIZE 4096 + +// extern matches the forward declaration in sd_functions.h so the +// constants have external linkage; otherwise their default internal +// linkage would mismatch the header. +extern const int PARSE_STATUS_GOOD = 0; +extern const int PARSE_STATUS_LOAD_FAILED = 5; +extern const int PARSE_STATUS_PARSE_FAILED = 10; + +char tracks[MAX_LAYOUTS][MAX_LAYOUT_LENGTH]; +TrackLayout trackLayouts[MAX_LAYOUTS]; +int numOfTracks = 0; + +// Track metadata (parsed from new JSON object format) +TrackMetadata activeTrackMetadata; + +// trackManifest is declared with the session-state globals above + +/////////////////////////////////////////// +// BLE FILE HANDLE (after SdFat include) +/////////////////////////////////////////// +File32 bleCurrentFile; + +/////////////////////////////////////////// +// BUTTON GLOBALS +/////////////////////////////////////////// +ButtonState button1; +ButtonState *btn1 = &button1; +ButtonState button2; +ButtonState *btn2 = &button2; +ButtonState button3; +ButtonState *btn3 = &button3; + +float epsilonPrecision = 0.001; + +// Debounce settings - tuned for EMI rejection while maintaining responsiveness +// 200ms allows ~5 presses/sec which is plenty fast for menu navigation +// Edge detection ensures button must be released before registering again +int buttonPressIntv = 500; +int buttonHoldIntv = 1000; +int antiBounceIntv = 200; +const int BUTTON_SAMPLE_COUNT = 3; // Number of samples to take +const int BUTTON_SAMPLE_DELAY_US = 500; // Microseconds between samples + +bool recentlyChanged = false; + +/////////////////////////////////////////// +// DISPLAY GLOBALS +/////////////////////////////////////////// + +// uses adafruit display libraries +#include + +#ifdef SIM +// #define USE_1306_DISPLAY // remove to use SH110X oled +#endif +// #define USE_1306_DISPLAY // remove to use SH110X oled + +#include "images.h" +#include "display_config.h" +int displayUpdateRateHz = 3; +unsigned long displayLastUpdate; + +// Page constants +const int PAGE_BOOT = 999; +const int PAGE_TEST = 995; +const int PAGE_RC_ERROR = 990; +// GPS status boot page — every boot lands here after the splash. Outside +// the ENDURANCE_MODE-reshuffled 3-12 running block and not arrow-navigable. +const int PAGE_GPS_STATUS = 900; + +// main menu (shown after boot) +const int PAGE_MAIN_MENU = -1; +const int PAGE_BLUETOOTH = -2; +const int PAGE_REPLAY_FILE_SELECT = -3; +const int PAGE_TRANSFER_MENU = -4; // Bluetooth-vs-USB submenu +const int PAGE_USB_STORAGE = -5; // USB mass-storage active screen +const int PAGE_PAIR_CAMERA = -6; // Insta360 pairing / paired-status screen +const int PAGE_CAMERA_SERIAL_ENTRY = -7; // manual 6-char camera serial entry +const int PAGE_REPLAY_RESULTS = -8; +const int PAGE_REPLAY_EXIT = -9; +const int PAGE_CAMERA_TEST = -10; // bench test menu (paired camera controls) +// On-device course creator (plan 0002 §5) — walk the cones, capture the +// lines. Five screens, all driven by the host-tested course_creator unit. +const int PAGE_COURSE_TRACK = -11; // "Are you at X?" Here / New Track +const int PAGE_COURSE_TYPE = -12; // Circuit / Sprint +const int PAGE_COURSE_LINES = -13; // one row per timing line + Save/Cancel +const int PAGE_COURSE_LINE = -14; // Point A / Point B / Save / Back +const int PAGE_COURSE_POINT = -15; // "Save current pos" averaging hold + +// running menu (these must be in order) +const int GPS_DEBUG = 3; +const int GPS_STATS = 4; + +#ifdef ENDURANCE_MODE + const int GPS_SPEED = 5; + const int GPS_LAP_TIME = 6; + const int GPS_LAP_PACE = 7; + const int GPS_LAP_BEST = 8; + const int LOGGING_STOP = 9; + + const int GPS_LAP_LIST = 1002; +#else + const int GPS_SPEED = 5; + const int TACHOMETER = 6; + // The Temp1 page only exists when the SensorEgg POC is compiled in + // (BIRDSEYE_ENABLE_SENSOREGG, see project.h) — otherwise the running + // block closes up behind the tachometer rather than leaving a dead page + // in the rotation. Same reshuffle idea as ENDURANCE_MODE above. + #if BIRDSEYE_ENABLE_SENSOREGG + const int SENSOR_TEMP = 7; // SensorEgg wireless EGT (Temp1) + const int SENSOR_TEMP2 = 8; // SensorEgg aux intake-air temp (Temp2, v2 eggs) + const int GPS_LAP_TIME = 9; + const int GPS_LAP_PACE = 10; + const int GPS_LAP_BEST = 11; + const int OPTIMAL_LAP = 12; + const int GPS_LAP_LIST = 13; + const int LOGGING_STOP = 14; + #else + const int GPS_LAP_TIME = 7; + const int GPS_LAP_PACE = 8; + const int GPS_LAP_BEST = 9; + const int OPTIMAL_LAP = 10; + const int GPS_LAP_LIST = 11; + const int LOGGING_STOP = 12; + #endif +#endif + +// end menu +const int LOGGING_STOP_CONFIRM = 90; +const int PAGE_INTERNAL_WARNING = 100; +const int PAGE_INTERNAL_FAULT = 105; +// Boot page when the SD card responds but has no mountable FAT volume +// (soldered-in module: factory-blank or corrupted). Unlike FAULT, its +// buttons stay live — driven by sdFormatPageLoop(). +const int PAGE_SD_FORMAT = 106; + +int currentPage = PAGE_BOOT; +int lastPage = 0; + +// "pageStart" defines where the UI starts, you cannot backup beyond this +#ifdef ENDURANCE_MODE + const int runningPageStart = GPS_SPEED; +#else + const int runningPageStart = GPS_DEBUG; // debug page carries the GPS pipeline counters +#endif + +int runningPageEnd = LOGGING_STOP; // only changes if sd:/tracks not found + +// Display state +int menuSelectionIndex = 0; +// Manual camera-serial entry state (PAGE_CAMERA_SERIAL_ENTRY): edited by the +// button handler in display_ui.ino, rendered by display_pages.ino. Cursor +// 0-5 = characters, 6 = OK, 7 = CANCEL. Reset when the page is entered. +char cameraSerialEntryBuf[7] = "AAAAAA"; +int cameraSerialEntryCursor = 0; +bool paceFlashStatus = false; +bool notificationFlash = false; +char internalNotification[64] = "N/A"; +bool calculatingFlip = false; +const int lapsPerPage = 3; +int current_lap_list_page = 0; +int lap_list_pages = 1; + +/////////////////////////////////////////// +// WATCHDOG TIMER +// nRF52840 hardware WDT - recovers from any lockup within ~4 seconds. +// Primary defense against I2C bus hangs, SD card stalls, etc. +/////////////////////////////////////////// + +void wdtSetup() { + NRF_WDT->CONFIG = WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos; // Keep running in sleep + NRF_WDT->CRV = 4 * 32768; // ~4 second timeout (32768 Hz clock) + NRF_WDT->RREN = WDT_RREN_RR0_Enabled << WDT_RREN_RR0_Pos; // Enable reload register 0 + NRF_WDT->TASKS_START = 1; // Start WDT (cannot be stopped once started) +} + +void wdtPet() { + NRF_WDT->RR[0] = WDT_RR_RR_Reload; // Feed the watchdog +} + +/////////////////////////////////////////// +// BOOT WAKE CAUSE +/////////////////////////////////////////// + +// Bit mask for an Arduino pin on the given GPIO port (0/1), or 0 if the +// pin lives on the other port. Uses the board variant's pin map so raw +// P-numbers never get hardcoded. +static uint32_t pinPortMask(uint32_t arduinoPin, int port) { + const uint32_t p = g_ADigitalPinMap[arduinoPin]; + if ((int)(p >> 5) != port) return 0; + return 1u << (p & 31); +} + +// Per-port masks of the System OFF wake pins for the wake_cause decoder. +// The button pin literals mirror setupButtons() in display_ui.ino (kept in +// sync by hand) — buttons aren't assigned to the ButtonState structs until +// displaySetup(), which runs after the boot decode needs these. +static wake_cause::PinMasks shutdownWakePinMasks() { + #ifndef SIM + const uint32_t buttonPins[3] = {1, 2, 3}; + #else + const uint32_t buttonPins[3] = {4, 5, 6}; + #endif + wake_cause::PinMasks m = {}; + m.tach0 = pinPortMask(tachInputPin, 0); + m.tach1 = pinPortMask(tachInputPin, 1); + for (int i = 0; i < 3; i++) { + m.buttons0 |= pinPortMask(buttonPins[i], 0); + m.buttons1 |= pinPortMask(buttonPins[i], 1); + } + return m; +} + +// Read (then clear) the sticky boot registers and decode why we booted. +// Must run before anything else touches them; RESETREAS is cumulative and +// LATCH survives System OFF, so stale bits would corrupt the next decode. +// The SoftDevice is never enabled this early (BLE init is lazy), so raw +// register access is safe. +static void captureBootWakeCause() { + wake_cause::Regs regs; + regs.resetreas = NRF_POWER->RESETREAS; + regs.latch0 = NRF_P0->LATCH; + regs.latch1 = NRF_P1->LATCH; + NRF_POWER->RESETREAS = 0xFFFFFFFF; // write-1-to-clear + NRF_P0->LATCH = 0xFFFFFFFF; + NRF_P1->LATCH = 0xFFFFFFFF; + bootWakeCause = wake_cause::decode(regs, shutdownWakePinMasks()); +} + +/////////////////////////////////////////// +// SETUP +/////////////////////////////////////////// + +void setup() { + captureBootWakeCause(); + +#ifdef HAS_DEBUG + Serial.begin(9600); + while (!Serial); +#endif + + #ifndef SIM + analogReadResolution(ADC_RESOLUTION); + pinMode(PIN_VBAT, INPUT); + pinMode(VBAT_ENABLE, OUTPUT); + digitalWrite(VBAT_ENABLE, LOW); + #if BIRDSEYE_ENABLE_ONBOARD_CHARGING + // Enable fast charging (~100mA vs default ~50mA) + // PIN_CHARGING_CURRENT = P0.13 = HICHG pin on BQ25100 charge IC + pinMode(PIN_CHARGING_CURRENT, OUTPUT); + digitalWrite(PIN_CHARGING_CURRENT, HIGH); + #else + // Onboard charging disabled (default): leave HICHG alone entirely. + // The pin stays an input, the BQ25100 runs at its ~50 mA default, and + // an external charging circuit owns the battery. See project.h. + #endif + lastBatteryCheck = millis(); + lastBatteryVoltage = getBatteryVoltage(); + #endif + + displaySetup(); + + // setup sd card and confirm we can read track list + sdSetupSuccess = SD_SETUP(); + sdTrackSuccess = buildTrackList(); + if(sdSetupSuccess && sdTrackSuccess) { + debugln(F("Obtained Track List")); + for (int i = 0; i < trackManifestCount; i++) { + char filepath[FILEPATH_MAX]; + makeFullTrackPath(trackManifest[i].filename, filepath, trackManifest[i].kind); + debugln(filepath); + } + } + + // Load settings from SD (creates defaults on first boot) + SETTINGS_SETUP(); + + // Register the USB mass-storage callbacks (no drive presented until the + // user enters USB transfer mode). Needs a working SD card for block I/O. + if (sdSetupSuccess) { + USB_MSC_SETUP(); + } + + ACCEL_SETUP(); + + GPS_SETUP(); + + // Read settings into runtime variables + { + char buf[48]; + if (getSetting("lap_detection_distance", buf, sizeof(buf))) { + settingLapDetectionDistance = atof(buf); + if (settingLapDetectionDistance <= 0) settingLapDetectionDistance = 7.0; + } + if (getSetting("waypoint_detection_distance", buf, sizeof(buf))) { + settingWaypointDetectionDistance = atof(buf); + if (settingWaypointDetectionDistance <= 0) settingWaypointDetectionDistance = 30.0; + } + if (getSetting("waypoint_speed", buf, sizeof(buf))) { + settingWaypointSpeed = atof(buf); + if (settingWaypointSpeed <= 0) settingWaypointSpeed = 30.0; + } + if (getSetting("driver_name", buf, sizeof(buf))) { + strncpy(settingDriverName, buf, sizeof(settingDriverName) - 1); + settingDriverName[sizeof(settingDriverName) - 1] = '\0'; + } + if (getSetting("device_name", buf, sizeof(buf))) { + strncpy(settingDeviceName, buf, sizeof(settingDeviceName) - 1); + settingDeviceName[sizeof(settingDeviceName) - 1] = '\0'; + } + if (getSetting("race_mode", buf, sizeof(buf))) { + settingRaceModePrefSprint = (strcasecmp(buf, "sprint") == 0); + } + // 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. + { + bool wastedSpark = true; + int cylinders = 1; + if (getSetting("spark_mode", buf, sizeof(buf))) { + wastedSpark = (strcasecmp(buf, "single") != 0); + } + if (getSetting("cylinder_count", buf, sizeof(buf))) { + const int n = atoi(buf); + if (n >= tach_filter::kMinCylinders) cylinders = n; + } + tachRevsPerPulse = tach_filter::revsPerPulse(cylinders, wastedSpark); + tachMinPulseGapUs = tach_filter::minPulseGapUs(cylinders, wastedSpark); + debug(F("Engine: cyl=")); + debug(cylinders); + debug(F(" spark=")); + debug(wastedSpark ? F("wasted") : F("single")); + debug(F(" revsPerPulse=")); + debug(tachRevsPerPulse); + debug(F(" minGapUs=")); + debugln((uint32_t)tachMinPulseGapUs); + } + crossingThresholdMeters = settingLapDetectionDistance; + debug(F("Settings loaded: lap_dist=")); + debug(settingLapDetectionDistance); + debug(F(" wp_dist=")); + debug(settingWaypointDetectionDistance); + debug(F(" wp_speed=")); + debug(settingWaypointSpeed); + debug(F(" driver=")); + debug(settingDriverName); + debug(F(" device=")); + debugln(settingDeviceName); + } + + // Camera auto-record: load the persisted Insta360 serial + init the FSM + CAMERA_SETUP(); + + // SensorEgg wireless EGT: bring the BLE core up and start the passive + // scanner (after CAMERA_SETUP so every GATT service is registered by + // bleCoreEnsureInit before anything advertises). A no-op — and BLE stays + // lazy — unless BIRDSEYE_ENABLE_SENSOREGG is set (beta channel only). + SENSOREGG_SETUP(); + + if (!sdSetupSuccess && sdCardUnformatted) { + // Card answers but no FAT volume mounts: soldered-in module out of the + // factory, or a corrupted filesystem. The card can't be pulled to fix + // it on a PC, so offer the on-device format (hold Select to confirm). + // Deliberately outranks the USB-wake charging branch — a device with an + // unusable card should say so; the page's idle timeout still lands in + // enterShutdown(), whose VBUS handling enters the charging loop anyway. + sd_format_page::begin(sdFormatState, millis()); + switchToDisplayPage(PAGE_SD_FORMAT); + } else if (!sdSetupSuccess) { + strncpy(internalNotification, "SD Init failed!\n\nlogging not possible!", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_FAULT); +#if BIRDSEYE_ENABLE_ONBOARD_CHARGING + } else if (bootWakeCause == wake_cause::Cause::kUsbWake) { + // Plugged in while off: VBUS woke the chip so software can hold the + // fast-charge pin. Skip the GPS status page and drop straight into + // the charging loop; a button press there resumes to the main menu + // (in which case setup() continues below), unplugging powers back off. + // + // Only worth doing when we actually manage the charge current. With + // onboard charging disabled (default) a VBUS wake boots normally — the + // cable means "host connected", and the idle timeout still lands in + // enterShutdown(), which parks on VBUS anyway. + enterShutdown(); +#endif + } else { + // A missing TRACKS folder is auto-created by buildTrackList(); a + // false here means even that failed — Lap Anything will handle it. + if (!sdTrackSuccess) { + debugln(F("No usable TRACKS folder — Lap Anything will activate")); + } + // Every boot lands on the GPS status page (MyChron-style): hold until + // a stable lock (or a button press), then continue to the menu — or + // straight into race mode when the tach woke us / the engine runs. + gps_status_page::begin(gpsStatusState, millis()); + switchToDisplayPage(PAGE_GPS_STATUS); + } + + // tachometer + pinMode(tachInputPin, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(tachInputPin), TACH_COUNT_PULSE, FALLING); + + // Start hardware watchdog LAST - everything above must complete before + // the 4-second timeout starts counting. If setup itself hangs, the + // device won't boot-loop because WDT hasn't started yet. + #ifndef SIM + wdtSetup(); + debugln(F("Watchdog timer started (~4s timeout)")); + #endif +} + +/////////////////////////////////////////// +// COURSE / TIMER HELPER FUNCTIONS +/////////////////////////////////////////// + +/** + * @brief Sprint timer accessor — non-null exactly while a sprint session's + * track has been detected (mode follows the track folder, plan 0002). + */ +SprintTimer* getActiveTimerSprint() { + return sprintTimer; +} + +bool sprintModeIsActive() { + return sprintTimer != nullptr; +} + +/** + * @brief True while timing is "live": a sprint run in progress, or (in + * circuit mode) the race started. Drives the sprint pages' *waiting* + * state — between runs the device stays in race mode with all pages up, + * but Current Lap / Pace show *waiting* instead of a dead 0:00. + */ +bool activeTimerRunActive() { + if (sprintTimer != nullptr) return sprintTimer->isRunActive(); + return activeTimerRaceStarted(); +} + +/** + * @brief Get the active timer pointer for display/lap-history reads. + * Returns whichever timer is active: course timer, lap anything, or nullptr. + */ +DovesLapTimer* getActiveTimerDLT() { + if (courseManager == nullptr) return nullptr; + if (courseManager->isLapAnythingActive()) return nullptr; + return courseManager->getActiveTimer(); +} + +WaypointLapTimer* getActiveTimerWLT() { + if (courseManager == nullptr) return nullptr; + if (courseManager->isLapAnythingActive()) return courseManager->getLapAnythingTimer(); + return nullptr; +} + +// Unified getter helpers for display pages +bool activeTimerRaceStarted() { + if (sprintTimer != nullptr) return sprintTimer->getRaceStarted(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getRaceStarted(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getRaceStarted(); + return false; +} + +bool activeTimerCrossing() { + if (sprintTimer != nullptr) return sprintTimer->getCrossing(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getCrossing(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getCrossing(); + return false; +} + +int activeTimerLaps() { + if (sprintTimer != nullptr) return sprintTimer->getRuns(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getLaps(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getLaps(); + return 0; +} + +unsigned long activeTimerCurrentLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getCurrentRunTime(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getCurrentLapTime(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getCurrentLapTime(); + return 0; +} + +unsigned long activeTimerLastLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getLastRunTime(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getLastLapTime(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getLastLapTime(); + return 0; +} + +unsigned long activeTimerBestLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getBestRunTime(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getBestLapTime(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getBestLapTime(); + return 0; +} + +int activeTimerBestLapNumber() { + if (sprintTimer != nullptr) return sprintTimer->getBestRunNumber(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getBestLapNumber(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getBestLapNumber(); + return 0; +} + +float activeTimerPaceDifference() { + if (sprintTimer != nullptr) return sprintTimer->getPaceDifference(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getPaceDifference(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getPaceDifference(); + return 0.0; +} + +float activeTimerTotalDistance() { + if (sprintTimer != nullptr) return sprintTimer->getTotalDistanceTraveled(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getTotalDistanceTraveled(); + WaypointLapTimer* wlt = getActiveTimerWLT(); + if (wlt) return wlt->getTotalDistanceTraveled(); + return 0.0; +} + +unsigned long activeTimerOptimalLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getOptimalLapTime(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getOptimalLapTime(); + return 0; +} + +bool activeTimerSectorsConfigured() { + if (sprintTimer != nullptr) return sprintTimer->areSectorLinesConfigured(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->areSectorLinesConfigured(); + return false; +} + +/** + * @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 + * event — see the host-tested sprint_select unit) and stand up a SprintTimer + * with its start/finish (+ optional split) lines. Returns false when no + * usable course exists (no finish line, degenerate lines). + */ +bool createSprintSession() { + const char* dates[MAX_LAYOUTS]; + for (int i = 0; i < numOfTracks; i++) dates[i] = trackLayouts[i].date_created; + int idx = sprint_select::newestCourseIndex(dates, numOfTracks); + if (idx < 0) return false; + + TrackLayout& L = trackLayouts[idx]; + if (!L.hasFinish) { + debugln(F("Sprint course has no finish line — cannot time runs")); + return false; + } + + // An RPM-wake may have created a Lap Anything CourseManager before + // detection ran — sprint replaces it (mirror of the circuit path). + if (courseManager != nullptr) { + delete courseManager; + courseManager = nullptr; + } + if (sprintTimer != nullptr) { + delete sprintTimer; + sprintTimer = nullptr; + } + + sprintTimer = new SprintTimer(crossingThresholdMeters); + sprintTimer->setStartLine(L.start_a_lat, L.start_a_lng, L.start_b_lat, L.start_b_lng); + sprintTimer->setFinishLine(L.finish_a_lat, L.finish_a_lng, L.finish_b_lat, L.finish_b_lng); + if (L.hasSector2) { + sprintTimer->setSector2Line(L.sector_2_a_lat, L.sector_2_a_lng, L.sector_2_b_lat, L.sector_2_b_lng); + } + if (L.hasSector3) { + sprintTimer->setSector3Line(L.sector_3_a_lat, L.sector_3_a_lng, L.sector_3_b_lat, L.sector_3_b_lng); + } + sprintTimer->forceLinearInterpolation(); + + if (!sprintTimer->isStartLineConfigured() || !sprintTimer->isFinishLineConfigured()) { + debugln(F("Sprint course lines invalid — cannot time runs")); + delete sprintTimer; + sprintTimer = nullptr; + return false; + } + + strncpy(sprintCourseName, tracks[idx], sizeof(sprintCourseName) - 1); + sprintCourseName[sizeof(sprintCourseName) - 1] = '\0'; + sprintLastRunCount = 0; + + debug(F("Sprint session ready — course: ")); + debug(sprintCourseName); + debug(F(" (date_created: ")); + debug(L.date_created[0] ? L.date_created : "n/a"); + debugln(F(")")); + return true; +} + +/** + * @brief Scan track manifest for closest match to current GPS position + * Creates CourseManager (circuit) or SprintTimer (sprint) when a match is + * found within 5 miles — mode follows the matched track's folder. + */ +void trackDetectionLoop() { + if (trackDetected || !gpsData.fix || trackManifestCount == 0) return; + + // Throttle the scan to 1 Hz. Each haversineDistanceMiles() is several + // software-emulated double libm calls (the M4F FPU is single-precision + // only); at the 200-entry manifest ceiling a full scan costs multiple + // milliseconds. gpsData.fix stays true BETWEEN PVT updates, so without + // this gate the scan ran every ~250 Hz loop iteration — collapsing the + // loop rate — for an answer that changes at driving pace. + static unsigned long lastManifestScan = 0; + if (millis() - lastManifestScan < 1000) return; + lastManifestScan = millis(); + + // Nearest entry PER KIND — mode follows the detected track's folder, + // with the race_mode preference as the both-kinds-in-range tiebreak + // (host-tested sprint_select unit; plan 0002 §7 Q1). + double bestDistCircuit = 999999.0, bestDistSprint = 999999.0; + int bestCircuit = -1, bestSprint = -1; + + for (int i = 0; i < trackManifestCount; i++) { + double dist = haversineDistanceMiles( + gpsData.latitudeDegrees, gpsData.longitudeDegrees, + trackManifest[i].lat, trackManifest[i].lon + ); + if (trackManifest[i].kind == TRACK_KIND_SPRINT) { + if (dist < bestDistSprint) { bestDistSprint = dist; bestSprint = i; } + } else { + if (dist < bestDistCircuit) { bestDistCircuit = dist; bestCircuit = i; } + } + } + + bool circuitInRange = bestCircuit >= 0 && bestDistCircuit <= TRACK_DETECT_RADIUS_MILES; + bool sprintInRange = bestSprint >= 0 && bestDistSprint <= TRACK_DETECT_RADIUS_MILES; + if (!circuitInRange && !sprintInRange) return; + + // Event-day heuristic: only needed when both kinds are near and the + // preference is circuit — the sprint file is parsed once to learn its + // newest course's date_created ("laid out today" = event day = sprint). + bool sprintCourseToday = false; + if (circuitInRange && sprintInRange && !settingRaceModePrefSprint) { + char filepath[FILEPATH_MAX]; + makeFullTrackPath(trackManifest[bestSprint].filename, filepath, TRACK_KIND_SPRINT); + if (parseTrackFile(filepath) == PARSE_STATUS_GOOD && numOfTracks > 0) { + const char* dates[MAX_LAYOUTS]; + for (int i = 0; i < numOfTracks; i++) dates[i] = trackLayouts[i].date_created; + int newest = sprint_select::newestCourseIndex(dates, numOfTracks); + char today[11]; + snprintf(today, sizeof(today), "20%02d-%02d-%02d", + gpsData.year, gpsData.month, gpsData.day); + sprintCourseToday = newest >= 0 && + sprint_select::isSameDay(trackLayouts[newest].date_created, today); + } + } + + sprint_select::Kind useKind = sprint_select::chooseKind( + circuitInRange, sprintInRange, + settingRaceModePrefSprint ? sprint_select::kPrefSprint : sprint_select::kPrefCircuit, + sprintCourseToday); + + int bestIndex = (useKind == sprint_select::kSprint) ? bestSprint : bestCircuit; + double bestDist = (useKind == sprint_select::kSprint) ? bestDistSprint : bestDistCircuit; + + { + debug(F("Track detected: ")); + debug(trackManifest[bestIndex].filename); + debug(useKind == sprint_select::kSprint ? F(" [sprint] (") : F(" [circuit] (")); + debug(bestDist, 2); + debugln(F(" miles)")); + + detectedTrackIndex = bestIndex; + + // Use manifest filename directly to build filepath — locations[] may + // truncate names longer than MAX_LOCATION_LENGTH (12 chars, old FAT16 + // 8.3 limit), causing strcmp mismatches that silently skip real tracks. + { + char filepath[FILEPATH_MAX]; + makeFullTrackPath(trackManifest[bestIndex].filename, filepath, trackManifest[bestIndex].kind); + int parseStatus = parseTrackFile(filepath); + + if (useKind == sprint_select::kSprint) { + // Sprint path: no CourseManager, no CourseDetector — select the + // newest course by date_created and time point-to-point runs. + if (parseStatus == PARSE_STATUS_GOOD && numOfTracks > 0) { + trackDetected = createSprintSession(); + } + } else if (parseStatus == PARSE_STATUS_GOOD && numOfTracks > 0) { + // Build TrackConfig from parsed data + activeTrackConfig.longName = activeTrackMetadata.longName[0] ? activeTrackMetadata.longName : trackManifest[bestIndex].filename; + activeTrackConfig.shortName = activeTrackMetadata.shortName[0] ? activeTrackMetadata.shortName : trackManifest[bestIndex].filename; + + // Populate CourseConfig entries + // Check if any course has lengthFt > 0. CourseDetector uses + // distance matching to identify which course the driver is on. + // Without lengthFt, distance can never match and detection gets + // stuck permanently — no candidates, no rejections, no fallback. + bool anyHasLength = false; + activeTrackConfig.courseCount = numOfTracks; + for (int i = 0; i < numOfTracks && i < MAX_COURSES; i++) { + activeTrackConfig.courses[i].name = tracks[i]; + activeTrackConfig.courses[i].lengthFt = activeTrackMetadata.courseLengthFt[i]; + activeTrackConfig.courses[i].startALat = trackLayouts[i].start_a_lat; + activeTrackConfig.courses[i].startALng = trackLayouts[i].start_a_lng; + activeTrackConfig.courses[i].startBLat = trackLayouts[i].start_b_lat; + activeTrackConfig.courses[i].startBLng = trackLayouts[i].start_b_lng; + activeTrackConfig.courses[i].sector2ALat = trackLayouts[i].sector_2_a_lat; + activeTrackConfig.courses[i].sector2ALng = trackLayouts[i].sector_2_a_lng; + activeTrackConfig.courses[i].sector2BLat = trackLayouts[i].sector_2_b_lat; + activeTrackConfig.courses[i].sector2BLng = trackLayouts[i].sector_2_b_lng; + activeTrackConfig.courses[i].sector3ALat = trackLayouts[i].sector_3_a_lat; + activeTrackConfig.courses[i].sector3ALng = trackLayouts[i].sector_3_a_lng; + activeTrackConfig.courses[i].sector3BLat = trackLayouts[i].sector_3_b_lat; + activeTrackConfig.courses[i].sector3BLng = trackLayouts[i].sector_3_b_lng; + activeTrackConfig.courses[i].hasSector2 = trackLayouts[i].hasSector2; + activeTrackConfig.courses[i].hasSector3 = trackLayouts[i].hasSector3; + if (activeTrackMetadata.courseLengthFt[i] > 0) anyHasLength = true; + } + + // If no courses have lengthFt, CourseDetector cannot match by + // distance and will be stuck forever. Force courseCount=0 so + // CourseManager activates Lap Anything immediately. Track name + // metadata is preserved so the display still shows which track. + if (!anyHasLength) { + debugln(F("WARNING: No courses have lengthFt — forcing Lap Anything")); + activeTrackConfig.courseCount = 0; + } + + // Create CourseManager (delete existing Lap Anything one if RPM-wake created it) + if (courseManager != nullptr) { + delete courseManager; + courseManager = nullptr; + } + courseManager = new CourseManager(activeTrackConfig, crossingThresholdMeters); + courseManager->setSpeedThresholdMph(settingWaypointSpeed); + courseManager->setWaypointProximityMeters(settingWaypointDetectionDistance); + courseManager->setDetectionProximityMeters(settingWaypointDetectionDistance); + + trackDetected = true; + debugln(F("CourseManager created with track data")); + } + } + + // If parsing failed or no tracks, create Lap Anything fallback + if (!trackDetected) { + createLapAnythingCourseManager(); + trackDetected = true; + } + } +} + +/** + * @brief End the current race session: write DOVEX header, close file, + * clean up CourseManager, reset state. Used by both checkAutoIdle() + * and LOGGING_STOP_CONFIRM in display_ui.ino. + */ +void endRaceSession() { + // Deliberately NO camera notification here: checkAutoIdle() ends the + // log session on speed alone (engine ignored), but the camera must + // keep recording through a stationary grid idle — its own + // stationary-AND-engine-off rule decides the recording stop. The + // camera is stopped explicitly where the user means "I'm done": + // the manual stop confirm (display_ui.ino) and shutdown entry + // (CAMERA_SLEEP() in enterShutdown()). + + // Write DOVEX metadata header into the reserved region + if (sdDataLogInitComplete && dataFile.isOpen()) { + writeDovexHeader(); + } + + // Close log file + if (dataFile.isOpen()) { + dataFile.flush(); + dataFile.close(); + } + releaseSDAccess(SD_ACCESS_LOGGING); + enableLogging = false; + sdDataLogInitComplete = false; + + // Clean up CourseManager + if (courseManager != nullptr) { + delete courseManager; + courseManager = nullptr; + } + // Clean up sprint session (mode follows detection; next session re-detects) + if (sprintTimer != nullptr) { + delete sprintTimer; + sprintTimer = nullptr; + } + sprintCourseName[0] = '\0'; + sprintLastRunCount = 0; + trackDetected = false; + detectedTrackIndex = -1; + raceActive = false; + idleTimerRunning = false; + idleStartTime = 0; + + // Reset lap history + lapHistoryCount = 0; + lastLap = 0; + memset(lapHistory, 0, sizeof(lapHistory)); + topTachReported = 0; + + debugln(F("Race session ended")); +} + +/** + * @brief Create a fallback CourseManager with no courses (Lap Anything mode). + * Used when no track is detected, parsing fails, or user enters race manually. + */ +void createLapAnythingCourseManager() { + if (courseManager != nullptr) return; // Already exists + if (sprintTimer != nullptr) return; // Sprint session owns timing + activeTrackConfig.longName = "Unknown"; + activeTrackConfig.shortName = ""; + activeTrackConfig.courseCount = 0; + courseManager = new CourseManager(activeTrackConfig, crossingThresholdMeters); + courseManager->setSpeedThresholdMph(settingWaypointSpeed); + courseManager->setWaypointProximityMeters(settingWaypointDetectionDistance); + debugln(F("CourseManager created (Lap Anything)")); +} + +/** + * @brief Check for auto-idle: 60s at <2mph ends the session + */ +void checkAutoIdle() { + if (!raceActive) return; + + // Yield to an active camera recording: while the camera is recording, IT owns + // the end (30 s engine-off -> cameraConsumeAutoStop() above), so the + // speed-based idle must not cut the log out from under it during a stationary + // but engine-running stint (grid/paddock). No camera, or not recording, keeps + // the original speed-only behavior below. + // + // EXCEPTION — GPS-lock hold: while the session is still waiting for its GPS + // time lock, no log file exists and the hold pins the UI with navigation + // disabled (displayLoop). If the camera is also recording, this yield was + // the ONLY session-ender left, so a lock that never arrives left the device + // looking bricked until a power cycle (2026-07-19 pull-start incident). + // There is no log to protect yet — let the idle timer end the fileless + // session, which releases the UI and stops the camera. + if (cameraActivelyRecording() && !gpsLockHoldActive) return; + + // Grace period: don't auto-idle within first 3 minutes of a session. + // After RPM wake the car is often stationary (warming up, waiting for + // track session) and GPS needs time to reacquire. Without this, the + // 60s idle timer kills the session before the driver even moves. + if (millis() - raceSessionStartedAt < 180000UL) return; + + // Sprint mode: between-run queue waits are normal (engine running, + // stationary, ~30-45 s — sometimes longer on grid delays). Idle only + // counts while the engine is off too, so a running engine at the start + // line can never end the session (plan 0002: safety margin, and each + // completed run re-arms the grace period via checkForNewLapData()). + if (sprintTimer != nullptr && tachLastReported > 0) { + idleTimerRunning = false; + idleStartTime = 0; + return; + } + + if (gps_speed_mph >= 2.0) { + idleTimerRunning = false; + idleStartTime = 0; + return; + } + + if (!idleTimerRunning) { + idleTimerRunning = true; + idleStartTime = millis(); + return; + } + + if (millis() - idleStartTime >= 60000) { + debugln(F("Auto-idle: 60s at <2mph — ending session")); + endRaceSession(); + switchToDisplayPage(PAGE_MAIN_MENU); + } +} + +/** + * @brief Auto-enter race mode from main menu when driving + */ +void autoRaceModeCheck() { + if (currentPage != PAGE_MAIN_MENU) return; + if (currentPage == PAGE_BLUETOOTH || bleConnected) return; + + // Don't hijack a deliberate navigation. Exiting a page — the course creator + // especially, since it is used out on the course where you may well be + // rolling — lands here, and above the trigger the NEXT loop iteration would + // jump straight into race mode, ~4 ms later. The menu is never drawn and the + // user has no idea what happened. + // + // Anchor on the newest of "arrived at the menu" and the button stamps, so + // actively navigating at speed defers it too. The debouncer's lastPressed + // values persist across iterations (same reason the menu-idle block uses + // them), which makes this independent of where in loop() we run. + unsigned long settledSince = mainMenuEnteredAtMs; + if ((long)(btn1->lastPressed - settledSince) > 0) settledSince = btn1->lastPressed; + if ((long)(btn2->lastPressed - settledSince) > 0) settledSince = btn2->lastPressed; + if ((long)(btn3->lastPressed - settledSince) > 0) settledSince = btn3->lastPressed; + if (millis() - settledSince < AUTO_RACE_MENU_GRACE_MS) return; + + bool rpmTriggered = tachLastReported > 500; + bool speedTriggered = gps_speed_mph >= 10.0; + + if (rpmTriggered || speedTriggered) { + debugln(F("Auto-entering race mode")); + raceActive = true; + enableLogging = true; + raceSessionStartedAt = millis(); + + // Create a minimal CourseManager if none exists yet (no track detected) + createLapAnythingCourseManager(); + + // Show tach page if RPM triggered first, otherwise speed page + switchToDisplayPage(rpmTriggered ? TACHOMETER : GPS_SPEED); + } +} + +/** + * @brief Drive the GPS status boot page: bounded GPS re-detect, then step + * the hold/auto-close state machine (host-tested gps_status_page + * unit) and act on its exit verdict. Runs between readButtons() + * and displayLoop() so it consumes this frame's presses — the page + * has an explicit no-op branch in displayLoop()'s button handling. + */ +void gpsStatusPageLoop() { + if (currentPage != PAGE_GPS_STATUS) return; + + // GPS missing at boot? Re-probe a few times while the page is up. + GPS_STATUS_RETRY_LOOP(); + + gps_status_page::Inputs in; + in.fix = gpsData.fix; + in.timeValid = gpsData.timeValid; + in.buttonPressed = btn1->pressed || btn2->pressed || btn3->pressed; + in.tachWakeBoot = (bootWakeCause == wake_cause::Cause::kTachWake); + in.engineRunning = tachLastReported > 500; // autoRaceModeCheck threshold + in.nowMs = millis(); + + const gps_status_page::Exit verdict = gps_status_page::step(gpsStatusState, in); + if (verdict != gps_status_page::Exit::kStay) { + // The press that skipped this page must not also drive the + // destination page's button handling in displayLoop() this frame. + resetButtons(); + } + + switch (verdict) { + case gps_status_page::Exit::kToMenu: + gpsEnterRaceMode(); // 25 Hz PVT-only is the steady state off this page + switchToDisplayPage(PAGE_MAIN_MENU); + break; + case gps_status_page::Exit::kToRace: + // Engine is (or was, at wake) running — straight into race mode with + // logging, mirroring the old RPM-wake path. + gpsEnterRaceMode(); + debugln(F("GPS status page -> race mode")); + raceActive = true; + enableLogging = true; + raceSessionStartedAt = millis(); + createLapAnythingCourseManager(); + switchToDisplayPage(TACHOMETER); + break; + case gps_status_page::Exit::kToShutdown: + // Nothing locked, engine silent for the idle timeout — a spurious + // wake or a shelf queen. Power back down. + enterShutdown(); + break; + case gps_status_page::Exit::kStay: + break; + } +} + +/** + * @brief Drive the SD format-confirm boot page: step the hold-to-confirm + * state machine (host-tested sd_format_page unit) and act on its + * exit verdict. Runs between readButtons() and displayLoop(), like + * gpsStatusPageLoop() — the page has an explicit no-op branch in + * displayLoop()'s button handling. + */ +void sdFormatPageLoop() { + if (currentPage != PAGE_SD_FORMAT) return; + + sd_format_page::Inputs in; + in.selectHeld = isButtonHeld(2, 0); // live level, updated by updateButtonHoldState() + // A held side button disarms the confirm — the user is going for the + // global Select+side reboot combo (5 s), which must outrank a 3 s erase. + in.otherButtonHeld = isButtonHeld(1, 0) || isButtonHeld(3, 0); + in.otherButtonPressed = btn1->pressed || btn3->pressed; + in.engineRunning = tachLastReported > 500; // autoRaceModeCheck threshold + in.nowMs = millis(); + + const sd_format_page::Exit verdict = sd_format_page::step(sdFormatState, in); + if (verdict != sd_format_page::Exit::kStay) { + resetButtons(); + } + + switch (verdict) { + case sd_format_page::Exit::kFormat: + // Blocking. Reboots the device on success; returns to this page's + // confirm screen on failure (fresh full hold required to retry). + sdPerformFormat(); + break; + case sd_format_page::Exit::kToShutdown: + // Unformatted card and nobody home — don't drain the pack. + enterShutdown(); + break; + case sd_format_page::Exit::kStay: + break; + } +} + +/////////////////////////////////////////// +// ON-DEVICE COURSE CREATOR — SKETCH GLUE (plan 0002 §5) +// +// The model lives in the host-tested course_creator unit. Everything here +// is the parts that need hardware: which page constant a screen maps to, +// feeding real GPS fixes into an averaging hold, and writing the result. +/////////////////////////////////////////// + +/** + * @brief Page constant for the creator's current screen. + */ +int courseCreatorPage() { + switch (courseCreator.screen) { + case course_creator::Screen::kTrackPrompt: return PAGE_COURSE_TRACK; + case course_creator::Screen::kTypeSelect: return PAGE_COURSE_TYPE; + case course_creator::Screen::kLineMenu: return PAGE_COURSE_LINES; + case course_creator::Screen::kLineDetail: return PAGE_COURSE_LINE; + case course_creator::Screen::kPointCapture: return PAGE_COURSE_POINT; + } + return PAGE_MAIN_MENU; +} + +/** @brief True while any of the creator's screens is up. */ +bool courseCreatorActive() { + return currentPage <= PAGE_COURSE_TRACK && currentPage >= PAGE_COURSE_POINT; +} + +/** + * @brief Enter the course creator from the main menu. + * + * Runs the same haversine proximity scan track detection uses, so the + * prompt can offer "you're at X" instead of making the user think about + * which file a new course belongs in. Without a fix there is no scan and + * no capture, so the creator refuses to open at all — every screen past + * here needs GPS. + */ +bool courseCreatorEnter() { + if (!gpsData.fix || !gpsData.timeValid) return false; + + courseCreatorTrackName[0] = '\0'; + double bestDist = TRACK_DETECT_RADIUS_MILES; + int best = -1; + for (int i = 0; i < trackManifestCount; i++) { + const double dist = haversineDistanceMiles( + gpsData.latitudeDegrees, gpsData.longitudeDegrees, + trackManifest[i].lat, trackManifest[i].lon); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + if (best >= 0) { + strncpy(courseCreatorTrackName, trackManifest[best].filename, + sizeof(courseCreatorTrackName) - 1); + courseCreatorTrackName[sizeof(courseCreatorTrackName) - 1] = '\0'; + } + + courseCreatorLastError = SD_COURSE_WRITE_OK; + course_creator::begin(courseCreator, best >= 0); + menuSelectionIndex = 0; + switchToDisplayPage(courseCreatorPage()); + return true; +} + +/** + * @brief Write the walked course to the card. + * + * Names are generated from the GPS clock — the creator never takes text + * input (plan 0002 §5), so this is the only naming path. A new track keeps + * the same stamp for its file, its long name and its first course, which + * is what lets the webapp show them as one thing to rename. + */ +void courseCreatorSave() { + char name[course_creator::kNameSize]; + char shortName[course_creator::kShortNameSize]; + char dateCreated[course_creator::kDateCreatedSize] = ""; + + course_creator::generatedName(name, sizeof(name), gpsData.year, gpsData.month, + gpsData.day, gpsData.hour, gpsData.minute); + course_creator::generatedShortName(shortName, sizeof(shortName), gpsData.month, + gpsData.day, gpsData.hour, gpsData.minute); + if (courseCreator.kind == course_creator::CourseKind::kSprint) { + course_creator::generatedDateCreated(dateCreated, sizeof(dateCreated), + gpsData.year, gpsData.month, gpsData.day, + gpsData.hour, gpsData.minute); + } + + CreatedCourseWrite req; + req.course = &courseCreator; + req.newTrack = courseCreator.newTrack; + req.trackName = courseCreator.newTrack ? name : courseCreatorTrackName; + req.shortName = shortName; + req.courseName = name; + req.dateCreated = dateCreated; + + courseCreatorLastError = sdSaveCreatedCourse(req); + if (courseCreatorLastError == SD_COURSE_WRITE_OK) { + debug(F("Course saved: ")); + debugln(name); + switchToDisplayPage(PAGE_MAIN_MENU); + } else { + // Stay on the line menu with everything still captured — walking the + // course again because the card was busy would be unforgivable. + debugln(F("Course save FAILED")); + switchToDisplayPage(PAGE_COURSE_LINES); + } +} + +/** + * @brief Act on a menu selection inside the creator. + * + * Called from handleMenuPageSelection(); the generic menu machinery has + * already tracked the row index. + */ +void courseCreatorSelect() { + const course_creator::Action action = + course_creator::select(courseCreator, (uint8_t)menuSelectionIndex); + + switch (action) { + case course_creator::Action::kBeginCapture: + course_creator::captureBegin(courseCreator, millis()); + break; + case course_creator::Action::kSaveCourse: + courseCreatorSave(); + return; + case course_creator::Action::kExit: + switchToDisplayPage(PAGE_MAIN_MENU); + return; + case course_creator::Action::kNone: + break; + } + + const int page = courseCreatorPage(); + if (page != currentPage) { + menuSelectionIndex = 0; + switchToDisplayPage(page); + } else { + forceDisplayRefresh(); + } +} + +/** + * @brief Feed GPS into a running capture and finish it when the hold ends. + * + * Runs every loop iteration while the creator is up. Only NEW PVT samples + * count — gpsData holds its last value between updates, so an un-gated + * feed would average the same fix 250 times a second and report a + * confidence the fix never had. gpsDataFresh can't be that gate: GPS_LOOP() + * consumes it earlier in the same iteration, so this runs on the sequence + * counter instead. + */ +void courseCreatorLoop() { + if (!courseCreatorActive()) return; + if (course_creator::capturePoll(courseCreator, millis()) == + course_creator::CaptureResult::kIdle) { + return; + } + + const uint32_t seq = gpsPvtSequence; + if (seq != courseCreatorLastPvtSeq && gpsData.fix) { + courseCreatorLastPvtSeq = seq; + course_creator::captureAddFix(courseCreator, gpsData.latitudeDegrees, + gpsData.longitudeDegrees, + gpsData.horizontalAccuracy, millis()); + } + + const course_creator::CaptureResult result = + course_creator::capturePoll(courseCreator, millis()); + if (result == course_creator::CaptureResult::kRunning) { + return; + } + + // kDone commits and drops back to the line detail; kFailed clears the + // hold and leaves captureFailed set so the page can offer a retry. + course_creator::captureCommit(courseCreator, millis()); + const int page = courseCreatorPage(); + if (page != currentPage) { + menuSelectionIndex = 0; + switchToDisplayPage(page); + } else { + forceDisplayRefresh(); + } +} + +/** + * @brief Maintain the GPS-lock hold state (see gpsLockHoldActive). + * + * While a race session wants to log but has no valid GPS time lock yet — so + * the log file cannot be named/created — and the engine is turning, we pin + * the user to the tachometer instead of trying to log with a garbage date. + * The hold latches on once the engine is seen running and clears the moment + * the log file is created (lock acquired) or the session ends. Logging then + * begins automatically and normal race-mode navigation resumes. + */ +void updateGpsLockHold() { + // Engine-off release window: the pin only makes sense while the engine is + // (recently) turning. Killing the motor with no lock used to leave the hold + // latched — tach page, navigation dead — until the session ended, which the + // camera's recording suppressed indefinitely (2026-07-19 field incident). + static uint32_t lastEngineActivityMs = 0; + const uint32_t kEngineOffReleaseMs = 10000; + + if (raceActive && enableLogging && !sdDataLogInitComplete) { + if (tachLastReported > 0) { + lastEngineActivityMs = millis(); + gpsLockHoldActive = true; + } else if (gpsLockHoldActive && + (uint32_t)(millis() - lastEngineActivityMs) >= kEngineOffReleaseMs) { + // Engine has been silent long enough — give the UI back. The session + // stays active (the user can end it from the END RACE page), and a + // restart re-latches the hold. + gpsLockHoldActive = false; + } + } else { + gpsLockHoldActive = false; // lock acquired (file created) or race ended + } +} + +/** + * @brief Write DOVEX header metadata into the reserved 1 KB at the + * start of the open log file. Layout + padding live in + * dovex_header::format(); this just wires up the I/O. + */ +void writeDovexHeader() { + if (!dataFile.isOpen()) return; + + char datetime[24]; + snprintf(datetime, sizeof(datetime), "20%02d-%02d-%02d %02d:%02d:%02d", + gpsData.year, gpsData.month, gpsData.day, + gpsData.hour, gpsData.minute, gpsData.seconds); + + const char* courseName = "Lap Anything"; + const char* shortName = ""; + if (sprintTimer != nullptr) { + courseName = sprintCourseName[0] ? sprintCourseName : "Sprint"; + shortName = activeTrackMetadata.shortName; // from the session's parse + } else if (courseManager != nullptr) { + const char* cn = courseManager->getActiveCourseName(); + if (cn) courseName = cn; + shortName = courseManager->getShortName(); + } + + const dovex_header::Metadata meta = { + datetime, + settingDriverName, + courseName, + shortName, + activeTimerBestLapTime(), + activeTimerOptimalLapTime(), + settingDeviceName, + // Webapp loading helper: with SPRINT the laps line is a runs line. + sprintTimer != nullptr ? "SPRINT" : "CIRCUIT", + }; + + static char headerBuf[dovex_header::kHeaderSize]; + dovex_header::format(headerBuf, sizeof(headerBuf), meta, + lapHistory, lapHistoryCount); + + dataFile.seekSet(0); + dataFile.write(reinterpret_cast(headerBuf), sizeof(headerBuf)); + dataFile.flush(); + debugln(F("DOVEX header written")); +} + +/////////////////////////////////////////// +// SHUTDOWN (System OFF) +// +// "Sleep" is a full power-down: tear everything down, arm GPIO SENSE on +// the tach + buttons as wake sources, and enter nRF52 System OFF (~µA). +// Wake is a chip reset — setup() runs fresh and captureBootWakeCause() +// reads why (tach pulse -> the GPS status page exits into race mode). +// +// The ONE soft exception is VBUS: while a cable is present the device +// stays alive and parks in the charging loop instead, only dropping to +// System OFF once the cable is pulled. Two reasons, and only the first is +// gated by BIRDSEYE_ENABLE_ONBOARD_CHARGING: +// 1. With onboard charging enabled, the HICHG fast-charge pin is +// software-held, so powering down would drop the charge rate. +// 2. Always: VBUS is an always-armed System OFF wake source on the +// nRF52840, so entering System OFF with the cable still in risks an +// immediate wake-reset loop. The park is a hardware constraint. +// Plugging in a dark device therefore boots it straight back into that +// loop (with charging disabled the boot is a normal one that idles its +// way back here, rather than a direct shortcut). +/////////////////////////////////////////// + +bool isUsbConnected() { + return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; +} + +// Raw multi-sampled poll until every button is released (or timeout). +// A held button at System OFF entry = SENSE already satisfied = instant +// wake-reset, so the entry combo must be released before we commit. +static void waitAllButtonsReleased(unsigned long timeoutMs) { + unsigned long start = millis(); + while (anyButtonPressed() && millis() - start < timeoutMs) { + wdtPet(); + delay(10); + } +} + +// CPU idle for the charging loop. sd_app_evt_wait() is an SVC into the +// SoftDevice and hard-faults when it isn't enabled — BLE is lazily +// initialized, so ask the SoftDevice itself (the old sleep loop called +// it unconditionally, a latent fault). +static void shutdownIdleWait() { + uint8_t sdEnabled = 0; + (void)sd_softdevice_is_enabled(&sdEnabled); + if (sdEnabled) { + sd_app_evt_wait(); + } else { + __WFE(); + } +} + +// Configure wake sources and enter System OFF. Does not return: wake is +// a full reset (RESETREAS.OFF + the pin's LATCH bit record the cause), +// and the WDT stops with every other clock — wdtSetup() re-arms on the +// fresh boot (nRF52840 PS: System OFF halts all clocks/peripherals). +static void shutdownSystemOff() { + #ifdef SIM + // Simulator has no System OFF emulation worth trusting — plain reset. + NVIC_SystemReset(); + #else + waitAllButtonsReleased(3000); + delay(50); // contact settle + wdtPet(); + + // The tach line's parked level depends on the pickup circuit's output + // stage (Schmitt inverter + optocoupler builds idle either way), and + // arming SENSE toward the idle level = DETECT satisfied = instant + // wake-reset (the battery-sleep reboot loop). Sample the parked line + // and arm the opposite level; the vote lives in the host-tested + // wake_cause unit. Engine is off on every shutdown path, so the line + // is quiet — the spread-out burst just rides through stray noise. + unsigned tachHighSamples = 0; + const unsigned kTachIdleSamples = 15; + for (unsigned i = 0; i < kTachIdleSamples; i++) { + if (digitalRead(tachInputPin) == HIGH) tachHighSamples++; + delay(2); + } + const bool tachIdleHigh = + wake_cause::tachIdleIsHigh(tachHighSamples, kTachIdleSamples); + wdtPet(); + + // Wake sources: the tach (pull-up, SENSE opposite its sampled idle + // level — a spark pulse is a transition away from idle) and all three + // buttons (active-low, SENSE-LOW). Pull + SENSE config is retained in + // System OFF. P-numbers via the board variant's pin map. + nrf_gpio_cfg_sense_input( + g_ADigitalPinMap[tachInputPin], NRF_GPIO_PIN_PULLUP, + tachIdleHigh ? NRF_GPIO_PIN_SENSE_LOW : NRF_GPIO_PIN_SENSE_HIGH); + nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn1->pin], + NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); + nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn2->pin], + NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); + nrf_gpio_cfg_sense_input(g_ADigitalPinMap[btn3->pin], + NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); + // VBUS wake needs no configuration on the nRF52840 — always armed. + + // A set LATCH bit is a pending DETECT = instant re-wake; clear last, + // right before entering OFF. (Boot already cleared RESETREAS, but the + // SoftDevice path below clears again to be unambiguous.) + NRF_P0->LATCH = 0xFFFFFFFF; + NRF_P1->LATCH = 0xFFFFFFFF; + + // Cortex-M4F: a pending FPU exception can inhibit low-power entry — + // clear lazily-stacked FP state before OFF (standard Nordic guidance). + __set_FPSCR(__get_FPSCR() & ~0x9F); + NVIC_ClearPendingIRQ(FPU_IRQn); + + // NRF_POWER is a restricted peripheral while the SoftDevice is enabled + // (BLE is lazy — it may or may not be up). GPREGRET is deliberately + // untouched: register 0 belongs to the OTA/bootloader handoff. + uint8_t sdEnabled = 0; + (void)sd_softdevice_is_enabled(&sdEnabled); + if (sdEnabled) { + sd_power_reset_reason_clr(0xFFFFFFFF); + (void)sd_power_system_off(); + } else { + NRF_POWER->RESETREAS = 0xFFFFFFFF; + NRF_POWER->SYSTEMOFF = 1; + } + // Only reachable in emulated System OFF (debugger attached). + while (true) { __WFE(); } + #endif +} + +// The charging loop — runs after full teardown whenever VBUS is present +// at shutdown (or woke us). Shows the charging screen for 10 s, then +// display off; ANY button press is a full wake back to the main menu +// (with onboard charging enabled the USB-on-menu trigger waits +// USB_MENU_CHARGE_IDLE_MS before pulling the device back here, so wake +// doesn't bounce; with it disabled nothing pulls the menu down early at +// all). Returns true to resume to the menu, false when the cable was +// pulled (caller enters System OFF). +static bool runChargingShutdownLoop() { + debugln(F("Charging loop (VBUS present)")); + DISPLAY_WAKE(); + waitAllButtonsReleased(2000); // shutdown entry combo may still be held + unsigned long shownAt = millis(); + if (shownAt == 0) shownAt = 1; // 0 is the display-off sentinel + + while (true) { + wdtPet(); + + if (!isUsbConnected()) { + if (shownAt != 0) DISPLAY_SLEEP(); + return false; // unplugged — fall through to System OFF + } + + if (anyButtonPressed()) { + waitAllButtonsReleased(2000); + return true; // full wake to the main menu + } + + if (shownAt != 0 && millis() - shownAt >= CHARGE_DISPLAY_TIMEOUT_MS) { + DISPLAY_SLEEP(); + shownAt = 0; + } + if (shownAt != 0 && + millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { + displayLastUpdate = millis(); + displayPage_sleep_charging(); + } + + shutdownIdleWait(); + } +} + +// Bring the subsystems back after the charging loop — the chip never +// powered down, so this is the minimal mirror of the cold-boot bring-up. +// BLE and the camera stay down (both come up lazily on demand). +static void softResumeFromCharging() { + if (accelAvailable) { + digitalWrite(PIN_LSM6DS3TR_C_POWER, LOW); + delay(50); + if (accelIMU.begin() != 0) { + debugln(F("IMU failed to reinitialize after charging")); + accelAvailable = false; + } + } + + // The menu's steady state is race config. Set the targets directly so + // GPS_WAKE()'s GPS_RECONFIGURE() applies them (a shutdown from the GPS + // status page would otherwise resume at 5 Hz with NAV-SAT on). + gpsNavRateTarget = GPS_NAV_RATE_HZ; + gpsNavSatWanted = false; + gpsSatCnoCount = 0; + gpsSatUsedCount = 0; + GPS_WAKE(); + + DISPLAY_WAKE(); + menuIdleTimerRunning = false; + if (!sdSetupSuccess && sdCardUnformatted) { + // The format page's idle timeout landed us in the charging loop; the + // card still has no FAT, so resume back to the format offer — the main + // menu is useless (and misleading) when nothing can mount. + sd_format_page::begin(sdFormatState, millis()); + switchToDisplayPage(PAGE_SD_FORMAT); + return; + } + switchToDisplayPage(PAGE_MAIN_MENU); +} + +// Full shutdown: tear down every subsystem, then System OFF — or the +// charging loop when VBUS is present. May return (charging resume); +// callers in loop() must `return` right after so the frame restarts. +void enterShutdown() { + debugln(F("Shutdown")); + + // End race session if active (safety net — may write the DOVEX header) + if (raceActive) endRaceSession(); + wdtPet(); + + // Camera power-off streams a synchronous 3 s ce82 hold — the longest + // single teardown step, bracketed by pets against the ~4 s WDT. + CAMERA_SLEEP(); + wdtPet(); + + // Stop BLE if active (advertising/connection teardown) + if (bleActive) BLE_STOP(); + + // Display off (I2C command, ~10 µA panel sleep) + DISPLAY_SLEEP(); + + // GPS to software backup mode (µA; config retained while powered) and + // TIMER3 serial-drain stopped + GPS_SLEEP(); + + // IMU rail off (HIGH = power disabled) + if (accelAvailable) { + pinMode(PIN_LSM6DS3TR_C_POWER, OUTPUT); + digitalWrite(PIN_LSM6DS3TR_C_POWER, HIGH); + } + wdtPet(); + + // VBUS exception: never System OFF while a cable is present. Powering + // down would drop the software-held HICHG charge rate (when onboard + // charging is enabled) and, either way, VBUS is an always-armed System + // OFF wake source — entering OFF with the cable in risks an immediate + // wake-reset loop. + if (isUsbConnected()) { + if (runChargingShutdownLoop()) { + softResumeFromCharging(); + return; + } + // Cable pulled during the charging loop — power down for real. + } + + shutdownSystemOff(); // does not return +} + +/////////////////////////////////////////// +// MAIN LOOP +/////////////////////////////////////////// + +#ifdef HAS_DEBUG +unsigned long loopMaxTime = 0; +#endif + +void loop() { + #ifndef SIM + wdtPet(); + #endif + + #ifdef HAS_DEBUG + unsigned long loopStart = millis(); + #endif + + // When BLE is active, skip GPS/tach/lap processing for better throughput + if (bleActive) { + BLUETOOTH_LOOP(); + + // Keep battery voltage fresh for BLE BATT command and display + if (millis() - lastBatteryCheck > batteryUpdateInterval) { + lastBatteryCheck = millis(); + lastBatteryVoltage = getBatteryVoltage(); + } + + // Minimal button check for exit + readButtons(); + if (btn2->pressed) { + BLE_STOP(); + switchToDisplayPage(PAGE_MAIN_MENU); + } + resetButtons(); + + // Reduced display rate: 5s during transfer, normal otherwise + unsigned long displayInterval = bleTransferInProgress ? 5000 : (1000 / displayUpdateRateHz); + if (millis() - displayLastUpdate > displayInterval) { + displayLastUpdate = millis(); + displayPage_bluetooth(); + } + + return; // Skip GPS, tach, lap checks while BLE is active + } + + // When USB mass-storage is active the host PC owns the SD card over MSC. + // Park the loop exactly like the BLE branch so the firmware never drives + // the FAT concurrently with the host — GPS/tach/lap/SD processing is all + // skipped. (The SD-access policy would deny those acquires anyway, but + // parking here is the real guarantee rather than a side effect.) + if (usbMscActive) { + // Cable pulled without using the on-device Exit (the natural way to + // "finish") — tear down so the SD lock, the fast SPI clock, and the + // media-ready state don't leak into a later driving session and + // silently refuse to log. USB_MSC_DISABLE() reboots and does not return. + if (!isUsbConnected()) { + USB_MSC_DISABLE(); + } + + // Minimal button check for the on-device Exit (Select). + readButtons(); + if (btn2->pressed) { + USB_MSC_DISABLE(); // does not return (NVIC_SystemReset) + } + resetButtons(); + + // Refresh the status page at the normal rate. + if (millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { + displayLastUpdate = millis(); + displayPage_usb_storage(); + } + + 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) + + // Camera auto-stopped recording (30 s engine-off): end + save the race + // session and return to the menu — the camera stays connected in WATCHING, + // ready to re-record if the engine restarts. endRaceSession() is idempotent + // and does not switch pages itself, so we do (mirrors checkAutoIdle). A + // manual logging-stop does not set this — that path already ended the log. + if (raceActive && cameraConsumeAutoStop()) { + endRaceSession(); + switchToDisplayPage(PAGE_MAIN_MENU); + } + + // Button hold detection for shutdown/reboot combos + updateButtonHoldState(); + + // Long-press left+right (5s) on main menu -> shutdown + if (currentPage == PAGE_MAIN_MENU && + isButtonHeld(1, SLEEP_LONG_PRESS_MS) && + isButtonHeld(3, SLEEP_LONG_PRESS_MS)) { + enterShutdown(); + return; + } + + // Reboot combo: select + either side button held 5s (any page) + if (isButtonHeld(2, SLEEP_LONG_PRESS_MS) && + (isButtonHeld(1, SLEEP_LONG_PRESS_MS) || isButtonHeld(3, SLEEP_LONG_PRESS_MS))) { + NVIC_SystemReset(); + } + + // Main-menu idle tracking. Consumers: + // - USB present, onboard charging ENABLED: enter the charging loop after + // USB_MENU_CHARGE_IDLE_MS of no button activity. Not immediate — a + // charging-loop button wake lands here, and an instant re-entry would + // bounce it right back. The window is what lets replay/transfer be + // used while plugged in. Compiled out when charging is disabled: the + // firmware isn't managing the charge current, so a plugged-in cable is + // no reason to cut the menu short (the plain idle timeout still fires, + // and enterShutdown() parks on VBUS as always). + // - Always: full shutdown after SLEEP_IDLE_TIMEOUT_MS. + if (currentPage == PAGE_MAIN_MENU) { + if (!menuIdleTimerRunning) { + menuIdleTimerRunning = true; + menuIdleStartTime = millis(); + } + // Button activity resets the idle clock. The `pressed` flags can't be + // used here: readButtons() sets them AFTER this check runs and + // resetButtons() clears them before the next iteration, so they always + // read false at this point (menu navigation never reset the timer and + // the device slept 5 min after menu entry regardless of activity — + // 2026-07-19 field incident). The debouncer's lastPressed stamps + // persist across iterations, so anchor on the newest of those. + unsigned long lastBtn = btn1->lastPressed; + if ((long)(btn2->lastPressed - lastBtn) > 0) lastBtn = btn2->lastPressed; + if ((long)(btn3->lastPressed - lastBtn) > 0) lastBtn = btn3->lastPressed; + if ((long)(lastBtn - menuIdleStartTime) > 0) { + menuIdleStartTime = lastBtn; + } + unsigned long idleFor = millis() - menuIdleStartTime; +#if BIRDSEYE_ENABLE_ONBOARD_CHARGING + if ((isUsbConnected() && idleFor >= USB_MENU_CHARGE_IDLE_MS) || + idleFor >= SLEEP_IDLE_TIMEOUT_MS) { +#else + if (idleFor >= SLEEP_IDLE_TIMEOUT_MS) { +#endif + enterShutdown(); + return; + } + } else { + menuIdleTimerRunning = false; + } + + 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(); + resetButtons(); + + if (tachLastReported > topTachReported) { + topTachReported = tachLastReported; + } + + #ifdef HAS_DEBUG + unsigned long loopElapsed = millis() - loopStart; + if (loopElapsed > loopMaxTime) { + loopMaxTime = loopElapsed; + } + if (loopElapsed > 100) { + debug(F("SLOW LOOP: ")); + debug(loopElapsed); + debug(F("ms (max: ")); + debug(loopMaxTime); + debugln(F("ms)")); + } + #endif +} diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino index b01f2b6..9824720 100644 --- a/BirdsEye/bluetooth.ino +++ b/BirdsEye/bluetooth.ino @@ -1,1139 +1,1171 @@ -/////////////////////////////////////////// -// BLUETOOTH (BLE) MODULE -// All BLE-related functions: callbacks, setup, file transfer, loop -/////////////////////////////////////////// - -#include "bluetooth.h" -#include "camera_ble.h" -#include "filename_validator.h" -#include "firmware_ota.h" - -// Deferred settings command buffer (BLE callback -> main loop) -static volatile bool settingsCmdPending = false; -static char settingsCmdBuffer[65]; // 64 chars + null - -// Track upload state (BLE callback -> main loop) -static volatile bool trackUploadActive = false; -static volatile bool trackUploadReady = false; // signals main loop to send TREADY -static volatile bool trackUploadComplete = false; // signals main loop to write file -static volatile bool trackUploadError = false; -static char trackUploadFilename[25]; // just the filename (e.g. "OKC.json") -static char trackUploadBuffer[4096]; -static volatile uint16_t trackUploadOffset = 0; - -// Track delete state (BLE callback -> main loop) -static volatile bool trackDeletePending = false; -static char trackDeleteFilename[25]; - -// Deferred file command buffer (BLE callback -> main loop). Carries the -// SD-touching commands (LIST / GET: / DELETE: / TLIST / TGET:) so SdFat is -// only ever driven from the main-loop task — the Bluefruit callback task -// can preempt an in-flight SD write, and SdFat is not thread-safe. -static volatile bool fileCmdPending = false; -static char fileCmdBuffer[65]; - -// Queue an SD-touching command for BLUETOOTH_LOOP(). Returns false if one -// is already pending — the caller sends its protocol-appropriate busy reply. -// The buffer is stable once fileCmdPending is set: the callback refuses new -// commands until the main loop has processed it and cleared the flag. -static bool deferFileCommand(const char* cmd) { - if (fileCmdPending) return false; - strncpy(fileCmdBuffer, cmd, sizeof(fileCmdBuffer) - 1); - fileCmdBuffer[sizeof(fileCmdBuffer) - 1] = '\0'; - fileCmdPending = true; - return true; -} - -// Set by the disconnect callback; BLUETOOTH_LOOP() performs the SD teardown -// (close transfer/staging file, release SD, abort OTA) and the auto-reboot -// on the main loop, so SdFat is only ever touched by one task. -static volatile bool bleDisconnectCleanupPending = false; - -// True once a transfer peer actually DROVE the file/settings/OTA service -// (any fileRequestChar write while the transfer owns the radio). Gates the -// auto-reboot-on-disconnect: a bonded camera that connects to the transfer -// advert (the radio has one BD_ADDR, so the X4 can chase it) and vets our -// GATT then drops must NOT reboot the logger out of the user's transfer -// session — repeatedly, if the camera keeps retrying (#1). A peer that never -// touched the service also held no SD, so skipping the teardown is safe. -static volatile bool bleTransferEngaged = false; - -void bleConnectCallback(uint16_t conn_handle) { - // Camera-owned link (the X4 connecting to our remote GATT) — route to the - // camera module and skip everything below: bleConnected and the MTU/PHY/ - // DLE negotiation are transfer-only. - if (bleOwner == BLE_OWNER_CAMERA) { - cameraBleOnConnect(conn_handle); - return; - } - - debugln(F("BLE: Device connected!")); - bleConnected = true; - bleTransferEngaged = false; // this peer hasn't used the service yet - - BLEConnection* connection = Bluefruit.Connection(conn_handle); - - debug(F("BLE: Initial MTU: ")); - debugln(connection->getMtu()); - - // Request MTU exchange - result will be read in BLUETOOTH_LOOP() after 500ms - debugln(F("BLE: Requesting MTU exchange to 247...")); - if (connection->requestMtuExchange(247)) { - debugln(F("BLE: MTU exchange requested successfully")); - } else { - debugln(F("BLE: MTU exchange request failed!")); - } - - // 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) - connection->requestDataLengthUpdate(); - - // Defer MTU read to main loop instead of blocking here with delay(500) - bleWaitingForMTU = true; - bleMTURequestTime = millis(); - bleMTUConnHandle = conn_handle; - - debug(F("BLE: Connection interval: ")); - debug(connection->getConnectionInterval() * 1.25); - debugln(F("ms")); -} - -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 - // BLUETOOTH_LOOP()) structurally unreachable for camera links: a camera - // dropping off must never reboot the logger mid-session. Matched BY - // HANDLE first, not owner: a teardown-initiated camera disconnect - // completes asynchronously, so its event can land after ownership has - // already moved to NONE/TRANSFER (e.g. CAMERA_FORCE_RELEASE() - // immediately followed by BLE_SETUP() on the transfer page) — owner - // routing alone would misdeliver it here and reboot the device. - if (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA) { - cameraBleOnDisconnect(conn_handle, reason); - return; - } - - debugln(F("BLE: Disconnected!")); - bleConnected = false; - bleNegotiatedMtu = 23; // Reset to default - - // Pure in-RAM flag resets are safe from this callback (Bluefruit) task. - bleTransferInProgress = false; - trackUploadActive = false; - trackUploadReady = false; - trackUploadComplete = false; - trackUploadError = false; - trackDeletePending = false; - // Drop any queued-but-unprocessed commands so they can't fire on behalf - // of a peer that is no longer connected (or after a reconnect). - fileCmdPending = false; - settingsCmdPending = false; - - // Everything that touches SdFat — closing the in-flight transfer/staging - // file, releasing SD access, aborting the OTA — plus the auto-reboot is - // DEFERRED to BLUETOOTH_LOOP() on the main loop. This callback runs in the - // Bluefruit task, which can preempt an in-flight SD write in the main loop, - // and SdFat is not thread-safe. If this was a local BLE_STOP() (which sets - // bleActive=false before disconnecting), BLE_STOP() already did the - // teardown on the main loop, so there is nothing to defer. - // - // Only reboot for a peer that actually USED the transfer service. A bonded - // camera can land on the transfer advert (shared BD_ADDR) and be routed - // here as "the phone" when it drops; without this gate its disconnect would - // reboot the logger mid-transfer, over and over (#1). A never-engaged peer - // also held no SD, so there is nothing to tear down. - if (bleActive && bleTransferEngaged) { - bleDisconnectCleanupPending = true; - } - bleTransferEngaged = false; // reset for the next peer -} - -// Forward declaration for callback -void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len); - -void bleSetupFileService() { - fileService.begin(); - - // File List Characteristic - fileListChar.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); - fileListChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); - fileListChar.setMaxLen(244); - fileListChar.begin(); - - // File Request Characteristic. Max length is 244 so the firmware-OTA - // path can receive ~240-byte raw image chunks (text commands and the - // legacy 64-byte track-upload chunks fit comfortably inside this). - fileRequestChar.setProperties(CHR_PROPS_WRITE | CHR_PROPS_WRITE_WO_RESP); - fileRequestChar.setPermission(SECMODE_NO_ACCESS, SECMODE_OPEN); - fileRequestChar.setMaxLen(244); - fileRequestChar.setWriteCallback(bleFileRequestCallback); - fileRequestChar.begin(); - - // File Data Characteristic - fileDataChar.setProperties(CHR_PROPS_NOTIFY); - fileDataChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); - fileDataChar.setMaxLen(244); - fileDataChar.begin(); - - // File Status Characteristic - fileStatusChar.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); - fileStatusChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); - fileStatusChar.setMaxLen(64); - fileStatusChar.begin(); -} - -void bleAdvFinalizePadded() { - // WORKAROUND for a Bluefruit 0.21.0 core bug (fixed upstream in - // Adafruit 1.7.0, but the Seeed fork ships the broken version): - // BLEAdvertising::_start() initializes its ble_gap_adv_data_t as a - // function-local STATIC, so both packet .len fields freeze at whatever - // the FIRST advert of the boot carried. Any later advert of a - // different length goes on air truncated or with a stale tail — a - // malformed PDU every receiver silently discards, while all our API - // calls report success. (This is what broke the camera wake advert: - // a 28-byte connect advert first froze the length, then the 31-byte - // wake PDU lost its last 3 bytes on air.) - // - // Defeat it by construction: EVERY advert in this firmware is padded - // to exactly 31+31 bytes before start(), so the frozen length is - // always correct. Zero padding after the last AD structure is - // explicitly legal (BT Core Spec Vol 3 Part C §11: the non-significant - // part is all-zero octets). Call this after building the payload - // (additive or raw setData) and immediately before Advertising.start(). - uint8_t buf[BLE_GAP_ADV_SET_DATA_SIZE_MAX] = {0}; - uint8_t n = Bluefruit.Advertising.count(); - memcpy(buf, Bluefruit.Advertising.getData(), n); - Bluefruit.Advertising.setData(buf, sizeof(buf)); - - memset(buf, 0, sizeof(buf)); - n = Bluefruit.ScanResponse.count(); - memcpy(buf, Bluefruit.ScanResponse.getData(), n); - Bluefruit.ScanResponse.setData(buf, sizeof(buf)); -} - -void bleApplyTransferAdvertising() { - // Full rebuild, not an incremental start: the camera module may have - // owned the advert set (name + payload) since the last transfer session, - // so drop whatever is there and reconstruct the transfer advert exactly. - Bluefruit.Advertising.stop(); // safe no-op if not advertising - Bluefruit.Advertising.clearData(); - Bluefruit.ScanResponse.clearData(); - - char bleName[32]; - if (getSetting("bluetooth_name", bleName, sizeof(bleName))) { - debug(F("BLE: Name from settings: ")); - debugln(bleName); - Bluefruit.setName(bleName); - } else { - debugln(F("BLE: WARNING - bluetooth_name not found, using fallback")); - Bluefruit.setName("DovesDataLogger"); - } - - // Force connectable: the shared Advertising object may have been left - // non-connectable by a camera wake burst (see kStartWakeBurst in - // camera_ble.ino), which would otherwise make this transfer advert - // unconnectable. - Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED); - Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); - Bluefruit.Advertising.addTxPower(); - Bluefruit.Advertising.addService(fileService); - Bluefruit.Advertising.addName(); - - Bluefruit.Advertising.restartOnDisconnect(true); - Bluefruit.Advertising.setInterval(32, 244); - Bluefruit.Advertising.setFastTimeout(30); - bleAdvFinalizePadded(); // every advert must be 31+31 — see the helper - Bluefruit.Advertising.start(0); -} - -void bleSendFileList() { - // Runs on the main loop (deferred via fileCmdBuffer). Hold the SD lock - // for the entire walk — the delay(10) per entry yields to other tasks, - // so ownership must be held, not just peeked. The explicit free-check - // first keeps the idempotent/preempting acquire from piggybacking on an - // active transfer or stealing a track parse. - if (currentSDAccess != SD_ACCESS_NONE || - !acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD busy, cannot list files")); - fileListChar.notify((uint8_t*)"BUSY", 4); - return; - } - - File32 root = SD.open("/"); - if (!root) { - debugln(F("BLE: Failed to open root directory")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileListChar.notify((uint8_t*)"BUSY", 4); - return; - } - - // Stream entries directly over BLE using a fixed buffer per entry - // instead of building one giant String (avoids heap fragmentation - // that was silently truncating the file list) - char entryBuf[300]; - int fileCount = 0; - bool firstEntry = true; - - while (true) { - File32 entry = root.openNextFile(); - if (!entry) break; - - if (!entry.isDirectory()) { - char name[256]; - entry.getName(name, sizeof(name)); - - // Build single entry: "|name:size" (skip | for first entry) - int len = snprintf(entryBuf, sizeof(entryBuf), "%s%s:%lu", - firstEntry ? "" : "|", - name, - (unsigned long)entry.size()); - firstEntry = false; - - if (len > 0 && len < (int)sizeof(entryBuf)) { - fileListChar.notify((uint8_t*)entryBuf, len); - delay(10); - fileCount++; - } - } - entry.close(); - } - root.close(); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - - fileListChar.notify((uint8_t*)"END", 3); - debug(F("BLE: File list sent, ")); - debug(fileCount); - debugln(F(" files")); -} - -void bleSendTrackList() { - // Same locking discipline as bleSendFileList() — see the comment there. - if (currentSDAccess != SD_ACCESS_NONE || - !acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD busy, cannot list tracks")); - fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); - return; - } - - File32 trackDir2 = SD.open("/TRACKS/"); - if (!trackDir2) { - debugln(F("BLE: Failed to open TRACKS directory")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileStatusChar.notify((uint8_t*)"TEND", 4); - return; - } - - int fileCount = 0; - while (true) { - File32 entry = trackDir2.openNextFile(); - if (!entry) break; - - if (!entry.isDirectory()) { - char name[64]; - entry.getName(name, sizeof(name)); - - char msg[70]; - int len = snprintf(msg, sizeof(msg), "TFILE:%s", name); - if (len > 0 && len < (int)sizeof(msg)) { - fileStatusChar.notify((uint8_t*)msg, len); - delay(10); - fileCount++; - } - } - entry.close(); - } - trackDir2.close(); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - - fileStatusChar.notify((uint8_t*)"TEND", 4); - debug(F("BLE: Track list sent, ")); - debug(fileCount); - debugln(F(" files")); -} - -void bleStartFileTransfer(const char* filename) { - if (bleCurrentFile) bleCurrentFile.close(); - - // Check if we can acquire SD access for BLE transfer - if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD card busy - cannot start transfer")); - fileStatusChar.notify((uint8_t*)"BUSY", 4); - return; - } - - debug(F("BLE: Opening file: [")); - debug(filename); - debugln(F("]")); - - bleCurrentFile = SD.open(filename, FILE_READ); - - if (!bleCurrentFile) { - debugln(F("BLE: Failed to open file!")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); // Release on failure - fileStatusChar.notify((uint8_t*)"ERROR", 5); - return; - } - - bleFileSize = bleCurrentFile.size(); - bleBytesTransferred = 0; - bleTransferInProgress = true; - - debug(F("BLE: File size: ")); - debug(bleFileSize); - debug(F(" bytes, MTU: ")); - debugln(bleNegotiatedMtu); - - char sizeMsg[32]; - snprintf(sizeMsg, sizeof(sizeMsg), "SIZE:%lu", bleFileSize); - fileStatusChar.notify((uint8_t*)sizeMsg, strlen(sizeMsg)); -} - -void bleDeleteFile(const char* filename) { - debug(F("BLE: Deleting file: [")); - debug(filename); - debugln(F("]")); - - // An active transfer holds SD_ACCESS_BLE_TRANSFER, and the same-mode - // re-acquire below would succeed — guard explicitly so a DELETE can't - // remove the file being streamed and then drop the transfer's lock. - if (bleTransferInProgress) { - debugln(F("BLE: transfer in progress, cannot delete")); - fileStatusChar.notify((uint8_t*)"BUSY", 4); - return; - } - if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD busy, cannot delete")); - fileStatusChar.notify((uint8_t*)"BUSY", 4); - return; - } - - if (SD.exists(filename)) { - if (SD.remove(filename)) { - debugln(F("BLE: File deleted successfully")); - fileStatusChar.notify((uint8_t*)"DELETED", 7); - } else { - debugln(F("BLE: Failed to delete file")); - fileStatusChar.notify((uint8_t*)"DEL_ERR", 7); - } - } else { - debugln(F("BLE: File not found")); - fileStatusChar.notify((uint8_t*)"NOT_FOUND", 9); - } - - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); -} - -void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len) { - // Only serve the file service while the transfer page owns the radio. A - // peer that connects during camera mode must not queue deferred SD work — - // BLUETOOTH_LOOP() is gated on bleActive and would never drain it. - if (bleOwner != BLE_OWNER_TRANSFER) return; - - // A write to the request characteristic means this is a genuine transfer - // peer (the phone app), not a bonded camera vetting our GATT — arm the - // reboot-on-disconnect gate (#1). - bleTransferEngaged = true; - - char buffer[65]; - memset(buffer, 0, sizeof(buffer)); - uint16_t copyLen = len < 64 ? len : 64; - memcpy(buffer, data, copyLen); - - // Trim trailing whitespace/newlines in-place - int end = strlen(buffer) - 1; - while (end >= 0 && (buffer[end] == ' ' || buffer[end] == '\r' || buffer[end] == '\n')) { - buffer[end--] = '\0'; - } - - // Handle upload data mode — all writes are raw data until TDONE - if (trackUploadActive) { - if (strncmp(buffer, "TDONE", 5) == 0 && len <= 6) { - debugln(F("BLE: TDONE received")); - trackUploadComplete = true; - return; - } - // Append raw data to buffer - if (trackUploadOffset + len <= sizeof(trackUploadBuffer)) { - memcpy(trackUploadBuffer + trackUploadOffset, data, len); - trackUploadOffset += len; - } else { - trackUploadError = true; - } - return; - } - - // Firmware OTA image stream: while receiving, every write is raw image - // data EXCEPT the short FWDONE / FWABORT control tokens. Bounding the - // token match by length keeps a full binary chunk from being mistaken for - // a command (mirrors the TPUT/TDONE convention above). - if (fwReceiving()) { - if (len <= 8 && (strcmp(buffer, "FWDONE") == 0 || strcmp(buffer, "FWABORT") == 0)) { - fwHandleCommand(buffer, len); - } else { - fwReceiveChunk(data, len); - } - return; - } - - // A command longer than the parse buffer was truncated by the memcpy - // above. The raw-data paths (track upload / OTA image) returned already, - // so anything this long is a malformed command — never a valid filename - // command (those are FAT-short). Reject rather than validate and act on a - // silently-mangled name. (len <= 64 fits buffer[65] with NUL intact.) - if (len >= sizeof(buffer)) { - debugln(F("BLE: command too long, rejecting")); - fileStatusChar.notify((uint8_t*)"ERROR", 5); - return; - } - - debug(F("BLE: Received command: [")); - debug(buffer); - debugln(F("]")); - - // File commands (LIST/GET/DELETE/TLIST/TGET) all touch SD, so they are - // DEFERRED to BLUETOOTH_LOOP() via deferFileCommand() — SdFat must never - // run in this Bluefruit callback task. Filename validation is RAM-only - // and stays here so bad names are rejected immediately. - if (strncmp(buffer, "LIST", 4) == 0) { - if (!deferFileCommand(buffer)) { - fileListChar.notify((uint8_t*)"BUSY", 4); - } - } else if (strncmp(buffer, "GET:", 4) == 0) { - // Skip "GET:" prefix and trim leading whitespace - char* filename = buffer + 4; - while (*filename == ' ') filename++; - // Reject path traversal / FAT-unsafe names before touching SD. - if (!filename_validator::isValidFilename(filename, filename_validator::kMaxBleFilenameLen)) { - debugln(F("BLE: GET rejected — bad filename")); - fileStatusChar.notify((uint8_t*)"ERROR", 5); - return; - } - if (!deferFileCommand(buffer)) { - fileStatusChar.notify((uint8_t*)"BUSY", 4); - } - } else if (strncmp(buffer, "DELETE:", 7) == 0) { - // Skip "DELETE:" prefix and trim leading whitespace - char* filename = buffer + 7; - while (*filename == ' ') filename++; - if (!filename_validator::isValidFilename(filename, filename_validator::kMaxBleFilenameLen)) { - debugln(F("BLE: DELETE rejected — bad filename")); - fileStatusChar.notify((uint8_t*)"NOT_FOUND", 9); - return; - } - if (!deferFileCommand(buffer)) { - fileStatusChar.notify((uint8_t*)"BUSY", 4); - } - } else if (strcmp(buffer, "SLIST") == 0 || - strncmp(buffer, "SGET:", 5) == 0 || - strncmp(buffer, "SSET:", 5) == 0 || - strcmp(buffer, "SRESET") == 0) { - // Settings commands — defer to main loop for thread-safe SD access - if (settingsCmdPending) { - fileStatusChar.notify((uint8_t*)"SBUSY", 5); - return; - } - strncpy(settingsCmdBuffer, buffer, sizeof(settingsCmdBuffer) - 1); - settingsCmdBuffer[sizeof(settingsCmdBuffer) - 1] = '\0'; - settingsCmdPending = true; - - // Track management commands - } else if (strcmp(buffer, "TLIST") == 0) { - if (!deferFileCommand(buffer)) { - fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); - } - } else if (strncmp(buffer, "TGET:", 5) == 0) { - // The name is spliced into "/TRACKS/%s"; validate it so it can't - // climb out of /TRACKS via ../ or carry FAT-unsafe characters. - if (!filename_validator::isValidFilename(buffer + 5, filename_validator::kMaxBleFilenameLen)) { - debugln(F("BLE: TGET rejected — bad filename")); - fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); - return; - } - if (!deferFileCommand(buffer)) { - fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); - } - } else if (strncmp(buffer, "TPUT:", 5) == 0) { - if (trackUploadActive || bleTransferInProgress) { - fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); - return; - } - if (!filename_validator::isValidFilename(buffer + 5, filename_validator::kMaxBleFilenameLen)) { - debugln(F("BLE: TPUT rejected — bad filename")); - fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); - return; - } - strncpy(trackUploadFilename, buffer + 5, sizeof(trackUploadFilename) - 1); - trackUploadFilename[sizeof(trackUploadFilename) - 1] = '\0'; - trackUploadOffset = 0; - trackUploadError = false; - trackUploadComplete = false; - trackUploadReady = true; - trackUploadActive = true; - debugln(F("BLE: Track upload started")); - } else if (strncmp(buffer, "TDEL:", 5) == 0) { - if (trackDeletePending) { - fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); - return; - } - if (!filename_validator::isValidFilename(buffer + 5, filename_validator::kMaxBleFilenameLen)) { - debugln(F("BLE: TDEL rejected — bad filename")); - fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); - return; - } - strncpy(trackDeleteFilename, buffer + 5, sizeof(trackDeleteFilename) - 1); - trackDeleteFilename[sizeof(trackDeleteFilename) - 1] = '\0'; - trackDeletePending = true; - - // Battery query — no SD access needed, uses cached voltage - } else if (strcmp(buffer, "BATT") == 0) { - int pct = getBatteryPercent(lastBatteryVoltage); - char vbuf[8]; - dtostrf(lastBatteryVoltage, 4, 2, vbuf); - char response[24]; - snprintf(response, sizeof(response), "BATT:%d,%s", pct, vbuf); - fileStatusChar.notify((uint8_t*)response, strlen(response)); - - // Firmware OTA commands (FWBEGIN/FWPUT/FWDONE/FWAPPLY/FWABORT). Parsing - // and synchronous replies happen here; SD writes + apply are deferred to - // FW_OTA_LOOP() on the main loop. - } else if (fwIsCommand(buffer)) { - fwHandleCommand(buffer, len); - } -} - -// Just-Works pairing result trace (Bluefruit pair-complete callback). -// Signature is plain integers, so no Bluefruit-type forward declaration is -// needed. auth_status == BLE_GAP_SEC_STATUS_SUCCESS (0) means bonded. -void blePairCompleteCallback(uint16_t conn_hdl, uint8_t auth_status) { - (void)conn_hdl; - debug(F("BLE: pairing complete, auth_status=0x")); - debugln(auth_status, HEX); -} - -void bleCoreEnsureInit() { - if (bleInitialized) return; - - debugln(F("BLE: Initializing Bluetooth core...")); - - // Custom BLE config for max file transfer throughput: - // MTU 247, event_len 100 (125ms max radio time per event), - // HVN TX queue 10 (up from BANDWIDTH_MAX's 3 — deeper notification pipeline), - // WrCmd queue 1 (default, we don't use write commands). - Bluefruit.configPrphConn(247, 100, 10, 1); - // 1 peripheral + 0 central: the camera feature is now a pure PERIPHERAL - // remote emulation (the camera connects to US and we notify our ce82 - // buttons), so the old central slot for the X4's be80 control link is - // gone. Both the transfer service and the camera remote are peripherals - // sharing the single peripheral slot via bleOwner. - Bluefruit.begin(1, 0); - Bluefruit.setTxPower(4); - - Bluefruit.Periph.setConnectCallback(bleConnectCallback); - Bluefruit.Periph.setDisconnectCallback(bleDisconnectCallback); - - // Just-Works pairing acceptance (peripheral). The genuine Insta360 GPS - // Remote link is encrypted + bonded, and a captured X4 brings up - // encryption immediately on connect, so the camera (as central) may - // withhold its ce82 CCCD subscription until the link is secured. Advertise - // NoInputNoOutput I/O capabilities and no MITM requirement so the - // SoftDevice completes Just-Works pairing without any on-device prompt. - // This is link-level only — NO characteristic is marked encrypted - // (SECMODE_OPEN everywhere), so the file-transfer service keeps working - // fully open/unbonded. NOTE (Bluefruit 0.21.0 assumption): NoInputNoOutput - // + MITM-off is already Bluefruit's default and yields Just-Works; setting - // it explicitly documents intent and guards against a future default - // change. The pair-complete callback is trace-only. - Bluefruit.Security.setIOCaps(false, false, false); // display, yes/no, keyboard - Bluefruit.Security.setMITM(false); - Bluefruit.Security.setPairCompleteCallback(blePairCompleteCallback); - - // Set connection interval (7.5-15ms) - Bluefruit.Periph.setConnInterval(6, 12); - - // Buttonless OTA DFU. Registers the Secure DFU service so a companion - // (DovesDataViewer over Web Bluetooth) can write the "enter bootloader" - // command and reboot the board into the bootloader's Nordic Secure DFU - // mode — no physical double-tap of reset required. The bootloader then - // receives the firmware image and flashes it. Added before the app - // service so it is registered when advertising starts. - bledfu.begin(); - - // Device Information Service (0x180A). Publishes the firmware version - // via the standard Firmware Revision characteristic (0x2A26) so the - // companion can read it and compare against the latest GitHub release - // to decide whether an OTA update is needed. - bledis.setManufacturer("DovesDataLogger"); - // Model encodes the board variant ("BirdsEye-sense" / "BirdsEye-nonsense") - // so the companion can pick the matching OTA package. - bledis.setModel("BirdsEye-" FIRMWARE_VARIANT); - bledis.setFirmwareRev(FIRMWARE_VERSION); - bledis.begin(); - - bleSetupFileService(); - - // Camera remote GATT (peripheral ce80 + D0FF services) plus the central - // client objects for the camera's be80 service. GATT services can only - // be added before advertising starts, so they are registered here even - // when the user never touches the camera feature. - cameraBleRegisterServices(); - - bleInitialized = true; - - // Deliberately NO advertising, NO device name, NO conn-LED here — the - // owner (transfer page or camera module) applies its own advert set. -} - -void BLE_SETUP() { - // Parked transfer — bump the SD clock for faster file transfers. Reverted - // in BLE_STOP() (and by the auto-reboot on phone disconnect). - sdSetTransferSpeed(true); - - debugln(F("BLE: Starting transfer mode...")); - - bleCoreEnsureInit(); - - // Take the radio for the transfer service (main-loop context — the camera - // module released its links via CAMERA_FORCE_RELEASE() before this page - // opened). The full advert rebuild below is what makes re-entry correct - // even when the camera owned the advert set in between. - bleOwner = BLE_OWNER_TRANSFER; - - // Enable connection LED - Bluefruit.autoConnLed(true); - Bluefruit.setConnLedInterval(250); // Blink every 250ms when connected - - bleApplyTransferAdvertising(); - - bleActive = true; - - debugln(F("BLE: Ready for connection!")); -} - -void BLE_STOP() { - if (!bleActive) return; - - debugln(F("BLE: Stopping Bluetooth...")); - - // Mark inactive BEFORE disconnect so the async bleDisconnectCallback - // knows this was a local stop (not a phone disconnect) and skips reboot. - bleActive = false; - - // Close any open file and release SD access (main-loop context — BLE_STOP() - // is called from the loop, so SdFat access here is safe). The disconnect - // callback skips its deferred teardown when bleActive is already false, so - // this is the single owner of the local-stop teardown. - if (bleCurrentFile) { - bleCurrentFile.close(); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - } - bleTransferInProgress = false; - bleTransferEngaged = false; // session is over — no reboot owed - fwReset(); // abort any in-flight OTA (closes staging file, frees SD) - - // Drop queued-but-unprocessed commands so a stale one can't execute on - // the next BLE session (BLUETOOTH_LOOP stops running once bleActive is - // false, so nothing would clear them otherwise). - fileCmdPending = false; - settingsCmdPending = false; - - // Disarm auto-restart BEFORE disconnecting. bleApplyTransferAdvertising() - // set restartOnDisconnect(true) so a mid-session phone drop re-advertises; - // but here we are deliberately tearing the transfer service down. The - // disconnect below is async, so if we left it armed Bluefruit's internal - // handler would restart an ownerless transfer advert AFTER we stop it — - // the phone would reconnect into a mute session (owner already NONE) and - // the occupied peripheral slot would block camera auto-record until a - // power cycle. - Bluefruit.Advertising.restartOnDisconnect(false); - - // Disconnect any connected device - if (Bluefruit.connected()) { - Bluefruit.disconnect(Bluefruit.connHandle()); - // BLE disconnect is async; no delay needed - stack handles it - } - - // Stop advertising - Bluefruit.Advertising.stop(); - - // Turn off the BLE LED - Bluefruit.autoConnLed(false); - Bluefruit.setConnLedInterval(0); - digitalWrite(LED_BLUE, HIGH); - - bleConnected = false; - // bleActive already set false at top of BLE_STOP() - - // Release radio ownership. The camera module re-acquires it on its next - // advertising action (in CAMERA_LOOP()) — nothing to hand off here. - bleOwner = BLE_OWNER_NONE; - - // Restore the EMI-safe SD clock now that the transfer session is over. - sdSetTransferSpeed(false); - - debugln(F("BLE: Bluetooth stopped")); -} - -// Execute a deferred file command (main-loop context — the only place -// SdFat may be touched). The filename was validated in the callback and -// the buffer is stable while fileCmdPending is set. -static void processFileCommand() { - debug(F("BLE: Processing file cmd: [")); - debug(fileCmdBuffer); - debugln(F("]")); - - if (strncmp(fileCmdBuffer, "LIST", 4) == 0) { - bleSendFileList(); - } else if (strncmp(fileCmdBuffer, "GET:", 4) == 0) { - char* filename = fileCmdBuffer + 4; - while (*filename == ' ') filename++; - bleStartFileTransfer(filename); - } else if (strncmp(fileCmdBuffer, "DELETE:", 7) == 0) { - char* filename = fileCmdBuffer + 7; - while (*filename == ' ') filename++; - bleDeleteFile(filename); - } else if (strcmp(fileCmdBuffer, "TLIST") == 0) { - bleSendTrackList(); - } else if (strncmp(fileCmdBuffer, "TGET:", 5) == 0) { - char filepath[FILEPATH_MAX]; - snprintf(filepath, sizeof(filepath), "/TRACKS/%s", fileCmdBuffer + 5); - bleStartFileTransfer(filepath); - } -} - -void processSettingsCommand() { - debug(F("BLE: Processing settings cmd: [")); - debug(settingsCmdBuffer); - debugln(F("]")); - - if (strcmp(settingsCmdBuffer, "SLIST") == 0) { - debugln(F("BLE: SLIST - listing all settings")); - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("BLE: SLIST - SD busy")); - fileStatusChar.notify((uint8_t*)"SERR:SD_BUSY", 12); - return; - } - - File settingsFile; - settingsFile.open("/SETTINGS.json", O_READ); - if (!settingsFile) { - debugln(F("BLE: SLIST - failed to open settings file")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - fileStatusChar.notify((uint8_t*)"SERR:NO_FILE", 12); - return; - } - - char fileBuf[512]; - int bytesRead = settingsFile.read(fileBuf, sizeof(fileBuf) - 1); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("BLE: SLIST - read ")); - debug(bytesRead); - debugln(F(" bytes")); - - if (bytesRead <= 0) { - debugln(F("BLE: SLIST - file empty")); - fileStatusChar.notify((uint8_t*)"SERR:EMPTY", 10); - return; - } - fileBuf[bytesRead] = '\0'; - - StaticJsonDocument<512> doc; - DeserializationError err = deserializeJson(doc, fileBuf); - if (err != DeserializationError::Ok) { - debug(F("BLE: SLIST - JSON parse error: ")); - debugln(err.c_str()); - fileStatusChar.notify((uint8_t*)"SERR:PARSE", 10); - return; - } - - int count = 0; - for (JsonPair kv : doc.as()) { - char entry[64]; - snprintf(entry, sizeof(entry), "SVAL:%s=%s", kv.key().c_str(), kv.value().as()); - debug(F("BLE: SLIST - sending: ")); - debugln(entry); - fileStatusChar.notify((uint8_t*)entry, strlen(entry)); - delay(10); // BLE notify spacing - count++; - } - debugln(F("BLE: SLIST - sending SEND")); - fileStatusChar.notify((uint8_t*)"SEND", 4); - debug(F("BLE: SLIST - done, sent ")); - debug(count); - debugln(F(" entries")); - - } else if (strncmp(settingsCmdBuffer, "SGET:", 5) == 0) { - char* key = settingsCmdBuffer + 5; - debug(F("BLE: SGET - key: [")); - debug(key); - debugln(F("]")); - - char valueBuf[48]; - if (getSetting(key, valueBuf, sizeof(valueBuf))) { - char response[64]; - snprintf(response, sizeof(response), "SVAL:%s=%s", key, valueBuf); - debug(F("BLE: SGET - responding: ")); - debugln(response); - fileStatusChar.notify((uint8_t*)response, strlen(response)); - } else { - debugln(F("BLE: SGET - key not found")); - fileStatusChar.notify((uint8_t*)"SERR:NOT_FOUND", 14); - } - - } else if (strncmp(settingsCmdBuffer, "SSET:", 5) == 0) { - char* payload = settingsCmdBuffer + 5; - char* eq = strchr(payload, '='); - if (!eq) { - debugln(F("BLE: SSET - missing '=' in command")); - fileStatusChar.notify((uint8_t*)"SERR:BAD_CMD", 12); - return; - } - *eq = '\0'; - char* key = payload; - char* value = eq + 1; - - debug(F("BLE: SSET - key: [")); - debug(key); - debug(F("] value: [")); - debug(value); - debugln(F("]")); - - if (setSetting(key, value)) { - char response[64]; - snprintf(response, sizeof(response), "SOK:%s", key); - debug(F("BLE: SSET - success: ")); - debugln(response); - fileStatusChar.notify((uint8_t*)response, strlen(response)); - } else { - debugln(F("BLE: SSET - write failed")); - fileStatusChar.notify((uint8_t*)"SERR:WRITE_FAIL", 15); - } - } else if (strcmp(settingsCmdBuffer, "SRESET") == 0) { - debugln(F("BLE: SRESET - resetting all settings to defaults")); - if (resetSettings()) { - fileStatusChar.notify((uint8_t*)"SOK:RESET", 9); - debugln(F("BLE: Settings reset, rebooting in 200ms...")); - delay(200); // Let the notification reach the phone - NVIC_SystemReset(); - } else { - fileStatusChar.notify((uint8_t*)"SERR:RESET_FAIL", 15); - } - } else { - debug(F("BLE: Unknown settings cmd: [")); - debug(settingsCmdBuffer); - debugln(F("]")); - } -} - -void processTrackUpload() { - debug(F("BLE: Writing track file: [")); - debug(trackUploadFilename); - debug(F("] size: ")); - debugln(trackUploadOffset); - - if (trackUploadError) { - debugln(F("BLE: Track upload too large")); - fileStatusChar.notify((uint8_t*)"TERR:TOO_LARGE", 14); - trackUploadActive = false; - trackUploadComplete = false; - trackUploadError = false; - return; - } - - if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD busy, cannot write track")); - fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); - trackUploadActive = false; - trackUploadComplete = false; - return; - } - - char filepath[FILEPATH_MAX]; - snprintf(filepath, sizeof(filepath), "/TRACKS/%s", trackUploadFilename); - - // Belt-and-suspenders: buildTrackList() provisions the folder at boot, - // but re-ensure it before every upload so a missing folder can never - // fail a TPUT with WRITE_FAIL. Return deliberately ignored. - sdEnsureTracksFolder(); - - // Delete existing file if present - if (SD.exists(filepath)) { - SD.remove(filepath); - } - - File32 outFile = SD.open(filepath, FILE_WRITE); - if (!outFile) { - debugln(F("BLE: Failed to create track file")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); - trackUploadActive = false; - trackUploadComplete = false; - return; - } - - size_t written = outFile.write((uint8_t*)trackUploadBuffer, trackUploadOffset); - outFile.close(); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - - if (written != trackUploadOffset) { - debugln(F("BLE: Track file write incomplete")); - fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); - } else { - debugln(F("BLE: Track file written successfully")); - fileStatusChar.notify((uint8_t*)"TOK", 3); - // Refresh in-memory track list - buildTrackList(); - } - - trackUploadActive = false; - trackUploadComplete = false; - trackUploadError = false; -} - -void processTrackDelete() { - debug(F("BLE: Deleting track file: [")); - debug(trackDeleteFilename); - debugln(F("]")); - - if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { - debugln(F("BLE: SD busy, cannot delete track")); - fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); - trackDeletePending = false; - return; - } - - char filepath[FILEPATH_MAX]; - snprintf(filepath, sizeof(filepath), "/TRACKS/%s", trackDeleteFilename); - - if (!SD.exists(filepath)) { - debugln(F("BLE: Track file not found")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileStatusChar.notify((uint8_t*)"TERR:NO_FILE", 12); - trackDeletePending = false; - return; - } - - if (SD.remove(filepath)) { - debugln(F("BLE: Track file deleted successfully")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileStatusChar.notify((uint8_t*)"TOK", 3); - buildTrackList(); - } else { - debugln(F("BLE: Failed to delete track file")); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); - } - - trackDeletePending = false; -} - -void BLUETOOTH_LOOP() { - if (!bleActive) return; - - // Deferred disconnect teardown — runs on the main loop so SdFat is touched - // by a single task. Closes any in-flight transfer/staging file, releases - // SD, aborts the OTA, then auto-reboots to apply changed settings. - if (bleDisconnectCleanupPending) { - bleDisconnectCleanupPending = false; - - // If a firmware OTA apply has been requested, the web app disconnecting is - // EXPECTED — it hands the device off to self-flash. We must NOT abort the - // OTA (fwReset) or reboot here: doing so discards the staged image and - // boots the OLD firmware. Leave the apply for FW_OTA_LOOP() below, which - // owns the install and its own reset. - if (fwApplyRequested()) { - debugln(F("BLE: disconnect during OTA apply — deferring to FW_OTA_LOOP")); - } else { - if (bleCurrentFile) { - bleCurrentFile.close(); - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - } - bleTransferInProgress = false; - fwReset(); // abort any in-flight OTA (closes staging file, frees SD) - - if (enableLogging) { - debugln(F("BLE: Skipping reboot (logging active)")); - } else { - debugln(F("BLE: Rebooting to apply settings...")); - delay(100); // Brief delay for debug output to flush - NVIC_SystemReset(); - } - } - } - - // Process deferred settings commands (thread-safe: runs in main loop) - if (settingsCmdPending) { - processSettingsCommand(); - settingsCmdPending = false; - } - - // Process deferred file commands (LIST/GET/DELETE/TLIST/TGET) — the only - // place these touch SdFat. A GET lands here before the burst-send block - // below, so a transfer still starts in the same loop iteration. - if (fileCmdPending) { - processFileCommand(); - fileCmdPending = false; - } - - // Process track upload state machine - if (trackUploadReady) { - fileStatusChar.notify((uint8_t*)"TREADY", 6); - trackUploadReady = false; - } - - if (trackUploadComplete) { - processTrackUpload(); - } - - if (trackDeletePending) { - processTrackDelete(); - } - - // Service deferred firmware-OTA work (staging-file writes, CRC verify, - // 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); - } - } - - 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); - - // Burst send: read + notify multiple chunks per loop iteration. - // 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)) { - break; // Disconnected or error - } - bleBytesTransferred += bytesRead; - } else { - // Transfer complete - bleCurrentFile.close(); - bleTransferInProgress = false; - releaseSDAccess(SD_ACCESS_BLE_TRANSFER); - - debugln(F("BLE: Transfer complete!")); - fileStatusChar.notify((uint8_t*)"DONE", 4); - break; - } - } - } -} +/////////////////////////////////////////// +// BLUETOOTH (BLE) MODULE +// All BLE-related functions: callbacks, setup, file transfer, loop +/////////////////////////////////////////// + +#include "bluetooth.h" +#include "camera_ble.h" +#include "filename_validator.h" +#include "firmware_ota.h" + +// Deferred settings command buffer (BLE callback -> main loop) +static volatile bool settingsCmdPending = false; +static char settingsCmdBuffer[65]; // 64 chars + null + +// Track upload state (BLE callback -> main loop) +static volatile bool trackUploadActive = false; +static volatile bool trackUploadReady = false; // signals main loop to send TREADY +static volatile bool trackUploadComplete = false; // signals main loop to write file +static volatile bool trackUploadError = false; +static char trackUploadFilename[25]; // just the filename (e.g. "OKC.json") +static char trackUploadBuffer[4096]; +static volatile uint16_t trackUploadOffset = 0; + +// Track delete state (BLE callback -> main loop) +static volatile bool trackDeletePending = false; +static char trackDeleteFilename[25]; +// Which folder a pending track upload/delete targets (TRACK_KIND_CIRCUIT / +// TRACK_KIND_SPRINT). Sprint tracks live in /TRACKS/SPRINT — see plan 0002. +static volatile uint8_t trackUploadKind = TRACK_KIND_CIRCUIT; +static volatile uint8_t trackDeleteKind = TRACK_KIND_CIRCUIT; + +// Folder for a track kind. The BLE filename validator deliberately rejects +// '/' so a client can never splice a path of its own — the folder is chosen +// here, by opcode, and never comes off the wire. +static const char* trackFolderFor(uint8_t kind) { + return (kind == TRACK_KIND_SPRINT) ? trackFolderSprint : trackFolder; +} + +// Deferred file command buffer (BLE callback -> main loop). Carries the +// SD-touching commands (LIST / GET: / DELETE: / TLIST / TGET:) so SdFat is +// only ever driven from the main-loop task — the Bluefruit callback task +// can preempt an in-flight SD write, and SdFat is not thread-safe. +static volatile bool fileCmdPending = false; +static char fileCmdBuffer[65]; + +// Queue an SD-touching command for BLUETOOTH_LOOP(). Returns false if one +// is already pending — the caller sends its protocol-appropriate busy reply. +// The buffer is stable once fileCmdPending is set: the callback refuses new +// commands until the main loop has processed it and cleared the flag. +static bool deferFileCommand(const char* cmd) { + if (fileCmdPending) return false; + strncpy(fileCmdBuffer, cmd, sizeof(fileCmdBuffer) - 1); + fileCmdBuffer[sizeof(fileCmdBuffer) - 1] = '\0'; + fileCmdPending = true; + return true; +} + +// Set by the disconnect callback; BLUETOOTH_LOOP() performs the SD teardown +// (close transfer/staging file, release SD, abort OTA) and the auto-reboot +// on the main loop, so SdFat is only ever touched by one task. +static volatile bool bleDisconnectCleanupPending = false; + +// True once a transfer peer actually DROVE the file/settings/OTA service +// (any fileRequestChar write while the transfer owns the radio). Gates the +// auto-reboot-on-disconnect: a bonded camera that connects to the transfer +// advert (the radio has one BD_ADDR, so the X4 can chase it) and vets our +// GATT then drops must NOT reboot the logger out of the user's transfer +// session — repeatedly, if the camera keeps retrying (#1). A peer that never +// touched the service also held no SD, so skipping the teardown is safe. +static volatile bool bleTransferEngaged = false; + +void bleConnectCallback(uint16_t conn_handle) { + // Camera-owned link (the X4 connecting to our remote GATT) — route to the + // camera module and skip everything below: bleConnected and the MTU/PHY/ + // DLE negotiation are transfer-only. + if (bleOwner == BLE_OWNER_CAMERA) { + cameraBleOnConnect(conn_handle); + return; + } + + debugln(F("BLE: Device connected!")); + bleConnected = true; + bleTransferEngaged = false; // this peer hasn't used the service yet + + BLEConnection* connection = Bluefruit.Connection(conn_handle); + + debug(F("BLE: Initial MTU: ")); + debugln(connection->getMtu()); + + // Request MTU exchange - result will be read in BLUETOOTH_LOOP() after 500ms + debugln(F("BLE: Requesting MTU exchange to 247...")); + if (connection->requestMtuExchange(247)) { + debugln(F("BLE: MTU exchange requested successfully")); + } else { + debugln(F("BLE: MTU exchange request failed!")); + } + + // 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) + connection->requestDataLengthUpdate(); + + // Defer MTU read to main loop instead of blocking here with delay(500) + bleWaitingForMTU = true; + bleMTURequestTime = millis(); + bleMTUConnHandle = conn_handle; + + debug(F("BLE: Connection interval: ")); + debug(connection->getConnectionInterval() * 1.25); + debugln(F("ms")); +} + +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 + // BLUETOOTH_LOOP()) structurally unreachable for camera links: a camera + // dropping off must never reboot the logger mid-session. Matched BY + // HANDLE first, not owner: a teardown-initiated camera disconnect + // completes asynchronously, so its event can land after ownership has + // already moved to NONE/TRANSFER (e.g. CAMERA_FORCE_RELEASE() + // immediately followed by BLE_SETUP() on the transfer page) — owner + // routing alone would misdeliver it here and reboot the device. + if (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA) { + cameraBleOnDisconnect(conn_handle, reason); + return; + } + + debugln(F("BLE: Disconnected!")); + bleConnected = false; + bleNegotiatedMtu = 23; // Reset to default + + // Pure in-RAM flag resets are safe from this callback (Bluefruit) task. + bleTransferInProgress = false; + trackUploadActive = false; + trackUploadReady = false; + trackUploadComplete = false; + trackUploadError = false; + trackDeletePending = false; + // Drop any queued-but-unprocessed commands so they can't fire on behalf + // of a peer that is no longer connected (or after a reconnect). + fileCmdPending = false; + settingsCmdPending = false; + + // Everything that touches SdFat — closing the in-flight transfer/staging + // file, releasing SD access, aborting the OTA — plus the auto-reboot is + // DEFERRED to BLUETOOTH_LOOP() on the main loop. This callback runs in the + // Bluefruit task, which can preempt an in-flight SD write in the main loop, + // and SdFat is not thread-safe. If this was a local BLE_STOP() (which sets + // bleActive=false before disconnecting), BLE_STOP() already did the + // teardown on the main loop, so there is nothing to defer. + // + // Only reboot for a peer that actually USED the transfer service. A bonded + // camera can land on the transfer advert (shared BD_ADDR) and be routed + // here as "the phone" when it drops; without this gate its disconnect would + // reboot the logger mid-transfer, over and over (#1). A never-engaged peer + // also held no SD, so there is nothing to tear down. + if (bleActive && bleTransferEngaged) { + bleDisconnectCleanupPending = true; + } + bleTransferEngaged = false; // reset for the next peer +} + +// Forward declaration for callback +void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len); + +void bleSetupFileService() { + fileService.begin(); + + // File List Characteristic + fileListChar.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); + fileListChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); + fileListChar.setMaxLen(244); + fileListChar.begin(); + + // File Request Characteristic. Max length is 244 so the firmware-OTA + // path can receive ~240-byte raw image chunks (text commands and the + // legacy 64-byte track-upload chunks fit comfortably inside this). + fileRequestChar.setProperties(CHR_PROPS_WRITE | CHR_PROPS_WRITE_WO_RESP); + fileRequestChar.setPermission(SECMODE_NO_ACCESS, SECMODE_OPEN); + fileRequestChar.setMaxLen(244); + fileRequestChar.setWriteCallback(bleFileRequestCallback); + fileRequestChar.begin(); + + // File Data Characteristic + fileDataChar.setProperties(CHR_PROPS_NOTIFY); + fileDataChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); + fileDataChar.setMaxLen(244); + fileDataChar.begin(); + + // File Status Characteristic + fileStatusChar.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); + fileStatusChar.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); + fileStatusChar.setMaxLen(64); + fileStatusChar.begin(); +} + +void bleAdvFinalizePadded() { + // WORKAROUND for a Bluefruit 0.21.0 core bug (fixed upstream in + // Adafruit 1.7.0, but the Seeed fork ships the broken version): + // BLEAdvertising::_start() initializes its ble_gap_adv_data_t as a + // function-local STATIC, so both packet .len fields freeze at whatever + // the FIRST advert of the boot carried. Any later advert of a + // different length goes on air truncated or with a stale tail — a + // malformed PDU every receiver silently discards, while all our API + // calls report success. (This is what broke the camera wake advert: + // a 28-byte connect advert first froze the length, then the 31-byte + // wake PDU lost its last 3 bytes on air.) + // + // Defeat it by construction: EVERY advert in this firmware is padded + // to exactly 31+31 bytes before start(), so the frozen length is + // always correct. Zero padding after the last AD structure is + // explicitly legal (BT Core Spec Vol 3 Part C §11: the non-significant + // part is all-zero octets). Call this after building the payload + // (additive or raw setData) and immediately before Advertising.start(). + uint8_t buf[BLE_GAP_ADV_SET_DATA_SIZE_MAX] = {0}; + uint8_t n = Bluefruit.Advertising.count(); + memcpy(buf, Bluefruit.Advertising.getData(), n); + Bluefruit.Advertising.setData(buf, sizeof(buf)); + + memset(buf, 0, sizeof(buf)); + n = Bluefruit.ScanResponse.count(); + memcpy(buf, Bluefruit.ScanResponse.getData(), n); + Bluefruit.ScanResponse.setData(buf, sizeof(buf)); +} + +void bleApplyTransferAdvertising() { + // Full rebuild, not an incremental start: the camera module may have + // owned the advert set (name + payload) since the last transfer session, + // so drop whatever is there and reconstruct the transfer advert exactly. + Bluefruit.Advertising.stop(); // safe no-op if not advertising + Bluefruit.Advertising.clearData(); + Bluefruit.ScanResponse.clearData(); + + char bleName[32]; + if (getSetting("bluetooth_name", bleName, sizeof(bleName))) { + debug(F("BLE: Name from settings: ")); + debugln(bleName); + Bluefruit.setName(bleName); + } else { + debugln(F("BLE: WARNING - bluetooth_name not found, using fallback")); + Bluefruit.setName("DovesDataLogger"); + } + + // Force connectable: the shared Advertising object may have been left + // non-connectable by a camera wake burst (see kStartWakeBurst in + // camera_ble.ino), which would otherwise make this transfer advert + // unconnectable. + Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED); + Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); + Bluefruit.Advertising.addTxPower(); + Bluefruit.Advertising.addService(fileService); + Bluefruit.Advertising.addName(); + + Bluefruit.Advertising.restartOnDisconnect(true); + Bluefruit.Advertising.setInterval(32, 244); + Bluefruit.Advertising.setFastTimeout(30); + bleAdvFinalizePadded(); // every advert must be 31+31 — see the helper + Bluefruit.Advertising.start(0); +} + +void bleSendFileList() { + // Runs on the main loop (deferred via fileCmdBuffer). Hold the SD lock + // for the entire walk — the delay(10) per entry yields to other tasks, + // so ownership must be held, not just peeked. The explicit free-check + // first keeps the idempotent/preempting acquire from piggybacking on an + // active transfer or stealing a track parse. + if (currentSDAccess != SD_ACCESS_NONE || + !acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD busy, cannot list files")); + fileListChar.notify((uint8_t*)"BUSY", 4); + return; + } + + File32 root = SD.open("/"); + if (!root) { + debugln(F("BLE: Failed to open root directory")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileListChar.notify((uint8_t*)"BUSY", 4); + return; + } + + // Stream entries directly over BLE using a fixed buffer per entry + // instead of building one giant String (avoids heap fragmentation + // that was silently truncating the file list) + char entryBuf[300]; + int fileCount = 0; + bool firstEntry = true; + + while (true) { + File32 entry = root.openNextFile(); + if (!entry) break; + + if (!entry.isDirectory()) { + char name[256]; + entry.getName(name, sizeof(name)); + + // Build single entry: "|name:size" (skip | for first entry) + int len = snprintf(entryBuf, sizeof(entryBuf), "%s%s:%lu", + firstEntry ? "" : "|", + name, + (unsigned long)entry.size()); + firstEntry = false; + + if (len > 0 && len < (int)sizeof(entryBuf)) { + fileListChar.notify((uint8_t*)entryBuf, len); + delay(10); + fileCount++; + } + } + entry.close(); + } + root.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + + fileListChar.notify((uint8_t*)"END", 3); + debug(F("BLE: File list sent, ")); + debug(fileCount); + debugln(F(" files")); +} + +void bleSendTrackList(uint8_t kind) { + // Sprint listings answer with their own tokens (TSFILE:/TSEND) so a client + // can never confuse a sprint enumeration with a circuit one. + const bool sprint = (kind == TRACK_KIND_SPRINT); + const char* fileTok = sprint ? "TSFILE:%s" : "TFILE:%s"; + const char* endTok = sprint ? "TSEND" : "TEND"; + // Same locking discipline as bleSendFileList() — see the comment there. + if (currentSDAccess != SD_ACCESS_NONE || + !acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD busy, cannot list tracks")); + fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); + return; + } + + File32 trackDir2 = SD.open(trackFolderFor(kind)); + if (!trackDir2) { + debugln(F("BLE: Failed to open TRACKS directory")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileStatusChar.notify((uint8_t*)endTok, strlen(endTok)); + return; + } + + int fileCount = 0; + while (true) { + File32 entry = trackDir2.openNextFile(); + if (!entry) break; + + if (!entry.isDirectory()) { + char name[64]; + entry.getName(name, sizeof(name)); + + char msg[70]; + int len = snprintf(msg, sizeof(msg), fileTok, name); + if (len > 0 && len < (int)sizeof(msg)) { + fileStatusChar.notify((uint8_t*)msg, len); + delay(10); + fileCount++; + } + } + entry.close(); + } + trackDir2.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + + fileStatusChar.notify((uint8_t*)endTok, strlen(endTok)); + debug(F("BLE: Track list sent, ")); + debug(fileCount); + debugln(F(" files")); +} + +void bleStartFileTransfer(const char* filename) { + if (bleCurrentFile) bleCurrentFile.close(); + + // Check if we can acquire SD access for BLE transfer + if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD card busy - cannot start transfer")); + fileStatusChar.notify((uint8_t*)"BUSY", 4); + return; + } + + debug(F("BLE: Opening file: [")); + debug(filename); + debugln(F("]")); + + bleCurrentFile = SD.open(filename, FILE_READ); + + if (!bleCurrentFile) { + debugln(F("BLE: Failed to open file!")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); // Release on failure + fileStatusChar.notify((uint8_t*)"ERROR", 5); + return; + } + + bleFileSize = bleCurrentFile.size(); + bleBytesTransferred = 0; + bleTransferInProgress = true; + + debug(F("BLE: File size: ")); + debug(bleFileSize); + debug(F(" bytes, MTU: ")); + debugln(bleNegotiatedMtu); + + char sizeMsg[32]; + snprintf(sizeMsg, sizeof(sizeMsg), "SIZE:%lu", bleFileSize); + fileStatusChar.notify((uint8_t*)sizeMsg, strlen(sizeMsg)); +} + +void bleDeleteFile(const char* filename) { + debug(F("BLE: Deleting file: [")); + debug(filename); + debugln(F("]")); + + // An active transfer holds SD_ACCESS_BLE_TRANSFER, and the same-mode + // re-acquire below would succeed — guard explicitly so a DELETE can't + // remove the file being streamed and then drop the transfer's lock. + if (bleTransferInProgress) { + debugln(F("BLE: transfer in progress, cannot delete")); + fileStatusChar.notify((uint8_t*)"BUSY", 4); + return; + } + if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD busy, cannot delete")); + fileStatusChar.notify((uint8_t*)"BUSY", 4); + return; + } + + if (SD.exists(filename)) { + if (SD.remove(filename)) { + debugln(F("BLE: File deleted successfully")); + fileStatusChar.notify((uint8_t*)"DELETED", 7); + } else { + debugln(F("BLE: Failed to delete file")); + fileStatusChar.notify((uint8_t*)"DEL_ERR", 7); + } + } else { + debugln(F("BLE: File not found")); + fileStatusChar.notify((uint8_t*)"NOT_FOUND", 9); + } + + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); +} + +void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len) { + // Only serve the file service while the transfer page owns the radio. A + // peer that connects during camera mode must not queue deferred SD work — + // BLUETOOTH_LOOP() is gated on bleActive and would never drain it. + if (bleOwner != BLE_OWNER_TRANSFER) return; + + // A write to the request characteristic means this is a genuine transfer + // peer (the phone app), not a bonded camera vetting our GATT — arm the + // reboot-on-disconnect gate (#1). + bleTransferEngaged = true; + + char buffer[65]; + memset(buffer, 0, sizeof(buffer)); + uint16_t copyLen = len < 64 ? len : 64; + memcpy(buffer, data, copyLen); + + // Trim trailing whitespace/newlines in-place + int end = strlen(buffer) - 1; + while (end >= 0 && (buffer[end] == ' ' || buffer[end] == '\r' || buffer[end] == '\n')) { + buffer[end--] = '\0'; + } + + // Handle upload data mode — all writes are raw data until TDONE + if (trackUploadActive) { + if (strncmp(buffer, "TDONE", 5) == 0 && len <= 6) { + debugln(F("BLE: TDONE received")); + trackUploadComplete = true; + return; + } + // Append raw data to buffer + if (trackUploadOffset + len <= sizeof(trackUploadBuffer)) { + memcpy(trackUploadBuffer + trackUploadOffset, data, len); + trackUploadOffset += len; + } else { + trackUploadError = true; + } + return; + } + + // Firmware OTA image stream: while receiving, every write is raw image + // data EXCEPT the short FWDONE / FWABORT control tokens. Bounding the + // token match by length keeps a full binary chunk from being mistaken for + // a command (mirrors the TPUT/TDONE convention above). + if (fwReceiving()) { + if (len <= 8 && (strcmp(buffer, "FWDONE") == 0 || strcmp(buffer, "FWABORT") == 0)) { + fwHandleCommand(buffer, len); + } else { + fwReceiveChunk(data, len); + } + return; + } + + // A command longer than the parse buffer was truncated by the memcpy + // above. The raw-data paths (track upload / OTA image) returned already, + // so anything this long is a malformed command — never a valid filename + // command (those are FAT-short). Reject rather than validate and act on a + // silently-mangled name. (len <= 64 fits buffer[65] with NUL intact.) + if (len >= sizeof(buffer)) { + debugln(F("BLE: command too long, rejecting")); + fileStatusChar.notify((uint8_t*)"ERROR", 5); + return; + } + + debug(F("BLE: Received command: [")); + debug(buffer); + debugln(F("]")); + + // File commands (LIST/GET/DELETE/TLIST/TGET) all touch SD, so they are + // DEFERRED to BLUETOOTH_LOOP() via deferFileCommand() — SdFat must never + // run in this Bluefruit callback task. Filename validation is RAM-only + // and stays here so bad names are rejected immediately. + if (strncmp(buffer, "LIST", 4) == 0) { + if (!deferFileCommand(buffer)) { + fileListChar.notify((uint8_t*)"BUSY", 4); + } + } else if (strncmp(buffer, "GET:", 4) == 0) { + // Skip "GET:" prefix and trim leading whitespace + char* filename = buffer + 4; + while (*filename == ' ') filename++; + // Reject path traversal / FAT-unsafe names before touching SD. + if (!filename_validator::isValidFilename(filename, filename_validator::kMaxBleFilenameLen)) { + debugln(F("BLE: GET rejected — bad filename")); + fileStatusChar.notify((uint8_t*)"ERROR", 5); + return; + } + if (!deferFileCommand(buffer)) { + fileStatusChar.notify((uint8_t*)"BUSY", 4); + } + } else if (strncmp(buffer, "DELETE:", 7) == 0) { + // Skip "DELETE:" prefix and trim leading whitespace + char* filename = buffer + 7; + while (*filename == ' ') filename++; + if (!filename_validator::isValidFilename(filename, filename_validator::kMaxBleFilenameLen)) { + debugln(F("BLE: DELETE rejected — bad filename")); + fileStatusChar.notify((uint8_t*)"NOT_FOUND", 9); + return; + } + if (!deferFileCommand(buffer)) { + fileStatusChar.notify((uint8_t*)"BUSY", 4); + } + } else if (strcmp(buffer, "SLIST") == 0 || + strncmp(buffer, "SGET:", 5) == 0 || + strncmp(buffer, "SSET:", 5) == 0 || + strcmp(buffer, "SRESET") == 0) { + // Settings commands — defer to main loop for thread-safe SD access + if (settingsCmdPending) { + fileStatusChar.notify((uint8_t*)"SBUSY", 5); + return; + } + strncpy(settingsCmdBuffer, buffer, sizeof(settingsCmdBuffer) - 1); + settingsCmdBuffer[sizeof(settingsCmdBuffer) - 1] = '\0'; + settingsCmdPending = true; + + // Track management commands + } else if (strcmp(buffer, "TLIST") == 0 || strcmp(buffer, "TSLIST") == 0) { + if (!deferFileCommand(buffer)) { + fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); + } + } else if (strncmp(buffer, "TGET:", 5) == 0 || strncmp(buffer, "TSGET:", 6) == 0) { + // The name is spliced into "/%s"; validate it so it can't + // climb out of the tracks folders via ../ or carry FAT-unsafe bytes. + // The folder itself comes from the opcode, never from the wire. + if (!filename_validator::isValidFilename(buffer + (buffer[1] == 'S' ? 6 : 5), + filename_validator::kMaxBleFilenameLen)) { + debugln(F("BLE: TGET rejected — bad filename")); + fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); + return; + } + if (!deferFileCommand(buffer)) { + fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); + } + } else if (strncmp(buffer, "TPUT:", 5) == 0 || strncmp(buffer, "TSPUT:", 6) == 0) { + if (trackUploadActive || bleTransferInProgress) { + fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); + return; + } + const bool putSprint = (buffer[1] == 'S'); + const char* putName = buffer + (putSprint ? 6 : 5); + if (!filename_validator::isValidFilename(putName, filename_validator::kMaxBleFilenameLen)) { + debugln(F("BLE: TPUT rejected — bad filename")); + fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); + return; + } + trackUploadKind = putSprint ? TRACK_KIND_SPRINT : TRACK_KIND_CIRCUIT; + strncpy(trackUploadFilename, putName, sizeof(trackUploadFilename) - 1); + trackUploadFilename[sizeof(trackUploadFilename) - 1] = '\0'; + trackUploadOffset = 0; + trackUploadError = false; + trackUploadComplete = false; + trackUploadReady = true; + trackUploadActive = true; + debugln(F("BLE: Track upload started")); + } else if (strncmp(buffer, "TDEL:", 5) == 0 || strncmp(buffer, "TSDEL:", 6) == 0) { + if (trackDeletePending) { + fileStatusChar.notify((uint8_t*)"TERR:BUSY", 9); + return; + } + const bool delSprint = (buffer[1] == 'S'); + const char* delName = buffer + (delSprint ? 6 : 5); + if (!filename_validator::isValidFilename(delName, filename_validator::kMaxBleFilenameLen)) { + debugln(F("BLE: TDEL rejected — bad filename")); + fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); + return; + } + trackDeleteKind = delSprint ? TRACK_KIND_SPRINT : TRACK_KIND_CIRCUIT; + strncpy(trackDeleteFilename, delName, sizeof(trackDeleteFilename) - 1); + trackDeleteFilename[sizeof(trackDeleteFilename) - 1] = '\0'; + trackDeletePending = true; + + // Battery query — no SD access needed, uses cached voltage + } else if (strcmp(buffer, "BATT") == 0) { + int pct = getBatteryPercent(lastBatteryVoltage); + char vbuf[8]; + dtostrf(lastBatteryVoltage, 4, 2, vbuf); + char response[24]; + snprintf(response, sizeof(response), "BATT:%d,%s", pct, vbuf); + fileStatusChar.notify((uint8_t*)response, strlen(response)); + + // Firmware OTA commands (FWBEGIN/FWPUT/FWDONE/FWAPPLY/FWABORT). Parsing + // and synchronous replies happen here; SD writes + apply are deferred to + // FW_OTA_LOOP() on the main loop. + } else if (fwIsCommand(buffer)) { + fwHandleCommand(buffer, len); + } +} + +// Just-Works pairing result trace (Bluefruit pair-complete callback). +// Signature is plain integers, so no Bluefruit-type forward declaration is +// needed. auth_status == BLE_GAP_SEC_STATUS_SUCCESS (0) means bonded. +void blePairCompleteCallback(uint16_t conn_hdl, uint8_t auth_status) { + (void)conn_hdl; + debug(F("BLE: pairing complete, auth_status=0x")); + debugln(auth_status, HEX); +} + +void bleCoreEnsureInit() { + if (bleInitialized) return; + + debugln(F("BLE: Initializing Bluetooth core...")); + + // Custom BLE config for max file transfer throughput: + // MTU 247, event_len 100 (125ms max radio time per event), + // HVN TX queue 10 (up from BANDWIDTH_MAX's 3 — deeper notification pipeline), + // WrCmd queue 1 (default, we don't use write commands). + Bluefruit.configPrphConn(247, 100, 10, 1); + // 1 peripheral + 0 central: the camera feature is now a pure PERIPHERAL + // remote emulation (the camera connects to US and we notify our ce82 + // buttons), so the old central slot for the X4's be80 control link is + // gone. Both the transfer service and the camera remote are peripherals + // sharing the single peripheral slot via bleOwner. + Bluefruit.begin(1, 0); + Bluefruit.setTxPower(4); + + Bluefruit.Periph.setConnectCallback(bleConnectCallback); + Bluefruit.Periph.setDisconnectCallback(bleDisconnectCallback); + + // Just-Works pairing acceptance (peripheral). The genuine Insta360 GPS + // Remote link is encrypted + bonded, and a captured X4 brings up + // encryption immediately on connect, so the camera (as central) may + // withhold its ce82 CCCD subscription until the link is secured. Advertise + // NoInputNoOutput I/O capabilities and no MITM requirement so the + // SoftDevice completes Just-Works pairing without any on-device prompt. + // This is link-level only — NO characteristic is marked encrypted + // (SECMODE_OPEN everywhere), so the file-transfer service keeps working + // fully open/unbonded. NOTE (Bluefruit 0.21.0 assumption): NoInputNoOutput + // + MITM-off is already Bluefruit's default and yields Just-Works; setting + // it explicitly documents intent and guards against a future default + // change. The pair-complete callback is trace-only. + Bluefruit.Security.setIOCaps(false, false, false); // display, yes/no, keyboard + Bluefruit.Security.setMITM(false); + Bluefruit.Security.setPairCompleteCallback(blePairCompleteCallback); + + // Set connection interval (7.5-15ms) + Bluefruit.Periph.setConnInterval(6, 12); + + // Buttonless OTA DFU. Registers the Secure DFU service so a companion + // (DovesDataViewer over Web Bluetooth) can write the "enter bootloader" + // command and reboot the board into the bootloader's Nordic Secure DFU + // mode — no physical double-tap of reset required. The bootloader then + // receives the firmware image and flashes it. Added before the app + // service so it is registered when advertising starts. + bledfu.begin(); + + // Device Information Service (0x180A). Publishes the firmware version + // via the standard Firmware Revision characteristic (0x2A26) so the + // companion can read it and compare against the latest GitHub release + // to decide whether an OTA update is needed. + bledis.setManufacturer("DovesDataLogger"); + // Model encodes the board variant ("BirdsEye-sense" / "BirdsEye-nonsense") + // so the companion can pick the matching OTA package. + bledis.setModel("BirdsEye-" FIRMWARE_VARIANT); + bledis.setFirmwareRev(FIRMWARE_VERSION); + bledis.begin(); + + bleSetupFileService(); + + // Camera remote GATT (peripheral ce80 + D0FF services) plus the central + // client objects for the camera's be80 service. GATT services can only + // be added before advertising starts, so they are registered here even + // when the user never touches the camera feature. + cameraBleRegisterServices(); + + bleInitialized = true; + + // Deliberately NO advertising, NO device name, NO conn-LED here — the + // owner (transfer page or camera module) applies its own advert set. +} + +void BLE_SETUP() { + // Parked transfer — bump the SD clock for faster file transfers. Reverted + // in BLE_STOP() (and by the auto-reboot on phone disconnect). + sdSetTransferSpeed(true); + + debugln(F("BLE: Starting transfer mode...")); + + bleCoreEnsureInit(); + + // Take the radio for the transfer service (main-loop context — the camera + // module released its links via CAMERA_FORCE_RELEASE() before this page + // opened). The full advert rebuild below is what makes re-entry correct + // even when the camera owned the advert set in between. + bleOwner = BLE_OWNER_TRANSFER; + + // Enable connection LED + Bluefruit.autoConnLed(true); + Bluefruit.setConnLedInterval(250); // Blink every 250ms when connected + + bleApplyTransferAdvertising(); + + bleActive = true; + + debugln(F("BLE: Ready for connection!")); +} + +void BLE_STOP() { + if (!bleActive) return; + + debugln(F("BLE: Stopping Bluetooth...")); + + // Mark inactive BEFORE disconnect so the async bleDisconnectCallback + // knows this was a local stop (not a phone disconnect) and skips reboot. + bleActive = false; + + // Close any open file and release SD access (main-loop context — BLE_STOP() + // is called from the loop, so SdFat access here is safe). The disconnect + // callback skips its deferred teardown when bleActive is already false, so + // this is the single owner of the local-stop teardown. + if (bleCurrentFile) { + bleCurrentFile.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + } + bleTransferInProgress = false; + bleTransferEngaged = false; // session is over — no reboot owed + fwReset(); // abort any in-flight OTA (closes staging file, frees SD) + + // Drop queued-but-unprocessed commands so a stale one can't execute on + // the next BLE session (BLUETOOTH_LOOP stops running once bleActive is + // false, so nothing would clear them otherwise). + fileCmdPending = false; + settingsCmdPending = false; + + // Disarm auto-restart BEFORE disconnecting. bleApplyTransferAdvertising() + // set restartOnDisconnect(true) so a mid-session phone drop re-advertises; + // but here we are deliberately tearing the transfer service down. The + // disconnect below is async, so if we left it armed Bluefruit's internal + // handler would restart an ownerless transfer advert AFTER we stop it — + // the phone would reconnect into a mute session (owner already NONE) and + // the occupied peripheral slot would block camera auto-record until a + // power cycle. + Bluefruit.Advertising.restartOnDisconnect(false); + + // Disconnect any connected device + if (Bluefruit.connected()) { + Bluefruit.disconnect(Bluefruit.connHandle()); + // BLE disconnect is async; no delay needed - stack handles it + } + + // Stop advertising + Bluefruit.Advertising.stop(); + + // Turn off the BLE LED + Bluefruit.autoConnLed(false); + Bluefruit.setConnLedInterval(0); + digitalWrite(LED_BLUE, HIGH); + + bleConnected = false; + // bleActive already set false at top of BLE_STOP() + + // Release radio ownership. The camera module re-acquires it on its next + // advertising action (in CAMERA_LOOP()) — nothing to hand off here. + bleOwner = BLE_OWNER_NONE; + + // Restore the EMI-safe SD clock now that the transfer session is over. + sdSetTransferSpeed(false); + + debugln(F("BLE: Bluetooth stopped")); +} + +// Execute a deferred file command (main-loop context — the only place +// SdFat may be touched). The filename was validated in the callback and +// the buffer is stable while fileCmdPending is set. +static void processFileCommand() { + debug(F("BLE: Processing file cmd: [")); + debug(fileCmdBuffer); + debugln(F("]")); + + if (strncmp(fileCmdBuffer, "LIST", 4) == 0) { + bleSendFileList(); + } else if (strncmp(fileCmdBuffer, "GET:", 4) == 0) { + char* filename = fileCmdBuffer + 4; + while (*filename == ' ') filename++; + bleStartFileTransfer(filename); + } else if (strncmp(fileCmdBuffer, "DELETE:", 7) == 0) { + char* filename = fileCmdBuffer + 7; + while (*filename == ' ') filename++; + bleDeleteFile(filename); + } else if (strcmp(fileCmdBuffer, "TLIST") == 0) { + bleSendTrackList(TRACK_KIND_CIRCUIT); + } else if (strcmp(fileCmdBuffer, "TSLIST") == 0) { + bleSendTrackList(TRACK_KIND_SPRINT); + } else if (strncmp(fileCmdBuffer, "TGET:", 5) == 0 || + strncmp(fileCmdBuffer, "TSGET:", 6) == 0) { + const bool sprint = (fileCmdBuffer[1] == 'S'); + char filepath[FILEPATH_MAX]; + snprintf(filepath, sizeof(filepath), "%s/%s", + trackFolderFor(sprint ? TRACK_KIND_SPRINT : TRACK_KIND_CIRCUIT), + fileCmdBuffer + (sprint ? 6 : 5)); + bleStartFileTransfer(filepath); + } +} + +void processSettingsCommand() { + debug(F("BLE: Processing settings cmd: [")); + debug(settingsCmdBuffer); + debugln(F("]")); + + if (strcmp(settingsCmdBuffer, "SLIST") == 0) { + debugln(F("BLE: SLIST - listing all settings")); + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("BLE: SLIST - SD busy")); + fileStatusChar.notify((uint8_t*)"SERR:SD_BUSY", 12); + return; + } + + File settingsFile; + settingsFile.open("/SETTINGS.json", O_READ); + if (!settingsFile) { + debugln(F("BLE: SLIST - failed to open settings file")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + fileStatusChar.notify((uint8_t*)"SERR:NO_FILE", 12); + return; + } + + char fileBuf[512]; + int bytesRead = settingsFile.read(fileBuf, sizeof(fileBuf) - 1); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("BLE: SLIST - read ")); + debug(bytesRead); + debugln(F(" bytes")); + + if (bytesRead <= 0) { + debugln(F("BLE: SLIST - file empty")); + fileStatusChar.notify((uint8_t*)"SERR:EMPTY", 10); + return; + } + fileBuf[bytesRead] = '\0'; + + StaticJsonDocument<512> doc; + DeserializationError err = deserializeJson(doc, fileBuf); + if (err != DeserializationError::Ok) { + debug(F("BLE: SLIST - JSON parse error: ")); + debugln(err.c_str()); + fileStatusChar.notify((uint8_t*)"SERR:PARSE", 10); + return; + } + + int count = 0; + for (JsonPair kv : doc.as()) { + char entry[64]; + snprintf(entry, sizeof(entry), "SVAL:%s=%s", kv.key().c_str(), kv.value().as()); + debug(F("BLE: SLIST - sending: ")); + debugln(entry); + fileStatusChar.notify((uint8_t*)entry, strlen(entry)); + delay(10); // BLE notify spacing + count++; + } + debugln(F("BLE: SLIST - sending SEND")); + fileStatusChar.notify((uint8_t*)"SEND", 4); + debug(F("BLE: SLIST - done, sent ")); + debug(count); + debugln(F(" entries")); + + } else if (strncmp(settingsCmdBuffer, "SGET:", 5) == 0) { + char* key = settingsCmdBuffer + 5; + debug(F("BLE: SGET - key: [")); + debug(key); + debugln(F("]")); + + char valueBuf[48]; + if (getSetting(key, valueBuf, sizeof(valueBuf))) { + char response[64]; + snprintf(response, sizeof(response), "SVAL:%s=%s", key, valueBuf); + debug(F("BLE: SGET - responding: ")); + debugln(response); + fileStatusChar.notify((uint8_t*)response, strlen(response)); + } else { + debugln(F("BLE: SGET - key not found")); + fileStatusChar.notify((uint8_t*)"SERR:NOT_FOUND", 14); + } + + } else if (strncmp(settingsCmdBuffer, "SSET:", 5) == 0) { + char* payload = settingsCmdBuffer + 5; + char* eq = strchr(payload, '='); + if (!eq) { + debugln(F("BLE: SSET - missing '=' in command")); + fileStatusChar.notify((uint8_t*)"SERR:BAD_CMD", 12); + return; + } + *eq = '\0'; + char* key = payload; + char* value = eq + 1; + + debug(F("BLE: SSET - key: [")); + debug(key); + debug(F("] value: [")); + debug(value); + debugln(F("]")); + + if (setSetting(key, value)) { + char response[64]; + snprintf(response, sizeof(response), "SOK:%s", key); + debug(F("BLE: SSET - success: ")); + debugln(response); + fileStatusChar.notify((uint8_t*)response, strlen(response)); + } else { + debugln(F("BLE: SSET - write failed")); + fileStatusChar.notify((uint8_t*)"SERR:WRITE_FAIL", 15); + } + } else if (strcmp(settingsCmdBuffer, "SRESET") == 0) { + debugln(F("BLE: SRESET - resetting all settings to defaults")); + if (resetSettings()) { + fileStatusChar.notify((uint8_t*)"SOK:RESET", 9); + debugln(F("BLE: Settings reset, rebooting in 200ms...")); + delay(200); // Let the notification reach the phone + NVIC_SystemReset(); + } else { + fileStatusChar.notify((uint8_t*)"SERR:RESET_FAIL", 15); + } + } else { + debug(F("BLE: Unknown settings cmd: [")); + debug(settingsCmdBuffer); + debugln(F("]")); + } +} + +void processTrackUpload() { + debug(F("BLE: Writing track file: [")); + debug(trackUploadFilename); + debug(F("] size: ")); + debugln(trackUploadOffset); + + if (trackUploadError) { + debugln(F("BLE: Track upload too large")); + fileStatusChar.notify((uint8_t*)"TERR:TOO_LARGE", 14); + trackUploadActive = false; + trackUploadComplete = false; + trackUploadError = false; + return; + } + + if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD busy, cannot write track")); + fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); + trackUploadActive = false; + trackUploadComplete = false; + return; + } + + char filepath[FILEPATH_MAX]; + snprintf(filepath, sizeof(filepath), "%s/%s", + trackFolderFor(trackUploadKind), trackUploadFilename); + + // Belt-and-suspenders: buildTrackList() provisions the folder at boot, + // but re-ensure it before every upload so a missing folder can never + // fail a TPUT with WRITE_FAIL. Return deliberately ignored. + sdEnsureTracksFolder(); + + // Delete existing file if present + if (SD.exists(filepath)) { + SD.remove(filepath); + } + + File32 outFile = SD.open(filepath, FILE_WRITE); + if (!outFile) { + debugln(F("BLE: Failed to create track file")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); + trackUploadActive = false; + trackUploadComplete = false; + return; + } + + size_t written = outFile.write((uint8_t*)trackUploadBuffer, trackUploadOffset); + outFile.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + + if (written != trackUploadOffset) { + debugln(F("BLE: Track file write incomplete")); + fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); + } else { + debugln(F("BLE: Track file written successfully")); + fileStatusChar.notify((uint8_t*)"TOK", 3); + // Refresh in-memory track list + buildTrackList(); + } + + trackUploadActive = false; + trackUploadComplete = false; + trackUploadError = false; +} + +void processTrackDelete() { + debug(F("BLE: Deleting track file: [")); + debug(trackDeleteFilename); + debugln(F("]")); + + if (!acquireSDAccess(SD_ACCESS_BLE_TRANSFER)) { + debugln(F("BLE: SD busy, cannot delete track")); + fileStatusChar.notify((uint8_t*)"TERR:SD_BUSY", 12); + trackDeletePending = false; + return; + } + + char filepath[FILEPATH_MAX]; + snprintf(filepath, sizeof(filepath), "%s/%s", + trackFolderFor(trackDeleteKind), trackDeleteFilename); + + if (!SD.exists(filepath)) { + debugln(F("BLE: Track file not found")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileStatusChar.notify((uint8_t*)"TERR:NO_FILE", 12); + trackDeletePending = false; + return; + } + + if (SD.remove(filepath)) { + debugln(F("BLE: Track file deleted successfully")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileStatusChar.notify((uint8_t*)"TOK", 3); + buildTrackList(); + } else { + debugln(F("BLE: Failed to delete track file")); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + fileStatusChar.notify((uint8_t*)"TERR:WRITE_FAIL", 15); + } + + trackDeletePending = false; +} + +void BLUETOOTH_LOOP() { + if (!bleActive) return; + + // Deferred disconnect teardown — runs on the main loop so SdFat is touched + // by a single task. Closes any in-flight transfer/staging file, releases + // SD, aborts the OTA, then auto-reboots to apply changed settings. + if (bleDisconnectCleanupPending) { + bleDisconnectCleanupPending = false; + + // If a firmware OTA apply has been requested, the web app disconnecting is + // EXPECTED — it hands the device off to self-flash. We must NOT abort the + // OTA (fwReset) or reboot here: doing so discards the staged image and + // boots the OLD firmware. Leave the apply for FW_OTA_LOOP() below, which + // owns the install and its own reset. + if (fwApplyRequested()) { + debugln(F("BLE: disconnect during OTA apply — deferring to FW_OTA_LOOP")); + } else { + if (bleCurrentFile) { + bleCurrentFile.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + } + bleTransferInProgress = false; + fwReset(); // abort any in-flight OTA (closes staging file, frees SD) + + if (enableLogging) { + debugln(F("BLE: Skipping reboot (logging active)")); + } else { + debugln(F("BLE: Rebooting to apply settings...")); + delay(100); // Brief delay for debug output to flush + NVIC_SystemReset(); + } + } + } + + // Process deferred settings commands (thread-safe: runs in main loop) + if (settingsCmdPending) { + processSettingsCommand(); + settingsCmdPending = false; + } + + // Process deferred file commands (LIST/GET/DELETE/TLIST/TGET) — the only + // place these touch SdFat. A GET lands here before the burst-send block + // below, so a transfer still starts in the same loop iteration. + if (fileCmdPending) { + processFileCommand(); + fileCmdPending = false; + } + + // Process track upload state machine + if (trackUploadReady) { + fileStatusChar.notify((uint8_t*)"TREADY", 6); + trackUploadReady = false; + } + + if (trackUploadComplete) { + processTrackUpload(); + } + + if (trackDeletePending) { + processTrackDelete(); + } + + // Service deferred firmware-OTA work (staging-file writes, CRC verify, + // 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); + } + } + + 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); + + // Burst send: read + notify multiple chunks per loop iteration. + // 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)) { + break; // Disconnected or error + } + bleBytesTransferred += bytesRead; + } else { + // Transfer complete + bleCurrentFile.close(); + bleTransferInProgress = false; + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); + + debugln(F("BLE: Transfer complete!")); + fileStatusChar.notify((uint8_t*)"DONE", 4); + break; + } + } + } +} diff --git a/BirdsEye/course_creator.cpp b/BirdsEye/course_creator.cpp new file mode 100644 index 0000000..1710587 --- /dev/null +++ b/BirdsEye/course_creator.cpp @@ -0,0 +1,370 @@ +#include "course_creator.h" + +#include +#include + +namespace course_creator { + +namespace { + +// Rows shown on the line menu, in order. Sprint appends the finish line; +// everything before it is shared, so the two layouts can't drift apart. +constexpr LineId kCircuitLines[] = {LineId::kStart, LineId::kSector2, LineId::kSector3}; +constexpr uint8_t kCircuitLineRows = 3; +constexpr uint8_t kSprintLineRows = 4; // + kFinish + +uint8_t lineRowsFor(CourseKind kind) { + return kind == CourseKind::kSprint ? kSprintLineRows : kCircuitLineRows; +} + +LineId lineRowAt(CourseKind kind, uint8_t i) { + if (kind == CourseKind::kSprint && i == kCircuitLineRows) return LineId::kFinish; + if (i >= kCircuitLineRows) i = kCircuitLineRows - 1; + return kCircuitLines[i]; +} + +Line& mutableLine(State& s, LineId line) { + return s.lines[static_cast(line)]; +} + +// Zero-padded two-digit write. `out` must have room for 2 chars. +void writeTwo(char* out, unsigned value) { + out[0] = static_cast('0' + (value / 10) % 10); + out[1] = static_cast('0' + value % 10); +} + +} // namespace + +/////////////////////////////////////////// +// LIFECYCLE +/////////////////////////////////////////// + +void begin(State& s, bool trackDetected) { + s = State{}; + s.trackChoiceOffered = trackDetected; + if (trackDetected) { + s.screen = Screen::kTrackPrompt; + } else { + // Nothing nearby to attach to — there is no choice to offer, so skip + // straight to the type picker with a new track already implied. + s.newTrack = true; + s.screen = Screen::kTypeSelect; + } +} + +/////////////////////////////////////////// +// ROWS +/////////////////////////////////////////// + +uint8_t rowCount(const State& s) { + switch (s.screen) { + case Screen::kTrackPrompt: return 2; // Here, New Track + case Screen::kTypeSelect: return 2; // Circuit, Sprint + 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 + } + return 1; +} + +RowRef rowAt(const State& s, uint8_t index) { + const uint8_t count = rowCount(s); + // A stale menuSelectionIndex left over from a larger menu must never be + // able to fire an action it wasn't pointing at — clamp, don't wrap. + if (index >= count) index = static_cast(count - 1); + + RowRef ref; + switch (s.screen) { + case Screen::kTrackPrompt: + ref.row = (index == 0) ? Row::kTrackHere : Row::kTrackNew; + return ref; + + case Screen::kTypeSelect: + ref.row = (index == 0) ? Row::kTypeCircuit : Row::kTypeSprint; + return ref; + + case Screen::kLineMenu: { + const uint8_t lineRows = lineRowsFor(s.kind); + if (index < lineRows) { + ref.row = Row::kLine; + ref.line = lineRowAt(s.kind, index); + } else if (index == lineRows) { + ref.row = Row::kSave; + } else { + ref.row = Row::kCancel; + } + return ref; + } + + case Screen::kLineDetail: + ref.line = s.editing; + if (index == 0) ref.row = Row::kPointA; + else if (index == 1) ref.row = Row::kPointB; + else if (index == 2) ref.row = Row::kLineSave; + else ref.row = Row::kLineBack; + return ref; + + case Screen::kPointCapture: + ref.line = s.editing; + ref.row = (index == 0) ? Row::kCaptureNow : Row::kCaptureBack; + return ref; + } + return ref; +} + +const char* lineLabel(LineId line, CourseKind kind) { + switch (line) { + case LineId::kStart: + // On a circuit the one line is both, and saying so is the difference + // between the user walking one line or two. + return kind == CourseKind::kSprint ? "Start" : "Start/Fin"; + case LineId::kSector2: return "Sector 2"; + case LineId::kSector3: return "Sector 3"; + case LineId::kFinish: return "Finish"; + } + return ""; +} + +bool lineRequired(LineId line, CourseKind kind) { + if (line == LineId::kStart) return true; + if (line == LineId::kFinish) return kind == CourseKind::kSprint; + return false; +} + +const Line& lineOf(const State& s, LineId line) { + return s.lines[static_cast(line)]; +} + +/////////////////////////////////////////// +// VALIDATION +/////////////////////////////////////////// + +SaveBlock saveBlocked(const State& s) { + const Line& start = lineOf(s, LineId::kStart); + if (!lineDone(start)) return SaveBlock::kStartMissing; + + const Line& s2 = lineOf(s, LineId::kSector2); + const Line& s3 = lineOf(s, LineId::kSector3); + + if (s.kind == CourseKind::kSprint) { + if (!lineDone(lineOf(s, LineId::kFinish))) return SaveBlock::kFinishMissing; + // Splits are optional and independent in principle, but the webapp + // stores them as an ORDERED list and re-exports them positionally, so + // a lone sector 3 would come back as a sector 2 after one sync round + // trip. Capture them in order and that can't happen. + if (lineDone(s3) && !lineDone(s2)) return SaveBlock::kSplitOrder; + return SaveBlock::kNone; + } + + // Circuit sectors are all-or-nothing: the webapp's course validator + // accepts zero sectors or exactly three majors (start/finish + two). + // Writing just one would produce a course the app can load but can + // never save again. + const bool has2 = lineDone(s2); + const bool has3 = lineDone(s3); + if (has2 != has3) return SaveBlock::kSectorPair; + + // A half-captured sector (one endpoint) is the same problem in a + // different disguise — it never became a line but the user thinks it did. + if ((!lineEmpty(s2) && !has2) || (!lineEmpty(s3) && !has3)) { + return SaveBlock::kSectorPair; + } + return SaveBlock::kNone; +} + +/////////////////////////////////////////// +// NAVIGATION +/////////////////////////////////////////// + +Action select(State& s, uint8_t index) { + const RowRef ref = rowAt(s, index); + + switch (ref.row) { + case Row::kTrackHere: + s.newTrack = false; + s.screen = Screen::kTypeSelect; + return Action::kNone; + + case Row::kTrackNew: + s.newTrack = true; + s.screen = Screen::kTypeSelect; + return Action::kNone; + + case Row::kTypeCircuit: + case Row::kTypeSprint: + s.kind = (ref.row == Row::kTypeSprint) ? CourseKind::kSprint : CourseKind::kCircuit; + s.screen = Screen::kLineMenu; + return Action::kNone; + + case Row::kLine: + // Editing works on a scratch copy so Back is a real undo — re-capture + // point A, decide you stood in the wrong place, and walk away. + s.editing = ref.line; + s.scratch = lineOf(s, ref.line); + s.screen = Screen::kLineDetail; + return Action::kNone; + + case Row::kSave: + // Refused rather than partially written: an unsaveable course on the + // card is worse than none, because the device would load it. + if (!canSave(s)) return Action::kNone; + return Action::kSaveCourse; + + case Row::kCancel: + return Action::kExit; + + case Row::kPointA: + case Row::kPointB: + s.editingPointB = (ref.row == Row::kPointB); + s.captureFailed = false; + s.capture = Capture{}; + s.screen = Screen::kPointCapture; + return Action::kNone; + + case Row::kLineSave: + mutableLine(s, s.editing) = s.scratch; + s.screen = Screen::kLineMenu; + return Action::kNone; + + case Row::kLineBack: + s.scratch = Line{}; + s.screen = Screen::kLineMenu; + return Action::kNone; + + case Row::kCaptureNow: + return Action::kBeginCapture; + + case Row::kCaptureBack: + captureCancel(s); + s.screen = Screen::kLineDetail; + return Action::kNone; + } + return Action::kNone; +} + +void captureCancel(State& s) { + s.capture = Capture{}; + s.captureFailed = false; +} + +/////////////////////////////////////////// +// POINT CAPTURE +/////////////////////////////////////////// + +void captureBegin(State& s, uint32_t nowMs) { + s.capture = Capture{}; + s.capture.active = true; + s.capture.startedMs = nowMs; + s.captureFailed = false; +} + +bool captureAddFix(State& s, double lat, double lon, float hAccM, uint32_t nowMs) { + if (!s.capture.active) return false; + // Past the window the hold is decided; late fixes must not shift a mean + // the user has already been shown. + if (nowMs - s.capture.startedMs >= kCaptureHoldMs) return false; + // A fix this loose puts the point most of a cone away. Counting it would + // quietly poison an average the whole point of which is precision. + if (!(hAccM > 0.0f) || hAccM > kCaptureMaxHAccM) { + if (s.capture.rejected < UINT16_MAX) s.capture.rejected++; + return false; + } + s.capture.latSum += lat; + s.capture.lonSum += lon; + if (s.capture.fixes < UINT16_MAX) s.capture.fixes++; + return true; +} + +CaptureResult capturePoll(const State& s, uint32_t nowMs) { + if (!s.capture.active) return CaptureResult::kIdle; + if (nowMs - s.capture.startedMs < kCaptureHoldMs) return CaptureResult::kRunning; + return s.capture.fixes >= kCaptureMinFixes ? CaptureResult::kDone : CaptureResult::kFailed; +} + +uint8_t capturePercent(const State& s, uint32_t nowMs) { + if (!s.capture.active) return 0; + const uint32_t elapsed = nowMs - s.capture.startedMs; + if (elapsed >= kCaptureHoldMs) return 100; + return static_cast((elapsed * 100u) / kCaptureHoldMs); +} + +bool captureCommit(State& s, uint32_t nowMs) { + if (capturePoll(s, nowMs) != CaptureResult::kDone) { + // Ending a failed hold here (rather than leaving it armed) is what lets + // the screen say "retry" instead of sitting at 100% forever. + if (s.capture.active) { + s.capture = Capture{}; + s.captureFailed = true; + } + return false; + } + + const double lat = s.capture.latSum / s.capture.fixes; + const double lon = s.capture.lonSum / s.capture.fixes; + if (s.editingPointB) { + s.scratch.bLat = lat; + s.scratch.bLon = lon; + s.scratch.hasB = true; + } else { + s.scratch.aLat = lat; + s.scratch.aLon = lon; + s.scratch.hasA = true; + } + + s.capture = Capture{}; + s.captureFailed = false; + s.screen = Screen::kLineDetail; + return true; +} + +/////////////////////////////////////////// +// NAME GENERATION +/////////////////////////////////////////// + +bool generatedName(char* out, size_t outSize, + uint16_t year, uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute) { + if (out == nullptr || outSize < kNameSize) return false; + out[0] = 'N'; + writeTwo(out + 1, year % 100u); + writeTwo(out + 3, month); + writeTwo(out + 5, day); + out[7] = '_'; + writeTwo(out + 8, hour); + writeTwo(out + 10, minute); + out[12] = '\0'; + return true; +} + +bool generatedShortName(char* out, size_t outSize, + uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute) { + if (out == nullptr || outSize < kShortNameSize) return false; + writeTwo(out + 0, month); + writeTwo(out + 2, day); + writeTwo(out + 4, hour); + writeTwo(out + 6, minute); + out[8] = '\0'; + return true; +} + +bool generatedDateCreated(char* out, size_t outSize, + uint16_t year, uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute) { + if (out == nullptr || outSize < kDateCreatedSize) return false; + out[0] = '2'; + out[1] = '0'; + writeTwo(out + 2, year % 100u); + out[4] = '-'; + writeTwo(out + 5, month); + out[7] = '-'; + writeTwo(out + 8, day); + out[10] = 'T'; + writeTwo(out + 11, hour); + out[13] = ':'; + writeTwo(out + 14, minute); + out[16] = '\0'; + return true; +} + +} // namespace course_creator diff --git a/BirdsEye/course_creator.h b/BirdsEye/course_creator.h new file mode 100644 index 0000000..3b4ec71 --- /dev/null +++ b/BirdsEye/course_creator.h @@ -0,0 +1,296 @@ +#pragma once + +#include +#include + +/////////////////////////////////////////// +// ON-DEVICE COURSE CREATOR — MODEL & NAVIGATION (pure, host-tested) +// +// Sprint entrants walk the course before an event (autocross re-lays the +// cones every time), so the device itself has to be able to create a +// course: stand at each cone, capture a GPS position. Plan 0002 §5. +// +// HARD RULE — no text entry on-device, ever. Every name is generated +// from the GPS clock and renamed later in the webapp, which is why +// `generatedName()` below is the only naming path. +// +// Three nested screens (plan 0002 §5 steps 3-5): +// 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. +// +// This unit owns the MODEL — which rows exist, what is captured, whether +// the course may be saved, and the point-averaging math. Navigation +// *input* stays with the sketch's existing menu machinery +// (`menuSelectionIndex` / `menuLimit`), which asks this unit for the row +// count and hands back the chosen index. Rendering and all SD access stay +// in the sketch. +// +// No Arduino headers — compiled into both the firmware and the host test +// harness (tests/course_creator_test.cpp). +/////////////////////////////////////////// + +namespace course_creator { + +/////////////////////////////////////////// +// NAMING +// +// Generated names are "N" + YYMMDD + "_" + HHMM, e.g. "N260803_1432": +// 12 characters, so it fits the track browser's 13-char window +// (MAX_LOCATION_LENGTH) WITHOUT truncation. Plan 0002 §5 originally +// proposed a literal `NEWTRACK_` / `NEWCOURSE_` prefix and flagged that +// the browser would truncate it — every same-day creation would render +// identically on-device, which defeats picking the right one. Favouring +// the timestamp over the prefix is that note resolved: unique to the +// minute, sorts chronologically, and still obviously machine-generated. +/////////////////////////////////////////// + +// "N" + 6 + "_" + 4 + NUL. +constexpr size_t kNameSize = 13; +// Short name for a generated track: MMDDHHMM — exactly the 8 characters +// the webapp's Track.shortName budget allows, and the key its device-sync +// merge uses, so two tracks walked on the same day cannot collide. +constexpr size_t kShortNameSize = 9; +// "20YY-MM-DDTHH:MM" + NUL — the sortable stamp sprint course selection +// compares byte-wise (see sprint_select.h). +constexpr size_t kDateCreatedSize = 17; + +/////////////////////////////////////////// +// POINT CAPTURE +// +// "Save current pos" averages instead of snapshotting (plan 0002 §5, +// decided): the user is standing at the cone anyway, so a hold costs +// nothing and buys a big accuracy win over a single fix. +/////////////////////////////////////////// + +// How long the averaging hold runs. +constexpr uint32_t kCaptureHoldMs = 3000; +// Fixes below this and the hold FAILS rather than returning a mean of +// two samples — at 25 Hz a healthy 3 s hold collects ~75. +constexpr uint16_t kCaptureMinFixes = 8; +// Fixes worse than this are dropped on the floor; a point built from +// them would be a cone-width off. +constexpr float kCaptureMaxHAccM = 10.0f; +// Above this the renderer warns but the fix still counts. +constexpr float kCaptureWarnHAccM = 5.0f; + +/////////////////////////////////////////// +// MODEL +/////////////////////////////////////////// + +enum class CourseKind : uint8_t { + kCircuit = 0, // one line is both start and finish + kSprint = 1, // start line + a separate finish line +}; + +// Timing lines, in the order they are stored in the track JSON. Sprint +// uses the same sector_2 / sector_3 slots for its optional splits — the +// firmware's SprintTimer reads them from exactly those fields. +enum class LineId : uint8_t { + kStart = 0, + kSector2 = 1, + kSector3 = 2, + kFinish = 3, // sprint only +}; +constexpr uint8_t kLineCount = 4; + +// A timing line under construction. Both endpoints must be captured +// before the line counts as done. +struct Line { + double aLat = 0.0; + double aLon = 0.0; + double bLat = 0.0; + double bLon = 0.0; + bool hasA = false; + bool hasB = false; +}; + +inline bool lineDone(const Line& l) { return l.hasA && l.hasB; } +inline bool lineEmpty(const Line& l) { return !l.hasA && !l.hasB; } + +// Averaging accumulator for one point. +struct Capture { + double latSum = 0.0; + double lonSum = 0.0; + uint16_t fixes = 0; + uint16_t rejected = 0; // fixes dropped for poor accuracy (renderer hint) + uint32_t startedMs = 0; + bool active = false; +}; + +enum class CaptureResult : uint8_t { + kIdle, // no hold running + kRunning, // still collecting + kDone, // hold complete with enough fixes — commit it + kFailed, // hold elapsed but too few usable fixes — tell the user to retry +}; + +enum class Screen : uint8_t { + kTrackPrompt, // "Are you at X?" Here / New Track + kTypeSelect, // Circuit / Sprint + kLineMenu, // one row per line + Save + Cancel + kLineDetail, // Point A / Point B / Save / Back + kPointCapture, // Save current pos / Back +}; + +// Row identities. The sketch renders and navigates by these rather than +// by raw indices, so inserting a row can't silently re-map an action. +enum class Row : uint8_t { + kTrackHere, // use the detected track + kTrackNew, // start a new track file + kTypeCircuit, + kTypeSprint, + kLine, // carries a LineId + kSave, // commit the course (line menu) + kCancel, // discard everything + kPointA, + kPointB, + kLineSave, // commit the scratch line back into the course + kLineBack, // discard the scratch line + kCaptureNow, // start the averaging hold + kCaptureBack, +}; + +struct RowRef { + Row row = Row::kCancel; + LineId line = LineId::kStart; // meaningful only when row == kLine +}; + +// What the sketch must DO after a selection. Screen changes are handled +// inside step()/select(); these are the side effects only. +enum class Action : uint8_t { + kNone, + kBeginCapture, // start the averaging hold (GPS must be feeding fixes) + kSaveCourse, // write the course to SD + kExit, // discard and leave the creator +}; + +// Why Save is refused. Surfaced on the line menu so the row can say what +// is missing instead of just failing silently. +enum class SaveBlock : uint8_t { + kNone, + kStartMissing, // start (or start/finish) line incomplete + kFinishMissing, // sprint: no finish line, so the run cannot be timed + kSectorPair, // circuit: sectors are all-or-nothing (see saveBlocked) + kSplitOrder, // sprint: sector 3 captured without sector 2 +}; + +struct State { + Screen screen = Screen::kTrackPrompt; + CourseKind kind = CourseKind::kCircuit; + bool newTrack = false; // true = write a new track file + bool trackChoiceOffered = true; // false when no track was detected nearby + + Line lines[kLineCount]; // committed lines + Line scratch; // the line currently being edited + LineId editing = LineId::kStart; + bool editingPointB = false; + + Capture capture; + bool captureFailed = false; // last hold ended with too few fixes +}; + +/////////////////////////////////////////// +// LIFECYCLE +/////////////////////////////////////////// + +// Enter the creator. `trackDetected` false means no known track is within +// range, so the prompt has nothing to offer and the flow starts on the +// type picker with newTrack already true. +void begin(State& s, bool trackDetected); + +/////////////////////////////////////////// +// ROWS — what the current screen shows +/////////////////////////////////////////// + +// Number of selectable rows on the current screen (the sketch's menuLimit). +uint8_t rowCount(const State& s); + +// The row at `index` on the current screen. Out-of-range indices clamp to +// the last row rather than returning garbage — a stale menuSelectionIndex +// left over from a bigger menu must not be able to fire the wrong action. +RowRef rowAt(const State& s, uint8_t index); + +// Human label for a timing line. Circuit's start line is also its finish, +// and says so; sprint's is just the start. +const char* lineLabel(LineId line, CourseKind kind); + +// True when the course cannot be timed without this line. +bool lineRequired(LineId line, CourseKind kind); + +// The committed line (not the scratch copy). +const Line& lineOf(const State& s, LineId line); + +/////////////////////////////////////////// +// VALIDATION +/////////////////////////////////////////// + +// Why the course may not be saved yet, or kNone when it is ready. +// +// Beyond the obvious required lines, two rules keep device-created courses +// loadable by the webapp's editor: +// - CIRCUIT sectors are all-or-nothing. The webapp's validator accepts +// zero sectors or exactly three majors (start/finish + two); a course +// with only sector 2 would load but could never be saved again there. +// - SPRINT splits fill in order. The webapp maps splits POSITIONALLY +// into sector_2 / sector_3, so a lone sector 3 would come back as a +// sector 2 on the next sync — a silent edit nobody made. +SaveBlock saveBlocked(const State& s); + +inline bool canSave(const State& s) { return saveBlocked(s) == SaveBlock::kNone; } + +/////////////////////////////////////////// +// NAVIGATION +/////////////////////////////////////////// + +// Act on the row the user selected. Mutates `s` (screen, scratch, choice) +// and returns the side effect the sketch must perform. +Action select(State& s, uint8_t index); + +// Abandon the averaging hold (Back on the capture screen). +void captureCancel(State& s); + +/////////////////////////////////////////// +// POINT CAPTURE +/////////////////////////////////////////// + +void captureBegin(State& s, uint32_t nowMs); + +// Feed one GPS fix. Returns false when it was rejected for accuracy. +// Safe to call when no hold is running (returns false, changes nothing). +bool captureAddFix(State& s, double lat, double lon, float hAccM, uint32_t nowMs); + +// Where the hold stands. kDone/kFailed are terminal — the caller commits +// or reports and then the hold is over. +CaptureResult capturePoll(const State& s, uint32_t nowMs); + +// 0-100 progress for the renderer, by elapsed time. +uint8_t capturePercent(const State& s, uint32_t nowMs); + +// Commit a completed hold into the scratch line's A or B endpoint and end +// the hold. Returns false (and captureFailed is set) when the hold did not +// gather enough fixes. +bool captureCommit(State& s, uint32_t nowMs); + +/////////////////////////////////////////// +// NAME GENERATION +/////////////////////////////////////////// + +// "N260803_1432" — the track filename and course name. `year` is the +// 2-digit form the GPS layer carries (25 == 2025). +bool generatedName(char* out, size_t outSize, + uint16_t year, uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute); + +// "08031432" — the 8-char webapp short name for a generated track. +bool generatedShortName(char* out, size_t outSize, + uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute); + +// "2026-08-03T14:32" — the sortable stamp sprint selection compares. +bool generatedDateCreated(char* out, size_t outSize, + uint16_t year, uint8_t month, uint8_t day, + uint8_t hour, uint8_t minute); + +} // namespace course_creator diff --git a/BirdsEye/crossing_pattern.cpp b/BirdsEye/crossing_pattern.cpp new file mode 100644 index 0000000..538d0e3 --- /dev/null +++ b/BirdsEye/crossing_pattern.cpp @@ -0,0 +1,28 @@ +#include "crossing_pattern.h" + +namespace crossing_pattern { + +int frameRects(bool flip, Rect* out, int maxOut) { + if (out == nullptr || maxOut <= 0) return 0; + + const int cols = kWidth / kCell; // 8 + const int rows = kHeight / kCell; // 4 + const int phase = flip ? 1 : 0; // which column parity is lit + int n = 0; + + // Only the ODD row bands carry blocks (this is what the original + // bitmaps did — the even bands were entirely blank). + for (int cy = 1; cy < rows; cy += 2) { + for (int cx = phase; cx < cols; cx += 2) { + if (n >= maxOut) return n; + out[n].x = cx * kCell; + out[n].y = cy * kCell; + out[n].w = kCell; + out[n].h = kCell; + n++; + } + } + return n; +} + +} // namespace crossing_pattern diff --git a/BirdsEye/crossing_pattern.h b/BirdsEye/crossing_pattern.h new file mode 100644 index 0000000..af50667 --- /dev/null +++ b/BirdsEye/crossing_pattern.h @@ -0,0 +1,48 @@ +#pragma once + +/////////////////////////////////////////// +// CROSSING ANIMATION PATTERN (pure, host-tested) +// +// The two-frame "calculating" animation shown while the timer is inside a +// crossing zone used to be two hand-stored 1 KB PROGMEM bitmaps +// (image_data_calculating1/2). Both were pure block patterns: 16x16 px +// cells, filled only in the ODD 16 px row bands, with the two frames +// offset by one cell horizontally so alternating them scrolls the blocks +// sideways. +// +// Storing 2048 bytes of flash to say that is a bad trade — the same +// output is eight fillRect() calls. This unit emits those rectangles; +// crossing_pattern_test.cpp rasterizes them and asserts the result is +// byte-identical to the original bitmaps, so the animation is provably +// unchanged. (The bird splash stays a real bitmap — it is actual art.) +// +// No Arduino headers: compiled into both the firmware and the host tests. +/////////////////////////////////////////// + +namespace crossing_pattern { + +// Display + cell geometry (the original bitmaps were 128x64, 16 px cells). +constexpr int kWidth = 128; +constexpr int kHeight = 64; +constexpr int kCell = 16; + +// Filled cells only ever occupy the odd row bands, 4 per band, 2 bands. +constexpr int kMaxRects = 8; + +struct Rect { + int x, y, w, h; +}; + +/** + * @brief Emit the filled blocks for one animation frame. + * + * @param flip Which of the two frames: false = the even column phase + * (original image_data_calculating2), true = the odd column phase + * (original image_data_calculating1). + * @param out Caller's array, at least kMaxRects entries. + * @param maxOut Capacity of out; emission stops if it would overflow. + * @return Number of rectangles written. + */ +int frameRects(bool flip, Rect* out, int maxOut); + +} // namespace crossing_pattern diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 232f8e4..6569bad 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -34,12 +34,21 @@ void displayPage_replay_file_select(); void displayPage_replay_results(); void displayPage_replay_exit(); +// On-device course creator (plan 0002 §5) — track prompt, type picker, +// line menu, per-line points, and the averaging capture screen. +void displayPage_course_track(); +void displayPage_course_type(); +void displayPage_course_lines(); +void displayPage_course_line(); +void displayPage_course_point(); + // Live racing pages. void displayPage_gps_stats(); void displayPage_gps_speed(); void displayPage_tachometer(); #if BIRDSEYE_ENABLE_SENSOREGG -void displayPage_sensorTemp(); // only built with the SensorEgg POC enabled +void displayPage_sensorTemp(); // only built with the SensorEgg POC enabled +void displayPage_sensorTemp2(); // v2 aux intake-air temp (same gating) #endif void displayPage_gps_lap_time(); void displayPage_gps_pace(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index b7f7d91..4723c2b 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -1,1175 +1,1470 @@ -/////////////////////////////////////////// -// DISPLAY PAGES MODULE -// All displayPage_*() rendering functions for each UI screen -/////////////////////////////////////////// - -#include "display_pages.h" // also pulls in project.h's build feature flags -#include "gps_status_page.h" -#include "lap_format.h" -#include "sat_bars.h" -#include "sd_format_page.h" -#include "sensoregg_protocol.h" - -void displayPage_boot() { - resetDisplay(); - - display.setTextSize(2); - display.println(F(" Doves\n MagicBox")); - display.setTextSize(1); - display.println(F("")); - display.println(F(" Timer + Data Logger")); - display.println(F("\n Initializing...")); - - safeDisplayUpdate(); -} - -// GPS status boot page (MyChron-style): top half is fix/satellite stats, -// bottom half is one vertical signal bar per satellite (height = CNO). -// Shown on every boot; hold/auto-close logic lives in gpsStatusPageLoop(). -void displayPage_gps_status() { - resetDisplay(); - - // GPS never came up — GPS_STATUS_RETRY_LOOP() is re-probing in the - // background (or has given up). Any button still exits to the menu. - if (!gpsInitialized) { - display.println(F("GPS: NOT DETECTED")); - if (gpsRetriesExhausted()) { - display.println(F("\nCHECK WIRING")); - } else { - display.println(F("\nRetrying...")); - } - display.println(F("\nAny button: menu")); - display.print(F("\nup:")); - display.print(millis() / 1000); - display.println(F("s")); - safeDisplayUpdate(); - return; - } - - // ---- Top half (4 size-1 lines, 32 px) ---- - // used-in-solution / tracked-with-signal — the second number matches - // the bar count below (until it exceeds the 16-bar display cap). - display.print(F("Sats:")); - display.print(gpsSatUsedCount); - display.print(F("/")); - display.print(gpsSatTrackedCount); - display.print(F(" HDOP:")); - if (gpsData.fix) { - display.println(gpsData.HDOP, 1); - } else { - display.println(F("--")); - } - - const uint32_t countdown = - gps_status_page::countdownSecondsLeft(gpsStatusState, millis()); - if (countdown > 0) { - display.print(F("LOCKED - ready in ")); - display.println(countdown); - } else { - if (gpsData.fix) { - display.print(F("FIX (time sync) ")); - } else { - display.print(F("ACQUIRING ")); - } - // Uptime breadcrumb: if the device ever reboots off this page, the - // last seconds value on screen identifies which timed path fired - // (~5 s = PVT watchdog -> baud recovery, ~10 s = GPS re-detect retry). - display.print(millis() / 1000); - display.println(F("s")); - } - - // Constellation only — the configured/live update rates confused more - // than they informed on a boot screen. - display.println(F("Mode:GPS-only")); - - if (millis() - lastBatteryCheck > batteryUpdateInterval) { - lastBatteryCheck = millis(); - lastBatteryVoltage = getBatteryVoltage(); - } - display.print(F("Batt:")); - display.print(getBatteryPercent(lastBatteryVoltage)); - display.print(F("% ")); - display.print(lastBatteryVoltage, 2); - display.println(F("V")); - - // ---- Bottom half: per-satellite CNO bars rising from the baseline ---- - const int kBarAreaH = 28; // bars live in y [63-kBarAreaH .. 62] - const int kBaselineY = 63; - display.drawFastHLine(0, kBaselineY, 128, DISPLAY_TEXT_WHITE); - sat_bars::Bar bars[sat_bars::kMaxSats]; - const int barCount = sat_bars::layout(gpsSatCnos, gpsSatCnoCount, 128, - kBarAreaH, bars, sat_bars::kMaxSats); - for (int i = 0; i < barCount; i++) { - if (bars[i].h <= 0) continue; - display.fillRect(bars[i].x, kBaselineY - bars[i].h, bars[i].w, bars[i].h, - DISPLAY_TEXT_WHITE); - } - - safeDisplayUpdate(); -} - -void displayPage_main_menu() { - resetDisplay(); - - // Scrolling menu: 3 size-2 rows (48 px) windowed over the items, plus - // a size-1 scroll-hint line. Four full size-2 rows fill the panel's - // nominal 64 px exactly, but the last row is cut off on real hardware - // — so the window follows the selection instead. - static const char* const kMenuItems[] = {"Race", "Review", "Transfer", "Camera"}; - const int itemCount = (int)(sizeof(kMenuItems) / sizeof(kMenuItems[0])); - const int visibleRows = 3; - - // Keep the selection inside the window (max window start = count - rows). - int first = menuSelectionIndex - 1; - if (first < 0) first = 0; - if (first > itemCount - visibleRows) first = itemCount - visibleRows; - - display.setTextSize(2); - for (int i = first; i < first + visibleRows; i++) { - display.print(menuSelectionIndex == i ? "->" : " "); - display.println(kMenuItems[i]); - } - - // Scroll hints on the spare bottom line. - display.setTextSize(1); - if (first > 0) { - display.print(F("^")); - } else { - display.print(F(" ")); - } - if (first + visibleRows < itemCount) { - display.print(F(" v more")); - } - - safeDisplayUpdate(); -} - -void displayPage_bluetooth() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" Bluetooth Connection")); - display.println(); - - display.setTextSize(2); - if (bleConnected) { - display.println(F(" Connected")); - } else { - display.println(F(" Waiting")); - } - - display.setTextSize(1); - display.println(); - - if (bleTransferInProgress) { - display.print(F("Transfer: ")); - display.print((bleBytesTransferred * 100) / bleFileSize); - display.println(F("%")); - } else { - display.println(); - } - - display.println(); - display.setTextSize(1); - display.println(F("->Exit")); - - safeDisplayUpdate(); -} - -void displayPage_transfer_menu() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" Transfer Mode")); - display.println(); - display.setTextSize(2); - - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("Bluetooth")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("USB")); - - safeDisplayUpdate(); -} - -void displayPage_usb_storage() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" USB Storage")); - display.println(); - - display.setTextSize(2); - display.println(F(" Drive On")); - - display.setTextSize(1); - display.println(); - display.println(F("Connected to PC.")); - display.println(F("Drag & drop files.")); - display.println(); - display.println(F("->Exit (reboots)")); - - safeDisplayUpdate(); -} - -void displayPage_pair_camera() { - resetDisplay(); - - if (cameraIsPaired()) { - // Paired: show the stored serial + Back/Test/Unpair menu. This branch - // also takes over the frame after pairing captures a serial (FSM -> - // kIdle). The three size-2 rows below start at y=16 and fill to y=64, - // so the header stays tight (no blank lines) to keep "Unpair" on-panel. - display.setTextSize(1); - display.println(F(" CAMERA")); - - char serial[7]; - cameraPairedSerial(serial, sizeof(serial)); - display.print(F("Paired: ")); - display.println(serial); - - // Back first (index 0): the page flips from the pairing screen to - // this menu the frame a serial is captured, and a "Cancel" press - // landing one frame late must not hit Unpair and erase the - // just-captured serial. - display.setTextSize(2); - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("Back")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("Test")); - display.print(menuSelectionIndex == 2 ? "->" : " "); - display.println(F("Unpair")); - } else { - // Unpaired: live pairing status from the camera FSM. - display.setTextSize(1); - display.println(F(" PAIR CAMERA")); - display.println(); - - if (cameraFsmState() == camera_fsm::State::kPairing) { - if (cameraRemoteLinkUp()) { - display.println(F("Connected -")); - display.println(F("reading serial...")); - } else { - display.println(F("Power on camera")); - display.println(F("nearby...")); - } - } else { - // Pairing ended without a capture (e.g. 2-min timeout). - display.println(F("Pairing stopped")); - display.println(); - } - - display.println(); - display.println(); - display.println(); - display.println(F("B1:Manual B2:Cancel")); - } - - safeDisplayUpdate(); -} - -void displayPage_camera_test() { - resetDisplay(); - - display.setTextSize(1); - // Title, with the camera's OWN reported record state (its 0x10 timer) on - // the right as an explicit rec:yes/no — proves a Record press actually - // started the camera, not just that we sent a frame. (`rec:--` when there's - // no fresh observation at all: no R-link, or the camera hasn't pushed a - // 0x10 frame yet.) - display.print(F("CAMERA TEST rec:")); - if (!cameraRecordObservationFresh()) { - display.println(F("--")); // no fresh 0x10 (no link, or camera hasn't reported yet) - } else { - display.println(cameraObservedRecording() ? F("yes") : F("no")); - } - - // Live link status so the tester can see what's actually connected: - // R = remote (peripheral) link — the camera connects to us and must be - // paired from its own Bluetooth-remote menu for this to come up. - // R:UP+ = camera connected AND subscribed to ce82 (buttons deliverable); - // R:UP without the + = connected but our button frames go nowhere. - display.print(F("R:")); - if (cameraRemoteLinkUp()) { - display.print(cameraCe82Subscribed() ? F("UP+") : F("UP")); - } else { - display.print(F("--")); - } - // Adv: our advert actually on air — a silently-rejected wake/connect - // advert shows Adv:-- (the "no blue LED" symptom). - display.print(F(" Adv:")); - display.print(cameraAdvertisingUp() ? F("UP") : F("--")); - // G: the 10 Hz GPS/RMC feed to the camera. SYNC = streaming with a fix, - // V = streaming but no lock (voided RMC — still a valid heartbeat), -- = - // not streaming. Confirms the GPS link end-to-end. ("R:UP+ Adv:UP G:SYNC" - // is 19 chars — fits the 21-char panel width.) - display.print(F(" G:")); - if (cameraGpsStreaming()) { - display.println(gpsData.fix ? F("SYNC") : F("V")); - } else { - display.println(F("--")); - } - - // Four size-1 rows follow — no blank line, so "Back" stays on-panel. - // Wake burst only wakes a standby camera (see camera_ble.ino). - static const char* const kTestItems[] = { - "Wake", "Record", "Power Off", "Back"}; - const int itemCount = (int)(sizeof(kTestItems) / sizeof(kTestItems[0])); - for (int i = 0; i < itemCount; i++) { - display.print(menuSelectionIndex == i ? F("->") : F(" ")); - display.println(kTestItems[i]); - } - -#if BIRDSEYE_ENABLE_SENSOREGG - // SensorEgg readout (bottom line): live Temp1 or NA when the egg is - // silent (>1 s) / faulted. Makes this page the coexistence soak-test - // harness: camera linked above + egg streaming here, and the page never - // idle-sleeps (the idle-shutdown and USB-charging entries are - // main-menu-only), so it can sit on a desk indefinitely. - display.print(F("egg: ")); - const float soakEgtF = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); - if (isnan(soakEgtF)) { - display.println(F("NA")); - } else { - display.print(soakEgtF, 1); - display.println(F("F")); - } -#endif - - safeDisplayUpdate(); -} - -void displayPage_camera_serial_entry() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" CAMERA SERIAL")); - - // Six entry characters, size 2 (12 px per column), left margin 16 px. - display.setTextSize(2); - display.setCursor(16, 16); - for (int i = 0; i < 6; i++) { - display.print(cameraSerialEntryBuf[i]); - } - - // Caret under the character being edited (cursor 6/7 = OK/CANCEL row). - if (cameraSerialEntryCursor < 6) { - display.setCursor(16 + cameraSerialEntryCursor * 12, 34); - display.print(F("^")); - } - - // OK / CANCEL on the bottom line; the cursor target renders inverted. - display.setTextSize(1); - display.setCursor(28, 56); - if (cameraSerialEntryCursor == 6) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - } - display.print(F(" OK ")); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.print(F(" ")); - if (cameraSerialEntryCursor == 7) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - } - display.print(F(" CANCEL ")); - display.setTextColor(DISPLAY_TEXT_WHITE); - - safeDisplayUpdate(); -} - -void displayPage_replay_file_select() { - resetDisplay(); - - display.print(F("Select Session: ")); - display.print(menuSelectionIndex + 1); - display.print(F("/")); - display.println(numReplayFiles); - display.println(); - display.setTextSize(1); - - if (numReplayFiles == 0) { - display.println(); - display.println(F("No .dovex files")); - display.println(F("found!")); - 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 { - // 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(); - } - - // 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(); - } - } - - safeDisplayUpdate(); -} - -void displayPage_replay_results() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" Replay Results")); - - // DOVEX replay: display from parsed header data - display.print(F("Laps: ")); - display.println(lapHistoryCount); - - if (lapHistoryCount > 0) { - // Find best lap from history - unsigned long bestTime = lapHistory[0]; - int bestNum = 1; - for (int i = 1; i < lapHistoryCount; i++) { - if (lapHistory[i] < bestTime) { - bestTime = lapHistory[i]; - bestNum = i + 1; - } - } - - display.print(F("Best: ")); - char lapStr[lap_format::kLapTimeStrLen]; - lap_format::formatLapTime(bestTime, lap_format::kOmit, lapStr, sizeof(lapStr)); - display.print(lapStr); - display.print(F(" (L")); - display.print(bestNum); - display.println(F(")")); - - // Show optimal if available - if (strcmp(dovexReplayOptimal, "N/A") != 0 && dovexReplayOptimal[0] != '\0') { - display.print(F("Opt: ")); - unsigned long optMs = strtoul(dovexReplayOptimal, NULL, 10); - lap_format::formatLapTime(optMs, lap_format::kOmit, lapStr, sizeof(lapStr)); - display.println(lapStr); - } - } - - display.print(F("Driver: ")); - display.println(dovexReplayDriver); - display.print(F("Course: ")); - display.println(dovexReplayCourseName); - - display.println(); - display.println(F("<- Laps Exit ->")); - - safeDisplayUpdate(); -} - -void displayPage_replay_exit() { - resetDisplay(); - - display.setTextSize(1); - display.println(F(" Exit Replay?")); - display.println(); - - display.setTextSize(2); - display.println(F("")); - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("Back")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("Exit")); - - safeDisplayUpdate(); -} - -void displayPage_gps_stats() { - resetDisplay(); - - // Safety: GPS stats page requires GPS to be initialized - if (!gpsInitialized) { - display.println(F("GPS not\ninitialized")); - safeDisplayUpdate(); - return; - } - - if (millis() - lastBatteryCheck > batteryUpdateInterval) { - lastBatteryCheck = millis(); - lastBatteryVoltage = getBatteryVoltage(); - } - { - int battPct = getBatteryPercent(lastBatteryVoltage); - display.print(F("Battery : ")); - display.print(battPct); - display.print(F("% ")); - display.print(lastBatteryVoltage, 2); - display.println(F("V")); - } - - - display.print(F("Sats : ")); - display.println(gpsData.satellites); - - display.print(F("Rate : ")); - if (gpsData.fix) { - display.print(gpsFrameRate, 1); - display.println(F("Hz")); - } else { - display.println(F("NO FIX")); - } - - display.print(F("HDOP : ")); - if (gpsData.fix) { - display.println(gpsData.HDOP, 1); - } else { - display.println(F("NO FIX")); - } - - display.print(F("SDCard : ")); - if (!sdSetupSuccess) { - display.println(F("Bad Init")); - } else if (enableLogging && sdDataLogInitComplete) { - display.println(F("Logging")); - } else if (enableLogging && !sdDataLogInitComplete) { - display.println(F("Waiting GPS")); - } else { - display.println(F("Ready")); - } - - // Pipeline-health summary: missing PVT frames and overflow events - // (core RX ring / 4 KB ring). Full attribution on the debug page. - display.print(F("Drops : ")); - display.print(gpsStatsDroppedPvt()); - display.print(F(" Ovf:")); - display.print(gpsStatsCoreSatEvents()); - display.print(F("/")); - display.println(gpsStatsRingFullEvents()); - - if (courseManager != nullptr) { - display.print(F("Track: ")); - display.println(courseManager->getShortName()); - display.print(F("Mode : ")); - const char* cn = courseManager->getActiveCourseName(); - display.println(cn ? cn : "Detecting..."); - } else { - display.print(F("Waiting for GPS...")); - } - - safeDisplayUpdate(); -} - -void displayPage_gps_speed() { - resetDisplay(); - - display.println(F("SPEED")); - - { - int currentLap = activeTimerLaps() + (activeTimerRaceStarted() ? 1 : 0); - if (currentLap > 0) { - display.println(F("\nLAP")); - if (currentLap < 100) { - display.setTextSize(3); - } else { - display.setTextSize(2); - } - display.print(currentLap); - } - } - - display.setCursor(40, 5); - display.setTextSize(7); - // Safety check for GPS access - if (gpsInitialized && gpsData.fix) { - display.println(round(gps_speed_mph)); - } else { - display.println(F("--")); - } - - safeDisplayUpdate(); -} - -void displayPage_gps_lap_time() { - resetDisplay(); - - display.println(F(" Current Lap Time")); - - display.print(F("\n\n")); - display.setTextSize(3); - - bool raceStarted = activeTimerRaceStarted(); - unsigned long currentLapTimeMs = activeTimerCurrentLapTime(); - - if (raceStarted) { - char lapStr[lap_format::kLapTimeStrLen]; - lap_format::formatLapTime(currentLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); - display.print(lapStr); - } else { - display.print(" N/A"); - } - - safeDisplayUpdate(); -} - -void displayPage_gps_pace() { - resetDisplay(); - - display.println(F(" Current Lap Pace")); - - int paceLaps = activeTimerLaps(); - float paceDiff = activeTimerPaceDifference(); - bool paceRaceStarted = activeTimerRaceStarted(); - - // animation - if (paceLaps >= 1 && paceDiff < (-1)) { - if (paceFlashStatus) { - paceFlashStatus = false; - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - display.print(F(" ")); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.println(F(" ")); - } else { - paceFlashStatus = true; - display.setTextColor(DISPLAY_TEXT_WHITE); - display.print(F(" ")); - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - display.println(F(" ")); - } - } - - // main page into - display.setTextColor(DISPLAY_TEXT_WHITE); - const int lineHeight = 21; - if (paceRaceStarted && paceLaps >= 1) { - display.setCursor(0, lineHeight); - display.setTextSize(4); - if (paceDiff > 0) { - display.print(F("+")); - } - display.print(paceDiff); - } else { - display.setTextSize(2); - display.println(); - display.setTextSize(3); - display.print(F(" N/A")); - } - - // animation - display.println(); - display.setTextSize(1); - - if (paceLaps >= 1 && paceDiff < (-1)) { - if (paceFlashStatus) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - display.print(F(" ")); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.println(F(" ")); - } else { - display.setTextColor(DISPLAY_TEXT_WHITE); - display.print(F(" ")); - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - display.println(F(" ")); - } - } - - - safeDisplayUpdate(); -} - -void displayPage_gps_best_lap() { - resetDisplay(); - - display.println(F(" Best Lap")); - display.print(F("\n")); - - bool bestRaceStarted = activeTimerRaceStarted(); - int bestLaps = activeTimerLaps(); - unsigned long bestLapTimeMs = activeTimerBestLapTime(); - int bestLapNum = activeTimerBestLapNumber(); - - if (bestRaceStarted && bestLaps > 0) { - display.setTextSize(3); - char lapStr[lap_format::kLapTimeStrLen]; - lap_format::formatLapTime(bestLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); - display.print(lapStr); - - display.setTextSize(2); - display.print(F("\n\n")); - display.print(F("Lap: ")); - display.print(bestLapNum); - } else { - display.print(F("\n")); - display.setTextSize(3); - display.print(" N/A"); - } - - safeDisplayUpdate(); -} - -void displayPage_tachometer() { - resetDisplay(); - - if (tachLastReported > 9999) { - display.println(F("Engine RPM *OVER REV*")); - } else { - display.println(F(" Engine RPM")); - } - - display.setCursor(5, 20); - display.setTextSize(4); - if (tachLastReported < 10000) { - display.print(F(" ")); - } - if (tachLastReported < 1000) { - display.print(F(" ")); - } - if (tachLastReported < 100) { - display.print(F(" ")); - } - if (tachLastReported < 10) { - display.print(F(" ")); - } - display.println(tachLastReported); - - - display.setTextSize(1); - display.setCursor(0, 55); - if (gpsLockHoldActive) { - // The GPS-lock hold pins the user here with navigation disabled (see - // displayLoop). Say so — a silent pin reads as a crash in the field. - display.print(F(" WAITING GPS LOCK..")); - } else { - display.print(F(" max: ")); - display.print(topTachReported); - } - - safeDisplayUpdate(); -} - -#if BIRDSEYE_ENABLE_SENSOREGG -// SensorEgg wireless EGT page — mirrors the tachometer layout: big value, -// small status subtext. NaN (stale link OR egg-reported invalid probe) -// renders '---'; the reading is NEVER held across a dropout. -// Rendered in Fahrenheit (DOVEX logging stays Celsius); a C/F display -// setting comes later. -void displayPage_sensorTemp() { - resetDisplay(); - - if (sensoreggTcFault()) { - display.println(F("Temp1 F *TC FAULT*")); - } else { - display.println(F(" Temp1 F")); - } - - const float egt = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); - - display.setCursor(5, 20); - display.setTextSize(4); - if (isnan(egt)) { - display.println(F(" ---")); - } else { - char egtStr[8]; - snprintf(egtStr, sizeof(egtStr), "%5d", (int)lroundf(egt)); - display.println(egtStr); - } - - display.setTextSize(1); - display.setCursor(0, 55); - display.print(F(" junc: ")); - const float junc = sensoregg_protocol::celsiusToFahrenheit(sensoreggJunctionC()); - if (isnan(junc)) { - display.print(F("---")); - } else { - display.print(junc, 1); - } - display.print(F(" rf: ")); - if (sensoreggAppHung()) { - // Packets arriving but the egg's app is frozen (sequence not moving) — - // its radio beacons the stale payload forever. Power-cycle the egg. - display.print(F("HUNG")); - } else { - display.print(sensoreggLinkUp() ? F("OK") : F("--")); - } - - safeDisplayUpdate(); -} -#endif // BIRDSEYE_ENABLE_SENSOREGG - -void displayPage_optimal_lap() { - resetDisplay(); - - // Hide optimal lap when no sectors configured (Lap Anything mode) - if (!activeTimerSectorsConfigured()) { - display.println(F(" Optimal Lap")); - display.print(F("\n\n")); - display.setTextSize(2); - display.println(F("No sectors")); - safeDisplayUpdate(); - return; - } - - display.println(F(" Optimal Lap")); - - bool optRaceStarted = activeTimerRaceStarted(); - int optLaps = activeTimerLaps(); - unsigned long optLapTimeMs = activeTimerOptimalLapTime(); - - if (optRaceStarted && optLaps > 0) { - const int lineHeight = 15; - display.setCursor(0, lineHeight); - display.setTextSize(2); - - char lapStr[lap_format::kLapTimeStrLen]; - lap_format::formatLapTime(optLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); - display.print(lapStr); - - display.setCursor(0, lineHeight+20); - display.setTextSize(1); - display.println(F(" Lap Numbers")); - display.setCursor(0, lineHeight+35); - display.setTextSize(2); - display.print(F(" ")); - { - DovesLapTimer* dlt = getActiveTimerDLT(); - if (dlt) { - display.print(dlt->getBestSector1LapNumber()); - display.print(F(" ")); - display.print(dlt->getBestSector2LapNumber()); - display.print(F(" ")); - display.print(dlt->getBestSector3LapNumber()); - } - } - } else { - display.print(F("\n\n")); - display.setTextSize(3); - display.print(" N/A"); - } - - safeDisplayUpdate(); -} - -// TODO: this page probably needs some kind of delayed rendering? -void displayPage_gps_lap_list() { - resetDisplay(); - if (recentlyChanged) { - current_lap_list_page = 0; - } - lap_list_pages = ceil((double)lapHistoryCount / (double)lapsPerPage); - - if (lapHistoryCount >= 1) { - display.print(F(" Lap History ")); - display.print(current_lap_list_page + 1); - display.print(F("/")); - display.print(lap_list_pages); - display.println(F("\n")); - display.setTextSize(2); - - int pageStart = current_lap_list_page * lapsPerPage; - int pageEnd = pageStart + lapsPerPage; - for (int lap = pageStart; lap < pageEnd; ++lap) { - if (lap < lapHistoryCount) { - int actualLap = lap + 1; - if (actualLap < 10) { - display.print(F(" ")); - } - display.print(actualLap); - display.setTextSize(1); - display.print(F(" ")); - display.setTextSize(2); - char lapStr[lap_format::kLapTimeStrLen]; - lap_format::formatLapTime(lapHistory[lap], lap_format::kShow, lapStr, sizeof(lapStr)); - display.println(lapStr); - } - } - } else { - display.println(F(" Lap History ")); - display.setTextSize(2); - display.println(); - display.setTextSize(3); - display.print(F(" N/A")); - } - - safeDisplayUpdate(); -} - -void displayPage_stop_logging() { - resetDisplay(); - - display.setTextSize(2); - display.println(); - display.println(F(" END RACE")); - display.setTextSize(1); - display.println(); - display.println(F(" press middle button")); - - safeDisplayUpdate(); -} - -void displayPage_stop_logging_confirm() { - resetDisplay(); - - display.println(F("Stop Logging?")); - display.println(); - display.setTextSize(2); - - display.println(F("")); - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("BACK")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("END RACE")); - - safeDisplayUpdate(); -} - -void displayPage_gps_debug() { - resetDisplay(); - display.println(F("GPS/RF DEBUG")); - - // Safety check for GPS access - if (!gpsInitialized) { - display.println(F("\nGPS not available")); - safeDisplayUpdate(); - return; - } - - // Serial-pipeline health (gps_stats + ISR counters): missing PVT - // frames + live rate, worst TIMER3 deferral by radio ISRs, drain - // burst high-water vs the core RX capacity, and overflow events - // (core-ring saturations / 4 KB-ring fulls). - display.print(F("Drops:")); - display.print(gpsStatsDroppedPvt()); - display.print(F(" R:")); - display.print(gpsFrameRate, 1); - display.println(F("Hz")); - display.print(F("ISRmax:")); - display.print(gpsStatsIsrLatencyMaxUs()); - display.println(F("us")); - display.print(F("Drain:")); - display.print(gpsStatsDrainMaxBytes()); - display.print(F("/")); - display.print(SERIAL_BUFFER_SIZE); - display.print(F(" Ovf:")); - display.print(gpsStatsCoreSatEvents()); - display.print(F("/")); - display.println(gpsStatsRingFullEvents()); - - // Lap-timer debug (trimmed to fit the 8-row page with the stats). - display.print(F("Laps:")); - display.print(activeTimerLaps()); - display.print(F(" Strt:")); - display.print(activeTimerRaceStarted() ? F("T") : F("F")); - display.print(F(" X:")); - display.println(activeTimerCrossing() ? F("T") : F("F")); - display.print(F("Cur : ")); - display.println(activeTimerCurrentLapTime()); - display.print(F("Best: ")); - display.print(activeTimerBestLapNumber()); - display.print(F(": ")); - display.println(activeTimerBestLapTime()); - display.print(F("Pace: ")); - display.println(activeTimerPaceDifference()); - - safeDisplayUpdate(); -} - -void displayPage_internal_fault() { - resetDisplay(); - display.setCursor(0, 0); - notificationFlash = notificationFlash == true ? false : true; - display.setTextSize(2); - - if (notificationFlash) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - } - display.println(F(" FAULT ")); - display.setTextWrap(true); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.setTextSize(1); - display.println(F(" Please Reboot Device")); - display.println(F("")); - display.println(internalNotification); - safeDisplayUpdate(); -} - -// Boot format-confirm page (PAGE_SD_FORMAT): the SD card answers but has -// no mountable FAT volume. Renders the hold-Select instructions + live -// countdown from the sd_format_page unit. The in-progress/done screens -// are painted by sdPerformFormat() via displayPage_sd_format_progress() -// (the format blocks the main loop, so displayLoop() never runs then). -void displayPage_sd_format() { - resetDisplay(); - display.setCursor(0, 0); - - // Flashing header, same idiom as the fault/warning pages. - notificationFlash = notificationFlash == true ? false : true; - display.setTextSize(2); - if (notificationFlash) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - } - display.println(F("SD FORMAT")); - display.setTextWrap(true); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.setTextSize(1); - if (sdFormatLastFailed) { - display.println(F("Format FAILED - retry")); - } else { - display.println(F("Card is not formatted")); - } - - uint32_t secondsLeft = sd_format_page::holdSecondsLeft(sdFormatState, millis()); - if (secondsLeft > 0) { - display.println(F("")); - display.print(F("Formatting in ")); - display.print(secondsLeft); - display.println(F("s...")); - display.println(F("Keep holding SELECT")); - } else { - display.println(F("Hold SELECT 3s to")); - display.println(F("format the card")); - display.println(F("(ERASES EVERYTHING)")); - } - safeDisplayUpdate(); -} - -// Static two-line status screen used by sdPerformFormat() for its -// "formatting" and "format OK" frames — painted directly because the -// format blocks the main loop and displayLoop() cannot run. -void displayPage_sd_format_progress(const __FlashStringHelper* line1, - const __FlashStringHelper* line2) { - resetDisplay(); - display.setCursor(0, 0); - display.setTextSize(2); - display.println(F("SD FORMAT")); - display.setTextSize(1); - display.println(F("")); - display.println(line1); - display.println(line2); - safeDisplayUpdate(); -} - -void displayPage_internal_warning() { - resetDisplay(); - notificationFlash = notificationFlash == true ? false : true; - - display.setTextSize(2); - if (notificationFlash) { - display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); - } - display.println(F(" WARNING ")); - display.setTextWrap(true); - display.setTextColor(DISPLAY_TEXT_WHITE); - display.setTextSize(1); - display.println(F("Continue With Caution")); - display.println(F("")); - display.println(internalNotification); - safeDisplayUpdate(); -} - -void displayPage_sleep_charging() { - resetDisplay(); - - float voltage = getBatteryVoltage(); - int percent = getBatteryPercent(voltage); - - display.setTextSize(1); - display.setCursor(32, 10); - display.print(F("Charging")); - - display.setTextSize(3); - char buf[8]; - snprintf(buf, sizeof(buf), "%d%%", percent); - int16_t x1, y1; - uint16_t w, h; - display.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h); - display.setCursor((128 - w) / 2, 28); - display.print(buf); - - display.setTextSize(1); - char vbuf[8]; - dtostrf(voltage, 4, 2, vbuf); - display.setCursor(40, 56); - display.print(vbuf); - display.print(F("V")); - - safeDisplayUpdate(); -} - -/////////////////////////////////////////// -void displayCrossing() { - display.clearDisplay(); - display.setTextSize(1); - display.setCursor(0, 0); - - #ifndef ENDURANCE_MODE - // Draw bitmap on the screen - calculatingFlip = calculatingFlip == true ? false : true; - if (calculatingFlip) { - display.drawBitmap(0, 0, image_data_calculating1, 128, 64, 1); - } else { - display.drawBitmap(0, 0, image_data_calculating2, 128, 64, 1); - } - #else - #endif - - safeDisplayUpdate(); -} +/////////////////////////////////////////// +// DISPLAY PAGES MODULE +// All displayPage_*() rendering functions for each UI screen +/////////////////////////////////////////// + +#include "display_pages.h" // also pulls in project.h's build feature flags +#include "crossing_pattern.h" +#include "gps_status_page.h" +#include "lap_format.h" +#include "sat_bars.h" +#include "sd_format_page.h" +#include "nan_bits.h" +#include "sensoregg_protocol.h" + +void displayPage_boot() { + resetDisplay(); + + display.setTextSize(2); + display.println(F(" Doves\n MagicBox")); + display.setTextSize(1); + display.println(F("")); + display.println(F(" Timer + Data Logger")); + display.println(F("\n Initializing...")); + + safeDisplayUpdate(); +} + +// GPS status boot page (MyChron-style): top half is fix/satellite stats, +// bottom half is one vertical signal bar per satellite (height = CNO). +// Shown on every boot; hold/auto-close logic lives in gpsStatusPageLoop(). +void displayPage_gps_status() { + resetDisplay(); + + // GPS never came up — GPS_STATUS_RETRY_LOOP() is re-probing in the + // background (or has given up). Any button still exits to the menu. + if (!gpsInitialized) { + display.println(F("GPS: NOT DETECTED")); + if (gpsRetriesExhausted()) { + display.println(F("\nCHECK WIRING")); + } else { + display.println(F("\nRetrying...")); + } + display.println(F("\nAny button: menu")); + display.print(F("\nup:")); + display.print(millis() / 1000); + display.println(F("s")); + safeDisplayUpdate(); + return; + } + + // ---- Top half (4 size-1 lines, 32 px) ---- + // used-in-solution / tracked-with-signal — the second number matches + // the bar count below (until it exceeds the 16-bar display cap). + display.print(F("Sats:")); + display.print(gpsSatUsedCount); + display.print(F("/")); + display.print(gpsSatTrackedCount); + display.print(F(" HDOP:")); + if (gpsData.fix) { + display.println(gpsData.HDOP, 1); + } else { + display.println(F("--")); + } + + const uint32_t countdown = + gps_status_page::countdownSecondsLeft(gpsStatusState, millis()); + if (countdown > 0) { + display.print(F("LOCKED - ready in ")); + display.println(countdown); + } else { + if (gpsData.fix) { + // "FIX ok" and not "FIX (time sync)": the old wording read as a fix + // TYPE — a time-only, position-less fix — when it actually meant the + // opposite (position is good, the clock isn't yet). That misreading + // cost a bench session, and the natural reaction to it (power-cycle) + // restarts the very countdown being waited on. + display.print(F("FIX ok UTC.. ")); + } else { + display.print(F("ACQUIRING ")); + } + // Uptime breadcrumb: if the device ever reboots off this page, the + // last seconds value on screen identifies which timed path fired + // (~5 s = PVT watchdog -> baud recovery, ~10 s = GPS re-detect retry). + display.print(millis() / 1000); + display.println(F("s")); + } + + // Line 3 carries whichever is more useful right now: while the clock is + // still catching up, WHICH milestone is outstanding (the whole point of + // this line — a bare "no lock" gives the user nothing to wait for or act + // on); once it is locked, the constellation the module is configured for. + switch (gps_status_page::timeSyncState(gpsData.timeDateValid, gpsData.timeResolved)) { + case gps_status_page::TimeSync::kNoDateTime: + display.println(F("UTC: no date/time")); + break; + case gps_status_page::TimeSync::kResolving: + // Bounded, not estimated: the UTC page repeats every ~12.5 min, so + // this is the worst case, and seeing it stops the power-cycling. + display.println(F("UTC: resolving <=12m")); + break; + case gps_status_page::TimeSync::kLocked: + display.println(F("Mode:GPS-only")); + break; + } + + if (millis() - lastBatteryCheck > batteryUpdateInterval) { + lastBatteryCheck = millis(); + lastBatteryVoltage = getBatteryVoltage(); + } + display.print(F("Batt:")); + display.print(getBatteryPercent(lastBatteryVoltage)); + display.print(F("% ")); + display.print(lastBatteryVoltage, 2); + display.println(F("V")); + + // ---- Bottom half: per-satellite CNO bars rising from the baseline ---- + const int kBarAreaH = 28; // bars live in y [63-kBarAreaH .. 62] + const int kBaselineY = 63; + display.drawFastHLine(0, kBaselineY, 128, DISPLAY_TEXT_WHITE); + sat_bars::Bar bars[sat_bars::kMaxSats]; + const int barCount = sat_bars::layout(gpsSatCnos, gpsSatCnoCount, 128, + kBarAreaH, bars, sat_bars::kMaxSats); + for (int i = 0; i < barCount; i++) { + if (bars[i].h <= 0) continue; + display.fillRect(bars[i].x, kBaselineY - bars[i].h, bars[i].w, bars[i].h, + DISPLAY_TEXT_WHITE); + } + + safeDisplayUpdate(); +} + +void displayPage_main_menu() { + resetDisplay(); + + // Scrolling menu: 3 size-2 rows (48 px) windowed over the items, plus + // a size-1 scroll-hint line. Four full size-2 rows fill the panel's + // nominal 64 px exactly, but the last row is cut off on real hardware + // — so the window follows the selection instead. + static const char* const kMenuItems[] = {"Race", "Review", "Transfer", "Create", "Camera"}; + const int itemCount = (int)(sizeof(kMenuItems) / sizeof(kMenuItems[0])); + const int visibleRows = 3; + + // Keep the selection inside the window (max window start = count - rows). + int first = menuSelectionIndex - 1; + if (first < 0) first = 0; + if (first > itemCount - visibleRows) first = itemCount - visibleRows; + + display.setTextSize(2); + for (int i = first; i < first + visibleRows; i++) { + display.print(menuSelectionIndex == i ? "->" : " "); + display.println(kMenuItems[i]); + } + + // Scroll hints on the spare bottom line. + display.setTextSize(1); + if (first > 0) { + display.print(F("^")); + } else { + display.print(F(" ")); + } + if (first + visibleRows < itemCount) { + display.print(F(" v more")); + } + + safeDisplayUpdate(); +} + +void displayPage_bluetooth() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" Bluetooth Connection")); + display.println(); + + display.setTextSize(2); + if (bleConnected) { + display.println(F(" Connected")); + } else { + display.println(F(" Waiting")); + } + + display.setTextSize(1); + display.println(); + + if (bleTransferInProgress) { + display.print(F("Transfer: ")); + display.print((bleBytesTransferred * 100) / bleFileSize); + display.println(F("%")); + } else { + display.println(); + } + + display.println(); + display.setTextSize(1); + display.println(F("->Exit")); + + safeDisplayUpdate(); +} + +void displayPage_transfer_menu() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" Transfer Mode")); + display.println(); + display.setTextSize(2); + + display.print(menuSelectionIndex == 0 ? "->" : " "); + display.println(F("Bluetooth")); + display.print(menuSelectionIndex == 1 ? "->" : " "); + display.println(F("USB")); + + safeDisplayUpdate(); +} + +void displayPage_usb_storage() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" USB Storage")); + display.println(); + + display.setTextSize(2); + display.println(F(" Drive On")); + + display.setTextSize(1); + display.println(); + display.println(F("Connected to PC.")); + display.println(F("Drag & drop files.")); + display.println(); + display.println(F("->Exit (reboots)")); + + safeDisplayUpdate(); +} + +void displayPage_pair_camera() { + resetDisplay(); + + if (cameraIsPaired()) { + // Paired: show the stored serial + Back/Test/Unpair menu. This branch + // also takes over the frame after pairing captures a serial (FSM -> + // kIdle). The three size-2 rows below start at y=16 and fill to y=64, + // so the header stays tight (no blank lines) to keep "Unpair" on-panel. + display.setTextSize(1); + display.println(F(" CAMERA")); + + char serial[7]; + cameraPairedSerial(serial, sizeof(serial)); + display.print(F("Paired: ")); + display.println(serial); + + // Back first (index 0): the page flips from the pairing screen to + // this menu the frame a serial is captured, and a "Cancel" press + // landing one frame late must not hit Unpair and erase the + // just-captured serial. + display.setTextSize(2); + display.print(menuSelectionIndex == 0 ? "->" : " "); + display.println(F("Back")); + display.print(menuSelectionIndex == 1 ? "->" : " "); + display.println(F("Test")); + display.print(menuSelectionIndex == 2 ? "->" : " "); + display.println(F("Unpair")); + } else { + // Unpaired: live pairing status from the camera FSM. + display.setTextSize(1); + display.println(F(" PAIR CAMERA")); + display.println(); + + if (cameraFsmState() == camera_fsm::State::kPairing) { + if (cameraRemoteLinkUp()) { + display.println(F("Connected -")); + display.println(F("reading serial...")); + } else { + display.println(F("Power on camera")); + display.println(F("nearby...")); + } + } else { + // Pairing ended without a capture (e.g. 2-min timeout). + display.println(F("Pairing stopped")); + display.println(); + } + + display.println(); + display.println(); + display.println(); + display.println(F("B1:Manual B2:Cancel")); + } + + safeDisplayUpdate(); +} + +void displayPage_camera_test() { + resetDisplay(); + + display.setTextSize(1); + // Title, with the camera's OWN reported record state (its 0x10 timer) on + // the right as an explicit rec:yes/no — proves a Record press actually + // started the camera, not just that we sent a frame. (`rec:--` when there's + // no fresh observation at all: no R-link, or the camera hasn't pushed a + // 0x10 frame yet.) + display.print(F("CAMERA TEST rec:")); + if (!cameraRecordObservationFresh()) { + display.println(F("--")); // no fresh 0x10 (no link, or camera hasn't reported yet) + } else { + display.println(cameraObservedRecording() ? F("yes") : F("no")); + } + + // Live link status so the tester can see what's actually connected: + // R = remote (peripheral) link — the camera connects to us and must be + // paired from its own Bluetooth-remote menu for this to come up. + // R:UP+ = camera connected AND subscribed to ce82 (buttons deliverable); + // R:UP without the + = connected but our button frames go nowhere. + display.print(F("R:")); + if (cameraRemoteLinkUp()) { + display.print(cameraCe82Subscribed() ? F("UP+") : F("UP")); + } else { + display.print(F("--")); + } + // Adv: our advert actually on air — a silently-rejected wake/connect + // advert shows Adv:-- (the "no blue LED" symptom). + display.print(F(" Adv:")); + display.print(cameraAdvertisingUp() ? F("UP") : F("--")); + // G: the 10 Hz GPS/RMC feed to the camera. SYNC = streaming with a fix, + // V = streaming but no lock (voided RMC — still a valid heartbeat), -- = + // not streaming. Confirms the GPS link end-to-end. ("R:UP+ Adv:UP G:SYNC" + // is 19 chars — fits the 21-char panel width.) + display.print(F(" G:")); + if (cameraGpsStreaming()) { + display.println(gpsData.fix ? F("SYNC") : F("V")); + } else { + display.println(F("--")); + } + + // Four size-1 rows follow — no blank line, so "Back" stays on-panel. + // Wake burst only wakes a standby camera (see camera_ble.ino). + static const char* const kTestItems[] = { + "Wake", "Record", "Power Off", "Back"}; + const int itemCount = (int)(sizeof(kTestItems) / sizeof(kTestItems[0])); + for (int i = 0; i < itemCount; i++) { + display.print(menuSelectionIndex == i ? F("->") : F(" ")); + display.println(kTestItems[i]); + } + +#if BIRDSEYE_ENABLE_SENSOREGG + // SensorEgg readout (bottom line): live Temp1 or NA when the egg is + // silent (>1 s) / faulted. Makes this page the coexistence soak-test + // harness: camera linked above + egg streaming here, and the page never + // idle-sleeps (the idle-shutdown and USB-charging entries are + // main-menu-only), so it can sit on a desk indefinitely. + display.print(F("egg: ")); + const float soakEgtF = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); + if (isNanF(soakEgtF)) { // isNanF: plain isnan() folds to false under -Ofast + display.println(F("NA")); + } else { + display.print(soakEgtF, 1); + display.println(F("F")); + } +#endif + + safeDisplayUpdate(); +} + +void displayPage_camera_serial_entry() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" CAMERA SERIAL")); + + // Six entry characters, size 2 (12 px per column), left margin 16 px. + display.setTextSize(2); + display.setCursor(16, 16); + for (int i = 0; i < 6; i++) { + display.print(cameraSerialEntryBuf[i]); + } + + // Caret under the character being edited (cursor 6/7 = OK/CANCEL row). + if (cameraSerialEntryCursor < 6) { + display.setCursor(16 + cameraSerialEntryCursor * 12, 34); + display.print(F("^")); + } + + // OK / CANCEL on the bottom line; the cursor target renders inverted. + display.setTextSize(1); + display.setCursor(28, 56); + if (cameraSerialEntryCursor == 6) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + } + display.print(F(" OK ")); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.print(F(" ")); + if (cameraSerialEntryCursor == 7) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + } + display.print(F(" CANCEL ")); + display.setTextColor(DISPLAY_TEXT_WHITE); + + safeDisplayUpdate(); +} + +void displayPage_replay_file_select() { + resetDisplay(); + + display.print(F("Select Session: ")); + display.print(menuSelectionIndex + 1); + display.print(F("/")); + display.println(numReplayFiles); + display.println(); + display.setTextSize(1); + + if (numReplayFiles == 0) { + display.println(); + display.println(F("No .dovex files")); + display.println(F("found!")); + 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 { + // 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(); + } + + // 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(); + } + } + + safeDisplayUpdate(); +} + +void displayPage_replay_results() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" Replay Results")); + + // DOVEX replay: display from parsed header data + display.print(F("Laps: ")); + display.println(lapHistoryCount); + + if (lapHistoryCount > 0) { + // Find best lap from history + unsigned long bestTime = lapHistory[0]; + int bestNum = 1; + for (int i = 1; i < lapHistoryCount; i++) { + if (lapHistory[i] < bestTime) { + bestTime = lapHistory[i]; + bestNum = i + 1; + } + } + + display.print(F("Best: ")); + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(bestTime, lap_format::kOmit, lapStr, sizeof(lapStr)); + display.print(lapStr); + display.print(F(" (L")); + display.print(bestNum); + display.println(F(")")); + + // Show optimal if available + if (strcmp(dovexReplayOptimal, "N/A") != 0 && dovexReplayOptimal[0] != '\0') { + display.print(F("Opt: ")); + unsigned long optMs = strtoul(dovexReplayOptimal, NULL, 10); + lap_format::formatLapTime(optMs, lap_format::kOmit, lapStr, sizeof(lapStr)); + display.println(lapStr); + } + } + + display.print(F("Driver: ")); + display.println(dovexReplayDriver); + display.print(F("Course: ")); + display.println(dovexReplayCourseName); + + display.println(); + display.println(F("<- Laps Exit ->")); + + safeDisplayUpdate(); +} + +void displayPage_replay_exit() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" Exit Replay?")); + display.println(); + + display.setTextSize(2); + display.println(F("")); + display.print(menuSelectionIndex == 0 ? "->" : " "); + display.println(F("Back")); + display.print(menuSelectionIndex == 1 ? "->" : " "); + display.println(F("Exit")); + + safeDisplayUpdate(); +} + +void displayPage_gps_stats() { + resetDisplay(); + + // Safety: GPS stats page requires GPS to be initialized + if (!gpsInitialized) { + display.println(F("GPS not\ninitialized")); + safeDisplayUpdate(); + return; + } + + if (millis() - lastBatteryCheck > batteryUpdateInterval) { + lastBatteryCheck = millis(); + lastBatteryVoltage = getBatteryVoltage(); + } + { + int battPct = getBatteryPercent(lastBatteryVoltage); + display.print(F("Battery : ")); + display.print(battPct); + display.print(F("% ")); + display.print(lastBatteryVoltage, 2); + display.println(F("V")); + } + + + display.print(F("Sats : ")); + display.println(gpsData.satellites); + + display.print(F("Rate : ")); + if (gpsData.fix) { + display.print(gpsFrameRate, 1); + display.println(F("Hz")); + } else { + display.println(F("NO FIX")); + } + + display.print(F("HDOP : ")); + if (gpsData.fix) { + display.println(gpsData.HDOP, 1); + } else { + display.println(F("NO FIX")); + } + + display.print(F("SDCard : ")); + if (!sdSetupSuccess) { + display.println(F("Bad Init")); + } else if (enableLogging && sdDataLogInitComplete) { + display.println(F("Logging")); + } else if (enableLogging && !sdDataLogInitComplete) { + display.println(F("Waiting GPS")); + } else { + display.println(F("Ready")); + } + + // Pipeline-health summary: missing PVT frames and overflow events + // (core RX ring / 4 KB ring). Full attribution on the debug page. + display.print(F("Drops : ")); + display.print(gpsStatsDroppedPvt()); + display.print(F(" Ovf:")); + display.print(gpsStatsCoreSatEvents()); + display.print(F("/")); + display.println(gpsStatsRingFullEvents()); + + if (courseManager != nullptr) { + display.print(F("Track: ")); + display.println(courseManager->getShortName()); + display.print(F("Mode : ")); + const char* cn = courseManager->getActiveCourseName(); + display.println(cn ? cn : "Detecting..."); + } else { + display.print(F("Waiting for GPS...")); + } + + safeDisplayUpdate(); +} + +void displayPage_gps_speed() { + resetDisplay(); + + display.println(F("SPEED")); + + { + int currentLap = activeTimerLaps() + (activeTimerRaceStarted() ? 1 : 0); + if (currentLap > 0) { + display.println(F("\nLAP")); + if (currentLap < 100) { + display.setTextSize(3); + } else { + display.setTextSize(2); + } + display.print(currentLap); + } + } + + display.setCursor(40, 5); + display.setTextSize(7); + // Safety check for GPS access + if (gpsInitialized && gpsData.fix) { + display.println(round(gps_speed_mph)); + } else { + display.println(F("--")); + } + + safeDisplayUpdate(); +} + +void displayPage_gps_lap_time() { + resetDisplay(); + + display.println(F(" Current Lap Time")); + + display.print(F("\n\n")); + display.setTextSize(3); + + bool raceStarted = activeTimerRaceStarted(); + unsigned long currentLapTimeMs = activeTimerCurrentLapTime(); + + if (sprintModeIsActive() && !activeTimerRunActive()) { + // Sprint mode, between runs: the session stays live (all pages work), + // but there is no lap ticking — say so instead of a dead 0:00. + display.setTextSize(2); + display.print(F(" *waiting*")); + } else if (raceStarted) { + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(currentLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); + display.print(lapStr); + } else { + display.print(" N/A"); + } + + safeDisplayUpdate(); +} + +void displayPage_gps_pace() { + resetDisplay(); + + display.println(F(" Current Lap Pace")); + + int paceLaps = activeTimerLaps(); + float paceDiff = activeTimerPaceDifference(); + bool paceRaceStarted = activeTimerRaceStarted(); + + // animation + if (paceLaps >= 1 && paceDiff < (-1)) { + if (paceFlashStatus) { + paceFlashStatus = false; + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + display.print(F(" ")); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.println(F(" ")); + } else { + paceFlashStatus = true; + display.setTextColor(DISPLAY_TEXT_WHITE); + display.print(F(" ")); + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + display.println(F(" ")); + } + } + + // main page into + display.setTextColor(DISPLAY_TEXT_WHITE); + const int lineHeight = 21; + if (sprintModeIsActive() && !activeTimerRunActive()) { + // Sprint mode, between runs — no live pace to compare (see lap page). + display.setCursor(0, lineHeight); + display.setTextSize(2); + display.print(F(" *waiting*")); + } else if (paceRaceStarted && paceLaps >= 1) { + display.setCursor(0, lineHeight); + display.setTextSize(4); + if (paceDiff > 0) { + display.print(F("+")); + } + display.print(paceDiff); + } else { + display.setTextSize(2); + display.println(); + display.setTextSize(3); + display.print(F(" N/A")); + } + + // animation + display.println(); + display.setTextSize(1); + + if (paceLaps >= 1 && paceDiff < (-1)) { + if (paceFlashStatus) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + display.print(F(" ")); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.println(F(" ")); + } else { + display.setTextColor(DISPLAY_TEXT_WHITE); + display.print(F(" ")); + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + display.println(F(" ")); + } + } + + + safeDisplayUpdate(); +} + +void displayPage_gps_best_lap() { + resetDisplay(); + + display.println(F(" Best Lap")); + display.print(F("\n")); + + bool bestRaceStarted = activeTimerRaceStarted(); + int bestLaps = activeTimerLaps(); + unsigned long bestLapTimeMs = activeTimerBestLapTime(); + int bestLapNum = activeTimerBestLapNumber(); + + if (bestRaceStarted && bestLaps > 0) { + display.setTextSize(3); + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(bestLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); + display.print(lapStr); + + display.setTextSize(2); + display.print(F("\n\n")); + display.print(F("Lap: ")); + display.print(bestLapNum); + } else { + display.print(F("\n")); + display.setTextSize(3); + display.print(" N/A"); + } + + safeDisplayUpdate(); +} + +void displayPage_tachometer() { + resetDisplay(); + + if (tachLastReported > 9999) { + display.println(F("Engine RPM *OVER REV*")); + } else { + display.println(F(" Engine RPM")); + } + + display.setCursor(5, 20); + display.setTextSize(4); + if (tachLastReported < 10000) { + display.print(F(" ")); + } + if (tachLastReported < 1000) { + display.print(F(" ")); + } + if (tachLastReported < 100) { + display.print(F(" ")); + } + if (tachLastReported < 10) { + display.print(F(" ")); + } + display.println(tachLastReported); + + + display.setTextSize(1); + display.setCursor(0, 55); + if (gpsLockHoldActive) { + // The GPS-lock hold pins the user here with navigation disabled (see + // displayLoop). Say so — a silent pin reads as a crash in the field. + display.print(F(" WAITING GPS LOCK..")); + } else { + display.print(F(" max: ")); + display.print(topTachReported); + } + + safeDisplayUpdate(); +} + +#if BIRDSEYE_ENABLE_SENSOREGG +// SensorEgg wireless EGT page — mirrors the tachometer layout: big value, +// small status subtext. NaN (stale link OR egg-reported invalid probe) +// renders '---'; the reading is NEVER held across a dropout. +// Rendered in Fahrenheit (DOVEX logging stays Celsius); a C/F display +// setting comes later. +void displayPage_sensorTemp() { + resetDisplay(); + + if (sensoreggTcFault()) { + display.println(F("Temp1 F *TC FAULT*")); + } else { + display.println(F(" Temp1 F")); + } + + const float egt = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); + + display.setCursor(5, 20); + display.setTextSize(4); + // isNanF, not isnan: -Ofast folds isnan() to false, and this branch + // then feeds lroundf(NaN) into %5d - the page showed "-214748" on a + // stale link instead of '---'. + if (isNanF(egt)) { + display.println(F(" ---")); + } else { + char egtStr[8]; + snprintf(egtStr, sizeof(egtStr), "%5d", (int)lroundf(egt)); + display.println(egtStr); + } + + display.setTextSize(1); + display.setCursor(0, 55); + display.print(F(" junc: ")); + const float junc = sensoregg_protocol::celsiusToFahrenheit(sensoreggJunctionC()); + if (isNanF(junc)) { + display.print(F("---")); + } else { + display.print(junc, 1); + } + display.print(F(" rf: ")); + if (sensoreggAppHung()) { + // Packets arriving but the egg's app is frozen (sequence not moving) — + // its radio beacons the stale payload forever. Power-cycle the egg. + display.print(F("HUNG")); + } else { + display.print(sensoreggLinkUp() ? F("OK") : F("--")); + } + + safeDisplayUpdate(); +} + +// SensorEgg aux intake-air temp (Temp2, v2 eggs) — same layout and +// staleness rules as the Temp1 page. NaN ('---') also covers a v1 egg, +// which has no aux field at all. The subtext shows the egg's battery +// (real on v2 eggs; '--' = unknown/stale/v1) instead of a junction — +// the thermistor has no cold junction. +void displayPage_sensorTemp2() { + resetDisplay(); + + display.println(F(" Temp2 F")); + + const float aux = sensoregg_protocol::celsiusToFahrenheit(sensoreggAuxC()); + + display.setCursor(5, 20); + display.setTextSize(4); + if (isNanF(aux)) { // isNanF: isnan() folds to false under -Ofast + display.println(F(" ---")); + } else { + char auxStr[8]; + snprintf(auxStr, sizeof(auxStr), "%5d", (int)lroundf(aux)); + display.println(auxStr); + } + + display.setTextSize(1); + display.setCursor(0, 55); + display.print(F(" batt: ")); + const uint8_t pct = sensoreggBatteryPct(); + if (pct > 100) { // 0xFF = unknown (stale link, v1 egg, no pack) + display.print(F("--")); + } else { + display.print(pct); + display.print(F("%")); + } + display.print(F(" rf: ")); + if (sensoreggAppHung()) { + display.print(F("HUNG")); + } else { + display.print(sensoreggLinkUp() ? F("OK") : F("--")); + } + + safeDisplayUpdate(); +} +#endif // BIRDSEYE_ENABLE_SENSOREGG + +void displayPage_optimal_lap() { + resetDisplay(); + + // Hide optimal lap when no sectors configured (Lap Anything mode) + if (!activeTimerSectorsConfigured()) { + display.println(F(" Optimal Lap")); + display.print(F("\n\n")); + display.setTextSize(2); + display.println(F("No sectors")); + safeDisplayUpdate(); + return; + } + + display.println(F(" Optimal Lap")); + + bool optRaceStarted = activeTimerRaceStarted(); + int optLaps = activeTimerLaps(); + unsigned long optLapTimeMs = activeTimerOptimalLapTime(); + + if (optRaceStarted && optLaps > 0) { + const int lineHeight = 15; + display.setCursor(0, lineHeight); + display.setTextSize(2); + + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(optLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); + display.print(lapStr); + + display.setCursor(0, lineHeight+20); + display.setTextSize(1); + display.println(F(" Lap Numbers")); + display.setCursor(0, lineHeight+35); + display.setTextSize(2); + display.print(F(" ")); + { + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) { + display.print(dlt->getBestSector1LapNumber()); + display.print(F(" ")); + display.print(dlt->getBestSector2LapNumber()); + display.print(F(" ")); + display.print(dlt->getBestSector3LapNumber()); + } + } + } else { + display.print(F("\n\n")); + display.setTextSize(3); + display.print(" N/A"); + } + + safeDisplayUpdate(); +} + +// TODO: this page probably needs some kind of delayed rendering? +void displayPage_gps_lap_list() { + resetDisplay(); + if (recentlyChanged) { + current_lap_list_page = 0; + } + lap_list_pages = ceil((double)lapHistoryCount / (double)lapsPerPage); + + if (lapHistoryCount >= 1) { + display.print(F(" Lap History ")); + display.print(current_lap_list_page + 1); + display.print(F("/")); + display.print(lap_list_pages); + display.println(F("\n")); + display.setTextSize(2); + + int pageStart = current_lap_list_page * lapsPerPage; + int pageEnd = pageStart + lapsPerPage; + for (int lap = pageStart; lap < pageEnd; ++lap) { + if (lap < lapHistoryCount) { + int actualLap = lap + 1; + if (actualLap < 10) { + display.print(F(" ")); + } + display.print(actualLap); + display.setTextSize(1); + display.print(F(" ")); + display.setTextSize(2); + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(lapHistory[lap], lap_format::kShow, lapStr, sizeof(lapStr)); + display.println(lapStr); + } + } + } else { + display.println(F(" Lap History ")); + display.setTextSize(2); + display.println(); + display.setTextSize(3); + display.print(F(" N/A")); + } + + safeDisplayUpdate(); +} + +void displayPage_stop_logging() { + resetDisplay(); + + display.setTextSize(2); + display.println(); + display.println(F(" END RACE")); + display.setTextSize(1); + display.println(); + display.println(F(" press middle button")); + + safeDisplayUpdate(); +} + +void displayPage_stop_logging_confirm() { + resetDisplay(); + + display.println(F("Stop Logging?")); + display.println(); + display.setTextSize(2); + + display.println(F("")); + display.print(menuSelectionIndex == 0 ? "->" : " "); + display.println(F("BACK")); + display.print(menuSelectionIndex == 1 ? "->" : " "); + display.println(F("END RACE")); + + safeDisplayUpdate(); +} + +void displayPage_gps_debug() { + resetDisplay(); + display.println(F("GPS/RF DEBUG")); + + // Safety check for GPS access + if (!gpsInitialized) { + display.println(F("\nGPS not available")); + safeDisplayUpdate(); + return; + } + + // Serial-pipeline health (gps_stats + ISR counters): missing PVT + // frames + live rate, worst TIMER3 deferral by radio ISRs, drain + // burst high-water vs the core RX capacity, and overflow events + // (core-ring saturations / 4 KB-ring fulls). + display.print(F("Drops:")); + display.print(gpsStatsDroppedPvt()); + display.print(F(" R:")); + display.print(gpsFrameRate, 1); + display.println(F("Hz")); + display.print(F("ISRmax:")); + display.print(gpsStatsIsrLatencyMaxUs()); + display.println(F("us")); + display.print(F("Drain:")); + display.print(gpsStatsDrainMaxBytes()); + display.print(F("/")); + display.print(SERIAL_BUFFER_SIZE); + display.print(F(" Ovf:")); + display.print(gpsStatsCoreSatEvents()); + display.print(F("/")); + display.println(gpsStatsRingFullEvents()); + + // Lap-timer debug (trimmed to fit the 8-row page with the stats). + display.print(F("Laps:")); + display.print(activeTimerLaps()); + display.print(F(" Strt:")); + display.print(activeTimerRaceStarted() ? F("T") : F("F")); + display.print(F(" X:")); + display.println(activeTimerCrossing() ? F("T") : F("F")); + display.print(F("Cur : ")); + display.println(activeTimerCurrentLapTime()); + display.print(F("Best: ")); + display.print(activeTimerBestLapNumber()); + display.print(F(": ")); + display.println(activeTimerBestLapTime()); + display.print(F("Pace: ")); + display.println(activeTimerPaceDifference()); + + safeDisplayUpdate(); +} + +void displayPage_internal_fault() { + resetDisplay(); + display.setCursor(0, 0); + notificationFlash = notificationFlash == true ? false : true; + display.setTextSize(2); + + if (notificationFlash) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + } + display.println(F(" FAULT ")); + display.setTextWrap(true); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.setTextSize(1); + display.println(F(" Please Reboot Device")); + display.println(F("")); + display.println(internalNotification); + safeDisplayUpdate(); +} + +// Boot format-confirm page (PAGE_SD_FORMAT): the SD card answers but has +// no mountable FAT volume. Renders the hold-Select instructions + live +// countdown from the sd_format_page unit. The in-progress/done screens +// are painted by sdPerformFormat() via displayPage_sd_format_progress() +// (the format blocks the main loop, so displayLoop() never runs then). +void displayPage_sd_format() { + resetDisplay(); + display.setCursor(0, 0); + + // Flashing header, same idiom as the fault/warning pages. + notificationFlash = notificationFlash == true ? false : true; + display.setTextSize(2); + if (notificationFlash) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + } + display.println(F("SD FORMAT")); + display.setTextWrap(true); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.setTextSize(1); + if (sdFormatLastFailed) { + display.println(F("Format FAILED - retry")); + } else { + display.println(F("Card is not formatted")); + } + + uint32_t secondsLeft = sd_format_page::holdSecondsLeft(sdFormatState, millis()); + if (secondsLeft > 0) { + display.println(F("")); + display.print(F("Formatting in ")); + display.print(secondsLeft); + display.println(F("s...")); + display.println(F("Keep holding SELECT")); + } else { + display.println(F("Hold SELECT 3s to")); + display.println(F("format the card")); + display.println(F("(ERASES EVERYTHING)")); + } + safeDisplayUpdate(); +} + +// Static two-line status screen used by sdPerformFormat() for its +// "formatting" and "format OK" frames — painted directly because the +// format blocks the main loop and displayLoop() cannot run. +void displayPage_sd_format_progress(const __FlashStringHelper* line1, + const __FlashStringHelper* line2) { + resetDisplay(); + display.setCursor(0, 0); + display.setTextSize(2); + display.println(F("SD FORMAT")); + display.setTextSize(1); + display.println(F("")); + display.println(line1); + display.println(line2); + safeDisplayUpdate(); +} + +void displayPage_internal_warning() { + resetDisplay(); + notificationFlash = notificationFlash == true ? false : true; + + display.setTextSize(2); + if (notificationFlash) { + display.setTextColor(DISPLAY_TEXT_BLACK, DISPLAY_TEXT_WHITE); + } + display.println(F(" WARNING ")); + display.setTextWrap(true); + display.setTextColor(DISPLAY_TEXT_WHITE); + display.setTextSize(1); + display.println(F("Continue With Caution")); + display.println(F("")); + display.println(internalNotification); + safeDisplayUpdate(); +} + +void displayPage_sleep_charging() { + resetDisplay(); + + float voltage = getBatteryVoltage(); + int percent = getBatteryPercent(voltage); + + display.setTextSize(1); + display.setCursor(32, 10); + display.print(F("Charging")); + + display.setTextSize(3); + char buf[8]; + snprintf(buf, sizeof(buf), "%d%%", percent); + int16_t x1, y1; + uint16_t w, h; + display.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h); + display.setCursor((128 - w) / 2, 28); + display.print(buf); + + display.setTextSize(1); + char vbuf[8]; + dtostrf(voltage, 4, 2, vbuf); + display.setCursor(40, 56); + display.print(vbuf); + display.print(F("V")); + + safeDisplayUpdate(); +} + +/////////////////////////////////////////// +void displayCrossing() { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + + #ifndef ENDURANCE_MODE + // Two-frame block animation, generated rather than stored: the frames + // were 2 KB of PROGMEM describing eight 16x16 cells. The host-tested + // crossing_pattern unit emits those cells and its golden test asserts + // the raster is byte-identical to the bitmaps this replaced. + calculatingFlip = calculatingFlip == true ? false : true; + crossing_pattern::Rect cells[crossing_pattern::kMaxRects]; + const int cellCount = + crossing_pattern::frameRects(calculatingFlip, cells, crossing_pattern::kMaxRects); + for (int i = 0; i < cellCount; i++) { + display.fillRect(cells[i].x, cells[i].y, cells[i].w, cells[i].h, DISPLAY_TEXT_WHITE); + } + #else + #endif + + safeDisplayUpdate(); +} + +/////////////////////////////////////////// +// ON-DEVICE COURSE CREATOR PAGES (plan 0002 §5) +// +// Five screens over the host-tested course_creator model. Every row shown +// here comes from course_creator::rowAt() rather than a local list, so a +// row can never render in one order and act in another. +/////////////////////////////////////////// + +// Shared header: what is being built and where it lands, so the user +// always knows whether they are walking a circuit or a sprint course. +// +// Budget is the panel's 21 size-1 characters and a track name can use 13 +// of them (MAX_LOCATION_LENGTH), so the type is abbreviated to keep the +// name whole — the name is the part that answers "am I adding this to the +// right track?". +static void courseCreatorHeader() { + display.setTextSize(1); + display.print(courseCreator.kind == course_creator::CourseKind::kSprint + ? F("SPRINT @") : F("CIRC @")); + display.println(courseCreator.newTrack ? "NEW" : courseCreatorTrackName); +} + +void displayPage_course_track() { + resetDisplay(); + + display.setTextSize(1); + display.println(F(" CREATE COURSE")); + display.println(F("Are you at:")); + display.setTextSize(2); + display.println(courseCreatorTrackName); + + display.setTextSize(1); + display.println(); + display.print(menuSelectionIndex == 0 ? F("->") : F(" ")); + display.println(F("Yes - add course")); + display.print(menuSelectionIndex == 1 ? F("->") : F(" ")); + display.println(F("No - new track")); + + safeDisplayUpdate(); +} + +void displayPage_course_type() { + resetDisplay(); + + 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")); + + // The difference that matters when you are about to walk it. + display.setTextSize(1); + display.println(); + display.println(menuSelectionIndex == 0 ? F("one start/finish line") + : F("start + finish lines")); + + safeDisplayUpdate(); +} + +// Short reason Save is refused, for the Save row. Kept to the panel width. +static const __FlashStringHelper* courseSaveBlockText() { + switch (course_creator::saveBlocked(courseCreator)) { + case course_creator::SaveBlock::kStartMissing: return F("need start"); + case course_creator::SaveBlock::kFinishMissing: return F("need finish"); + case course_creator::SaveBlock::kSectorPair: return F("need S2+S3"); + case course_creator::SaveBlock::kSplitOrder: return F("S2 before S3"); + case course_creator::SaveBlock::kNone: return F(""); + } + return F(""); +} + +// Why the last save attempt failed, or nullptr when nothing has failed. +static const __FlashStringHelper* courseSaveErrorText() { + switch (courseCreatorLastError) { + case SD_COURSE_WRITE_BUSY: return F("SD busy - retry"); + case SD_COURSE_WRITE_NO_TRACK: return F("track file bad"); + case SD_COURSE_WRITE_TOO_BIG: return F("track file full"); + case SD_COURSE_WRITE_IO: return F("SD write failed"); + case SD_COURSE_WRITE_EXISTS: return F("name taken"); + case SD_COURSE_WRITE_OK: return nullptr; + } + return nullptr; +} + +void displayPage_course_lines() { + resetDisplay(); + courseCreatorHeader(); + + const uint8_t rows = course_creator::rowCount(courseCreator); + for (uint8_t i = 0; i < rows; i++) { + const course_creator::RowRef ref = course_creator::rowAt(courseCreator, i); + display.print(menuSelectionIndex == (int)i ? F("->") : F(" ")); + + if (ref.row == course_creator::Row::kLine) { + display.print(course_creator::lineLabel(ref.line, courseCreator.kind)); + if (course_creator::lineRequired(ref.line, courseCreator.kind)) { + display.print(F("*")); + } + if (course_creator::lineDone(course_creator::lineOf(courseCreator, ref.line))) { + display.print(F(" DONE")); + } + display.println(); + } else if (ref.row == course_creator::Row::kSave) { + display.print(F("Save")); + // Saying WHY beats a row that silently does nothing when pressed. + if (!course_creator::canSave(courseCreator)) { + display.print(F(" - ")); + display.print(courseSaveBlockText()); + } + display.println(); + } else { + display.println(F("Cancel")); + } + } + + const __FlashStringHelper* err = courseSaveErrorText(); + if (err != nullptr) display.print(err); + + safeDisplayUpdate(); +} + +void displayPage_course_line() { + resetDisplay(); + + display.setTextSize(1); + display.print(F("LINE: ")); + display.println(course_creator::lineLabel(courseCreator.editing, courseCreator.kind)); + display.println(); + + // Point rows read from the SCRATCH copy — what Save would commit, not + // what is already stored. That is what makes Back a real undo. + display.print(menuSelectionIndex == 0 ? F("->") : F(" ")); + display.print(F("Point A")); + display.println(courseCreator.scratch.hasA ? F(" DONE") : F(" *")); + + display.print(menuSelectionIndex == 1 ? F("->") : F(" ")); + display.print(F("Point B")); + display.println(courseCreator.scratch.hasB ? F(" DONE") : F(" *")); + + display.println(); + display.print(menuSelectionIndex == 2 ? F("->") : F(" ")); + display.println(F("Save line")); + display.print(menuSelectionIndex == 3 ? F("->") : F(" ")); + display.println(F("Back (discard)")); + + safeDisplayUpdate(); +} + +void displayPage_course_point() { + resetDisplay(); + + display.setTextSize(1); + display.print(course_creator::lineLabel(courseCreator.editing, courseCreator.kind)); + display.print(F(" : ")); + display.println(courseCreator.editingPointB ? F("B") : F("A")); + + const uint32_t now = millis(); + const course_creator::CaptureResult result = + course_creator::capturePoll(courseCreator, now); + + if (result == course_creator::CaptureResult::kRunning) { + // Hold-still feedback: the average is only as good as the user standing + // still for it, so show both the countdown and the fix count. + display.setTextSize(2); + display.print(course_creator::capturePercent(courseCreator, now)); + display.println(F("%")); + display.setTextSize(1); + display.println(F("hold still...")); + display.print(F("fixes: ")); + display.println(courseCreator.capture.fixes); + if (courseCreator.capture.rejected > 0) { + display.print(F("dropped: ")); + display.println(courseCreator.capture.rejected); + } + safeDisplayUpdate(); + return; + } + + // Live accuracy, so the user can wait for the fix to settle before + // starting a hold instead of discovering it afterwards. + display.print(F("acc: ")); + if (gpsData.fix) { + display.print(gpsData.horizontalAccuracy, 1); + display.print(F("m")); + if (gpsData.horizontalAccuracy > course_creator::kCaptureMaxHAccM) { + display.println(F(" TOO POOR")); + } else if (gpsData.horizontalAccuracy > course_creator::kCaptureWarnHAccM) { + display.println(F(" weak")); + } else { + display.println(); + } + } else { + display.println(F("NO FIX")); + } + + if (courseCreator.captureFailed) { + display.println(F("too few fixes -")); + display.println(F("try again")); + } else { + display.println(); + display.println(); + } + + display.print(menuSelectionIndex == 0 ? F("->") : F(" ")); + display.println(F("Save current pos")); + display.print(menuSelectionIndex == 1 ? F("->") : F(" ")); + display.println(F("Back")); + + safeDisplayUpdate(); +} diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 62125e8..a420fbd 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -1,860 +1,912 @@ -/////////////////////////////////////////// -// DISPLAY UI MODULE -// Display setup, button handling, menu navigation, and display loop -/////////////////////////////////////////// - -#include "display_ui.h" - -// Explicit, though Arduino's .ino concatenation already pulls it in via -// BirdsEye.ino: the build feature flags below must never silently evaluate -// as undefined (0) because an include order changed. -#include "project.h" - -/////////////////////////////////////////// -// I2C BUS RECOVERY -// EMI from ignition can glitch the I2C bus, leaving a slave holding SDA low. -// The Wire library may hang forever waiting. This recovery routine bit-bangs -// 9 SCL clocks to free a stuck slave, then re-initializes Wire. -/////////////////////////////////////////// - -static bool i2cRecoveryNeeded = false; - -void i2cBusRecover() { - debugln(F("I2C: Bus recovery - bit-banging 9 SCL clocks")); - - // Feed the watchdog before each potentially-blocking I2C re-init step. - // If ignition EMI is still glitching the bus, Wire.begin() / display.begin() - // can stall on it — without these pets the recovery routine itself would - // trip the 4 s WDT and reboot, then re-trigger on the next boot (boot loop). - // Mirrors the GPS baud-recovery hardening in gps_functions.ino. - wdtPet(); - - Wire.end(); - - // Manually toggle SCL 9 times to free stuck slave - // SDA must be floating (input) so slave can release it - pinMode(PIN_WIRE_SDA, INPUT); - pinMode(PIN_WIRE_SCL, OUTPUT); - - for (int i = 0; i < 9; i++) { - digitalWrite(PIN_WIRE_SCL, LOW); - delayMicroseconds(5); - digitalWrite(PIN_WIRE_SCL, HIGH); - delayMicroseconds(5); - } - - // Generate STOP condition: SDA low-to-high while SCL is high - pinMode(PIN_WIRE_SDA, OUTPUT); - digitalWrite(PIN_WIRE_SDA, LOW); - delayMicroseconds(5); - digitalWrite(PIN_WIRE_SCL, HIGH); - delayMicroseconds(5); - digitalWrite(PIN_WIRE_SDA, HIGH); - delayMicroseconds(5); - - // Re-init Wire - wdtPet(); - Wire.begin(); - Wire.setClock(400000); // Must re-set after begin() (resets to 100kHz) - - // Re-init display - wdtPet(); - #ifdef USE_1306_DISPLAY - display.begin(SSD1306_SWITCHCAPVCC, I2C_DISPLAY_ADDRESS); - #else - display.begin(I2C_DISPLAY_ADDRESS, true); - #endif - - wdtPet(); - debugln(F("I2C: Bus recovery complete")); -} - -// Safe wrapper around display.display() - detects hung I2C and recovers. -// A normal 1024-byte I2C transfer at 400kHz takes ~25ms. -// If it takes >100ms, something is wrong (EMI glitch or bus hang). -void safeDisplayUpdate() { - unsigned long start = millis(); - display.display(); - unsigned long elapsed = millis() - start; - - if (elapsed > 100) { - // Recover immediately rather than deferring to the next frame — a stuck - // bus would otherwise get one more (also-slow) paint before recovery runs. - debugln(F("I2C: display.display() took too long, recovering now")); - i2cRecoveryNeeded = false; - i2cBusRecover(); - } -} - -void setupButtons() { - #ifndef SIM - // greybox - btn1->pin = 1; - btn2->pin = 2; - btn3->pin = 3; - - #else - btn1->pin = 4; - btn2->pin = 5; - btn3->pin = 6; - #endif - - pinMode(btn1->pin, INPUT_PULLUP); - pinMode(btn2->pin, INPUT_PULLUP); - pinMode(btn3->pin, INPUT_PULLUP); -} - -void readButtons() { - checkButton(btn1); - checkButton(btn2); - checkButton(btn3); - - //force update when button pressed - if ( - btn1->pressed || - btn2->pressed || - btn3->pressed - ) { - forceDisplayRefresh(); - } -} - -void resetButtons() { - resetButton(btn1); - resetButton(btn2); - resetButton(btn3); -} - -void updateButtonHoldState() { - bool b1 = readButtonMultiSample(btn1->pin); - bool b2 = readButtonMultiSample(btn2->pin); - bool b3 = readButtonMultiSample(btn3->pin); - - // Track continuous hold duration per button - if (b1) { if (!btn1Held) { btn1HoldStart = millis(); btn1Held = true; } } - else { btn1Held = false; } - - if (b2) { if (!btn2Held) { btn2HoldStart = millis(); btn2Held = true; } } - else { btn2Held = false; } - - if (b3) { if (!btn3Held) { btn3HoldStart = millis(); btn3Held = true; } } - else { btn3Held = false; } -} - -bool isButtonHeld(int btnNum, unsigned long durationMs) { - unsigned long start; - bool held; - switch(btnNum) { - case 1: start = btn1HoldStart; held = btn1Held; break; - case 2: start = btn2HoldStart; held = btn2Held; break; - case 3: start = btn3HoldStart; held = btn3Held; break; - default: return false; - } - return held && start > 0 && (millis() - start >= durationMs); -} - -bool anyButtonPressed() { - return readButtonMultiSample(btn1->pin) || - readButtonMultiSample(btn2->pin) || - readButtonMultiSample(btn3->pin); -} - -void resetButton(ButtonState* button) { - button->pressed = false; -} - -/** - * @brief Multi-sample button read with EMI rejection - * - * Takes multiple samples with small delays and requires ALL samples - * to show the button pressed. This rejects transient EMI spikes that - * might cause a single false LOW reading. - * - * @param pin The GPIO pin to read - * @return true only if ALL samples show button pressed (LOW) - */ -bool readButtonMultiSample(int pin) { - for (int i = 0; i < BUTTON_SAMPLE_COUNT; i++) { - if (digitalRead(pin) != LOW) { - return false; // Any HIGH reading = not pressed - } - if (i < BUTTON_SAMPLE_COUNT - 1) { - delayMicroseconds(BUTTON_SAMPLE_DELAY_US); - } - } - return true; // All samples were LOW = definitely pressed -} - -/** - * @brief Check button state with debouncing, edge detection, and multi-sample verification - * - * Uses edge detection to only trigger on button PRESS (not while held). - * Button must be released before it can trigger again. - */ -void checkButton(ButtonState* button) { - // Multi-sample read: require consistent LOW across all samples - bool btnCurrentlyPressed = readButtonMultiSample(button->pin); - - if (!btnCurrentlyPressed) { - // Button is released - mark it as ready for next press - button->wasReleased = true; - return; - } - - // Button is pressed - check if we should register this press - // Requires: 1) button was released since last press (edge detection) - // 2) debounce time has passed - bool btnReady = millis() - button->lastPressed >= antiBounceIntv; - - if (button->wasReleased && btnReady) { - button->lastPressed = millis(); - button->pressed = true; - button->wasReleased = false; // Must release before next press - } -} - -////////////////////////////////////////// -// TODO: make display into own class?? -void resetDisplay() { - if (currentPage != lastPage) { - lastPage = currentPage; - recentlyChanged = true; - menuSelectionIndex = 0; - } else { - recentlyChanged = false; - } - display.setTextWrap(false); - display.clearDisplay(); - display.setTextSize(1); - display.setCursor(0, 0); - - display.setTextColor(DISPLAY_TEXT_WHITE); -} - -void forceDisplayRefresh() { - // why does adding work but not subtracting? - displayLastUpdate += 5000; -} - -void switchToDisplayPage(int newDisplayPage) { - currentPage = newDisplayPage; - forceDisplayRefresh(); -} - -/////////////////////////////////////////// - -void displaySetup() { - debugln(F("SETTING UP DISPLAY")); - delay(250); // wait for the OLED to power up - - // Set I2C timeout to prevent infinite hangs from EMI-induced bus faults - Wire.setTimeout(100); - -#ifdef USE_1306_DISPLAY - display.begin(SSD1306_SWITCHCAPVCC, I2C_DISPLAY_ADDRESS); -#else - display.begin(I2C_DISPLAY_ADDRESS, true); -#endif - - // 400kHz I2C: reduces display.display() from ~100ms to ~25ms. - // At 100kHz, the 1024-byte framebuffer transfer blocks long enough - // for 2-3 GPS PVT messages (40ms each) to arrive, but the SparkFun - // library's auto-PVT buffer only keeps the latest — losing ~2 samples - // every display refresh (3Hz). At 400kHz the transfer completes within - // a single PVT interval, eliminating the loss. - Wire.setClock(400000); - - display.setTextColor(DISPLAY_TEXT_WHITE); - display.setTextWrap(false); - - setupButtons(); - - displayLastUpdate = millis(); - - currentPage = PAGE_BOOT; - - // silly boot splash, maybe anim? - resetDisplay(); - display.drawBitmap(0, 0, image_data_bird1, 128, 64, 1); - safeDisplayUpdate(); - delay(750); - - displayPage_boot(); -} - -void handleMenuPageSelection() { - if (currentPage == PAGE_MAIN_MENU) { - if (menuSelectionIndex == 0) { - // Race selected — go directly to race mode, start logging on GPS fix - debugln(F("Main Menu: Race selected")); - raceActive = true; - enableLogging = true; - raceSessionStartedAt = millis(); - // Create CourseManager if not already created by track detection - createLapAnythingCourseManager(); - switchToDisplayPage(GPS_SPEED); - } else if (menuSelectionIndex == 1) { - // Replay selected - debugln(F("Main Menu: Replay selected")); - resetReplayState(); - if (buildReplayFileList()) { - switchToDisplayPage(PAGE_REPLAY_FILE_SELECT); - } else { - strncpy(internalNotification, "No .dovex files\nfound on SD!", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } else if (menuSelectionIndex == 2) { - // Transfer selected — open the Bluetooth-vs-USB submenu - debugln(F("Main Menu: Transfer selected")); - switchToDisplayPage(PAGE_TRANSFER_MENU); - } else { - // Camera selected — paired shows status/unpair, unpaired starts pairing - debugln(F("Main Menu: Camera selected")); - if (!cameraIsPaired()) { - cameraRequestPair(); // pairing begins as the page comes up - } - switchToDisplayPage(PAGE_PAIR_CAMERA); - } - } else if (currentPage == PAGE_PAIR_CAMERA) { - // Only a menu while paired (Back / Test / Unpair) — the unpaired - // pairing screen handles its buttons in the custom branch in - // displayLoop(). Back is index 0 so a late "Cancel" press right after - // a serial capture can't land on Test or Unpair. - if (menuSelectionIndex == 0) { - debugln(F("Camera: Back selected")); - switchToDisplayPage(PAGE_MAIN_MENU); - } else if (menuSelectionIndex == 1) { - debugln(F("Camera: Test selected")); - cameraTestEnterMode(); - switchToDisplayPage(PAGE_CAMERA_TEST); - } else { - debugln(F("Camera: Unpair selected")); - if (cameraRequestUnpair()) { - switchToDisplayPage(PAGE_MAIN_MENU); - } else { - // FSM is mid-session (waking/recording/cooldown) — refuse - strncpy(internalNotification, "Camera busy -\nend session first", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } - } else if (currentPage == PAGE_CAMERA_TEST) { - // Bench-test controls for the paired camera. Items: - // 0 Wake — standby wake burst (no effect on a fully-off camera) - // 1 Record — ce82 shutter toggle (needs R link + ce82 subscribed) - // 2 Power Off — ce82 power hold (needs R link + ce82 subscribed) - // 3 Back - // Record and Power Off both ride the ce82 button characteristic, so - // both need the camera connected to us (R) and subscribed. Share the - // failure-diagnosis path. - if (menuSelectionIndex == 0) { - debugln(F("Camera Test: Wake")); - cameraTestWake(); - } else if (menuSelectionIndex == 1 || menuSelectionIndex == 2) { - const bool ok = (menuSelectionIndex == 1) ? cameraTestRecord() - : cameraTestPowerOff(); - debugln(menuSelectionIndex == 1 ? F("Camera Test: Record") - : F("Camera Test: Power Off")); - if (!ok) { - // Distinguish the failure for the tester: no R-link at all vs - // connected-but-never-subscribed (camera ignoring our buttons). - if (!cameraRemoteLinkUp()) { - strncpy(internalNotification, "No remote link -\nrun Wake first", - sizeof(internalNotification) - 1); - } else if (!cameraCe82Subscribed()) { - strncpy(internalNotification, "Camera not subbed\nto buttons (ce82)", - sizeof(internalNotification) - 1); - } else { - strncpy(internalNotification, "Button send\nrejected by stack", - sizeof(internalNotification) - 1); - } - internalNotification[sizeof(internalNotification) - 1] = '\0'; - // The warning page dismisses to the MAIN MENU — leave bench-test - // mode first or cameraTestActive would stay latched (FSM - // suppressed) with no way back to this menu's Back action. - cameraTestExitMode(); - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } else { - debugln(F("Camera Test: Back")); - cameraTestExitMode(); - switchToDisplayPage(PAGE_PAIR_CAMERA); - } - forceDisplayRefresh(); - } else if (currentPage == PAGE_TRANSFER_MENU) { - 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 - CAMERA_FORCE_RELEASE(); - BLE_SETUP(); - switchToDisplayPage(PAGE_BLUETOOTH); - } else { - // USB mass storage — show the status page, then enumerate the drive. - // Release the camera first: the USB parking branch never runs - // CAMERA_LOOP(), so an in-flight camera session would otherwise be - // frozen (advert broadcasting unserviced / cooldown never expiring) - // for the whole USB session. - debugln(F("Transfer: USB selected")); - // No cable = the USB page would instantly reboot (its parked loop reads - // absent VBUS as "cable pulled"). Guide the user instead of bouncing. - if (!isUsbConnected()) { - strncpy(internalNotification, "Plug in USB\ncable first!", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } else { - CAMERA_FORCE_RELEASE(); - switchToDisplayPage(PAGE_USB_STORAGE); - if (!USB_MSC_ENABLE()) { - // SD busy with another subsystem — bounce back to the submenu - strncpy(internalNotification, "SD busy, cannot\nstart USB mode!", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } - } - } else if (currentPage == PAGE_USB_STORAGE) { - // Exit — reboot to drop the drive and remount a fresh filesystem - 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 - switchToDisplayPage(PAGE_MAIN_MENU); - } else { - selectedReplayFile = menuSelectionIndex; - debug(F("Replay: Selected file: ")); - debugln(replayFiles[selectedReplayFile]); - - // DOVEX instant replay: parse header and go straight to results - if (parseDovexHeader(replayFiles[selectedReplayFile])) { - replayProcessingComplete = true; - switchToDisplayPage(PAGE_REPLAY_RESULTS); - } else { - strncpy(internalNotification, "Cannot read DOVEX\nheader (incomplete?)", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } - } else if (currentPage == PAGE_REPLAY_EXIT) { - if (menuSelectionIndex == 0) { - // Back - return to results - switchToDisplayPage(PAGE_REPLAY_RESULTS); - } else { - // Exit - return to main menu - resetReplayState(); - switchToDisplayPage(PAGE_MAIN_MENU); - } - } else if (currentPage == PAGE_BLUETOOTH) { - // Exit button pressed - go back to main menu and disable bluetooth - debugln(F("Bluetooth: Exit selected")); - BLE_STOP(); - switchToDisplayPage(PAGE_MAIN_MENU); - } else if (currentPage == LOGGING_STOP_CONFIRM) { - if (menuSelectionIndex == 0) { - switchToDisplayPage(GPS_SPEED); - } else { - // LOGGING STOP — the user's explicit "I'm done": stop the camera - // recording immediately too (bypasses its stationary+engine-off - // hold). Auto-idle deliberately does NOT do this — see the comment - // in endRaceSession(). - CAMERA_NOTIFY_SESSION_END(); - endRaceSession(); - switchToDisplayPage(PAGE_MAIN_MENU); - } - debug(F("Stop Logging?: ")); - debugln(menuSelectionIndex == 0 ? "NO" : "YES"); - } -} - -void handleRunningPageSelection() { - if (currentPage == LOGGING_STOP) { - switchToDisplayPage(LOGGING_STOP_CONFIRM); - } else if (currentPage == GPS_LAP_LIST) { - // Middle button cycles lap list pages (both live and replay mode) - current_lap_list_page = current_lap_list_page == (lap_list_pages-1) ? 0 : current_lap_list_page + 1; - forceDisplayRefresh(); - } else if (currentPage == PAGE_REPLAY_RESULTS) { - // Middle button on results does nothing (use left/right for navigation) - } else { - // Speed-aware center-button jump: pace page while moving, best-lap - // page when (nearly) stopped. SIM uses the same behavior — the old - // Wokwi carve-out here toggled panel inversion instead, which is - // invisible in the simulator (inversion happens in the panel, not - // the framebuffer) and read as a dead button. - if (gps_speed_mph <= 5.0) { - currentPage = GPS_LAP_BEST; - } else { - currentPage = GPS_LAP_PACE; - } - switchToDisplayPage(currentPage); - } -} - -/////////////////////////////////////////// -// MANUAL CAMERA-SERIAL ENTRY -// Character wheel for PAGE_CAMERA_SERIAL_ENTRY: digits then letters, -// wrapping in both directions. Buffer + cursor live in BirdsEye.ino -// (cameraSerialEntryBuf / cameraSerialEntryCursor) so the renderer in -// display_pages.ino can see them. -/////////////////////////////////////////// - -static const char kSerialEntryChars[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - -static char serialEntryCycle(char c, int dir) { - const int n = (int)(sizeof(kSerialEntryChars) - 1); - int idx = 0; - for (int i = 0; i < n; i++) { - if (kSerialEntryChars[i] == c) { - idx = i; - break; - } - } - idx = (idx + dir + n) % n; - return kSerialEntryChars[idx]; -} - -void displayLoop() { - // GPS-lock hold: while a race session is waiting for a valid GPS lock to - // create its log file (engine running, no lock yet), pin the user to the - // tachometer. Navigation is disabled below until the lock arrives. - if (gpsLockHoldActive) { - currentPage = TACHOMETER; - } - - // Check if I2C recovery was flagged by a previous slow display update - if (i2cRecoveryNeeded) { - i2cRecoveryNeeded = false; - i2cBusRecover(); - } - - // todo: better page handling - if (millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { - displayLastUpdate = millis(); - - #ifdef ENDURANCE_MODE - bool inEndurance = true; - #else - bool inEndurance = false; - #endif - - bool isCrossing = activeTimerCrossing(); - - if ( - currentPage != GPS_STATS && - currentPage != GPS_DEBUG && - currentPage != LOGGING_STOP && - currentPage != LOGGING_STOP_CONFIRM && - currentPage != PAGE_INTERNAL_FAULT && - currentPage != PAGE_INTERNAL_WARNING && - isCrossing && - inEndurance == false - ) { - displayCrossing(); - lastPage = 999; - } else if (currentPage == PAGE_GPS_STATUS) { - displayPage_gps_status(); - } else if (currentPage == PAGE_MAIN_MENU) { - displayPage_main_menu(); - } else if (currentPage == PAGE_BLUETOOTH) { - displayPage_bluetooth(); - } else if (currentPage == PAGE_TRANSFER_MENU) { - displayPage_transfer_menu(); - } else if (currentPage == PAGE_USB_STORAGE) { - displayPage_usb_storage(); - } else if (currentPage == PAGE_PAIR_CAMERA) { - displayPage_pair_camera(); - } else if (currentPage == PAGE_CAMERA_TEST) { - displayPage_camera_test(); - } else if (currentPage == PAGE_CAMERA_SERIAL_ENTRY) { - displayPage_camera_serial_entry(); - } else if (currentPage == PAGE_REPLAY_FILE_SELECT) { - displayPage_replay_file_select(); - } else if (currentPage == PAGE_REPLAY_RESULTS) { - displayPage_replay_results(); - } else if (currentPage == PAGE_REPLAY_EXIT) { - displayPage_replay_exit(); - } else if (currentPage == GPS_STATS) { - displayPage_gps_stats(); - } else if (currentPage == GPS_SPEED) { - displayPage_gps_speed(); - } else if (currentPage == TACHOMETER) { - displayPage_tachometer(); -#if !defined(ENDURANCE_MODE) && BIRDSEYE_ENABLE_SENSOREGG - } else if (currentPage == SENSOR_TEMP) { - displayPage_sensorTemp(); -#endif - } else if (currentPage == GPS_LAP_TIME) { - displayPage_gps_lap_time(); - } else if (currentPage == GPS_LAP_PACE) { - displayPage_gps_pace(); - } else if (currentPage == GPS_LAP_BEST) { - #ifndef ENDURANCE_MODE - displayPage_gps_best_lap(); - #else - if (gps_speed_mph <= 10.0) { - displayPage_gps_best_lap(); - } else { - if (lastPage == (GPS_LAP_BEST - 1)) { - currentPage = GPS_SPEED; - } else { - currentPage = (GPS_LAP_BEST - 1); - } - switchToDisplayPage(currentPage); - } - #endif - } else if (currentPage == OPTIMAL_LAP) { - displayPage_optimal_lap(); - } else if (currentPage == GPS_LAP_LIST) { - displayPage_gps_lap_list(); - } else if (currentPage == LOGGING_STOP) { - // dont let us stop logging while were moving, duh - if (gps_speed_mph <= 2.0) { - displayPage_stop_logging(); - } else { - // todo: not super friendly when expanding pages, assuming "logging stop" is always the "last" page - if (lastPage == (LOGGING_STOP - 1)) { - currentPage = runningPageStart; - } else { - currentPage = LOGGING_STOP - 1; - } - switchToDisplayPage(currentPage); - } - } else if (currentPage == LOGGING_STOP_CONFIRM) { - displayPage_stop_logging_confirm(); - } else if (currentPage == GPS_DEBUG) { - displayPage_gps_debug(); - } else if (currentPage == PAGE_INTERNAL_FAULT) { - displayPage_internal_fault(); - } else if (currentPage == PAGE_INTERNAL_WARNING) { - displayPage_internal_warning(); - } else if (currentPage == PAGE_SD_FORMAT) { - displayPage_sd_format(); - } - } - - // todo: better button handling - bool insideMenu = false; - bool buttonsDisabled = false; - int menuLimit = 0; - - if ( - currentPage == PAGE_MAIN_MENU || - currentPage == PAGE_BLUETOOTH || - currentPage == PAGE_TRANSFER_MENU || - currentPage == PAGE_USB_STORAGE || - currentPage == LOGGING_STOP_CONFIRM || - currentPage == PAGE_REPLAY_FILE_SELECT || - currentPage == PAGE_REPLAY_EXIT || - // Camera page is only a menu while paired; while pairing it uses the - // custom (non-menu) button handling below. Deriving this per frame - // flips the page into menu mode the moment a serial is captured. - (currentPage == PAGE_PAIR_CAMERA && cameraIsPaired()) || - currentPage == PAGE_CAMERA_TEST - ) { - insideMenu = true; - if (currentPage == PAGE_MAIN_MENU) { - menuLimit = 4; // Race, Review, Transfer, Camera - } else if (currentPage == PAGE_BLUETOOTH) { - menuLimit = 1; // Only "Exit" option - } else if (currentPage == PAGE_TRANSFER_MENU) { - menuLimit = 2; // Bluetooth, USB - } else if (currentPage == PAGE_USB_STORAGE) { - menuLimit = 1; // Only "Exit" option - } else if (currentPage == PAGE_PAIR_CAMERA) { - menuLimit = 3; // Back, Test, Unpair - } else if (currentPage == PAGE_CAMERA_TEST) { - menuLimit = 4; // Wake, Record, Power Off, Back - } else if ( - currentPage == LOGGING_STOP_CONFIRM || - currentPage == PAGE_REPLAY_EXIT - ) { - menuLimit = 2; - } else if (currentPage == PAGE_REPLAY_FILE_SELECT) { - menuLimit = numReplayFiles > 0 ? numReplayFiles : 1; - } - } - - if (currentPage == PAGE_INTERNAL_FAULT) { - buttonsDisabled = true; - } - - // While held on the tachometer waiting for a GPS lock, block all navigation. - if (gpsLockHoldActive) { - buttonsDisabled = true; - } - - // menu operator - if (insideMenu && !buttonsDisabled) { - // we are in a menu do weird menu things - // Statically-rendered menus list their items top-to-bottom (index - // 0,1,2,3 down the panel), so the physical up/down buttons must be - // reversed vs the scrolling menus. The main menu AND the camera pages - // (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). - bool reverseDirection = (currentPage == PAGE_MAIN_MENU || - currentPage == PAGE_PAIR_CAMERA || - currentPage == PAGE_CAMERA_TEST); - - // BUTTON UP (or DOWN for reversed menus) - if (btn1->pressed) { - if (reverseDirection) { - // Move UP visually = decrease index - if (menuSelectionIndex == 0) { - menuSelectionIndex = menuLimit-1; - } else { - menuSelectionIndex--; - } - } else { - if (menuSelectionIndex == menuLimit-1) { - menuSelectionIndex = 0; - } else { - menuSelectionIndex++; - } - } - debug(F("menu number: ")); - debugln(menuSelectionIndex); - forceDisplayRefresh(); - } - // BUTTON ENTER - if (btn2->pressed) { - debugln(F("Button Enter")); - handleMenuPageSelection(); - } - // BUTTON DOWN (or UP for reversed menus) - if (btn3->pressed) { - if (reverseDirection) { - // Move DOWN visually = increase index - if (menuSelectionIndex == menuLimit-1) { - menuSelectionIndex = 0; - } else { - menuSelectionIndex++; - } - } else { - if (menuSelectionIndex == 0) { - menuSelectionIndex = menuLimit-1; - } else { - menuSelectionIndex--; - } - } - debug(F("menu number: ")); - debugln(menuSelectionIndex); - forceDisplayRefresh(); - } - } else if (currentPage == PAGE_GPS_STATUS) { - // Boot GPS status page: presses are consumed by gpsStatusPageLoop() - // (BirdsEye.ino) BEFORE displayLoop() runs — nothing to do here. This - // branch exists so the generic running-page navigation below can't - // grab the page. - } else if (currentPage == PAGE_SD_FORMAT) { - // Boot format-confirm page: buttons are consumed by sdFormatPageLoop() - // (BirdsEye.ino) BEFORE displayLoop() runs — nothing to do here. Unlike - // PAGE_INTERNAL_FAULT, buttons stay live (the confirm hold needs them). - } else if (currentPage == PAGE_INTERNAL_WARNING) { - // Warning page: any button returns to main menu - if (btn1->pressed || btn2->pressed || btn3->pressed) { - switchToDisplayPage(PAGE_MAIN_MENU); - } - } else if (currentPage == PAGE_PAIR_CAMERA) { - // Unpaired pairing screen only (the paired variant is a menu above). - // Button map: B1 (Left) = manual serial entry, B2 (Select) = cancel. - if (btn1->pressed) { - debugln(F("Pair Camera: manual entry")); - cameraCancelPair(); - // Fresh entry state every time the page is entered - strcpy(cameraSerialEntryBuf, "AAAAAA"); - cameraSerialEntryCursor = 0; - switchToDisplayPage(PAGE_CAMERA_SERIAL_ENTRY); - } else if (btn2->pressed) { - debugln(F("Pair Camera: cancel")); - cameraCancelPair(); - switchToDisplayPage(PAGE_MAIN_MENU); - } - } else if (currentPage == PAGE_CAMERA_SERIAL_ENTRY) { - // Manual serial entry button map: - // B1 (Left) = cycle char backward (cursor 0-5) / toggle OK-CANCEL - // B2 (Select) = advance cursor; on OK = submit, on CANCEL = main menu - // B3 (Right) = cycle char forward (cursor 0-5) / toggle OK-CANCEL - if (btn1->pressed || btn3->pressed) { - if (cameraSerialEntryCursor < 6) { - cameraSerialEntryBuf[cameraSerialEntryCursor] = serialEntryCycle( - cameraSerialEntryBuf[cameraSerialEntryCursor], btn3->pressed ? 1 : -1); - } else { - // Hop between OK (6) and CANCEL (7) - cameraSerialEntryCursor = cameraSerialEntryCursor == 6 ? 7 : 6; - } - forceDisplayRefresh(); - } - if (btn2->pressed) { - if (cameraSerialEntryCursor < 6) { - cameraSerialEntryCursor++; // next char, then OK, then CANCEL - forceDisplayRefresh(); - } else if (cameraSerialEntryCursor == 6) { - // OK — validate + persist via the camera module - debugln(F("Serial Entry: OK")); - if (cameraSetManualSerial(cameraSerialEntryBuf)) { - switchToDisplayPage(PAGE_PAIR_CAMERA); - } else { - strncpy(internalNotification, "Invalid serial", sizeof(internalNotification) - 1); - internalNotification[sizeof(internalNotification) - 1] = '\0'; - switchToDisplayPage(PAGE_INTERNAL_WARNING); - } - } else { - debugln(F("Serial Entry: cancel")); - switchToDisplayPage(PAGE_MAIN_MENU); - } - } - } else if (!buttonsDisabled){ - // page up/down/enter - // BUTTON LEFT - if (btn1->pressed) { - debugln(F("Button Left")); - // Special handling for replay results - left goes to lap list - if (currentPage == PAGE_REPLAY_RESULTS) { - if (lapHistoryCount > 0) { - current_lap_list_page = 0; - switchToDisplayPage(GPS_LAP_LIST); - } - // Special handling for lap list during replay - left goes back to results - } else if (currentPage == GPS_LAP_LIST && replayProcessingComplete) { - switchToDisplayPage(PAGE_REPLAY_RESULTS); - } else { - if (currentPage <= runningPageStart) { - currentPage = runningPageEnd; - } else { - currentPage--; - } - debug(F("running menu number: ")); - debugln(currentPage); - switchToDisplayPage(currentPage); - } - } - // BUTTON ENTER - if (btn2->pressed) { - debugln(F("Button Middle (running)")); - handleRunningPageSelection(); - } - // BUTTON DOWN/RIGHT - if (btn3->pressed) { - debugln(F("Button Right")); - // Special handling for replay results - right goes to exit page - if (currentPage == PAGE_REPLAY_RESULTS) { - switchToDisplayPage(PAGE_REPLAY_EXIT); - // Special handling for lap list during replay - right goes back to results - } else if (currentPage == GPS_LAP_LIST && replayProcessingComplete) { - switchToDisplayPage(PAGE_REPLAY_RESULTS); - } else { - if (currentPage >= runningPageEnd) { - currentPage = runningPageStart; - } else { - currentPage++; - } - debug(F("running menu number: ")); - debugln(currentPage); - switchToDisplayPage(currentPage); - } - } - } -} +/////////////////////////////////////////// +// DISPLAY UI MODULE +// Display setup, button handling, menu navigation, and display loop +/////////////////////////////////////////// + +#include "display_ui.h" + +// Explicit, though Arduino's .ino concatenation already pulls it in via +// BirdsEye.ino: the build feature flags below must never silently evaluate +// as undefined (0) because an include order changed. +#include "project.h" + +/////////////////////////////////////////// +// I2C BUS RECOVERY +// EMI from ignition can glitch the I2C bus, leaving a slave holding SDA low. +// The Wire library may hang forever waiting. This recovery routine bit-bangs +// 9 SCL clocks to free a stuck slave, then re-initializes Wire. +/////////////////////////////////////////// + +static bool i2cRecoveryNeeded = false; + +void i2cBusRecover() { + debugln(F("I2C: Bus recovery - bit-banging 9 SCL clocks")); + + // Feed the watchdog before each potentially-blocking I2C re-init step. + // If ignition EMI is still glitching the bus, Wire.begin() / display.begin() + // can stall on it — without these pets the recovery routine itself would + // trip the 4 s WDT and reboot, then re-trigger on the next boot (boot loop). + // Mirrors the GPS baud-recovery hardening in gps_functions.ino. + wdtPet(); + + Wire.end(); + + // Manually toggle SCL 9 times to free stuck slave + // SDA must be floating (input) so slave can release it + pinMode(PIN_WIRE_SDA, INPUT); + pinMode(PIN_WIRE_SCL, OUTPUT); + + for (int i = 0; i < 9; i++) { + digitalWrite(PIN_WIRE_SCL, LOW); + delayMicroseconds(5); + digitalWrite(PIN_WIRE_SCL, HIGH); + delayMicroseconds(5); + } + + // Generate STOP condition: SDA low-to-high while SCL is high + pinMode(PIN_WIRE_SDA, OUTPUT); + digitalWrite(PIN_WIRE_SDA, LOW); + delayMicroseconds(5); + digitalWrite(PIN_WIRE_SCL, HIGH); + delayMicroseconds(5); + digitalWrite(PIN_WIRE_SDA, HIGH); + delayMicroseconds(5); + + // Re-init Wire + wdtPet(); + Wire.begin(); + Wire.setClock(400000); // Must re-set after begin() (resets to 100kHz) + + // Re-init display + wdtPet(); + #ifdef USE_1306_DISPLAY + display.begin(SSD1306_SWITCHCAPVCC, I2C_DISPLAY_ADDRESS); + #else + display.begin(I2C_DISPLAY_ADDRESS, true); + #endif + + wdtPet(); + debugln(F("I2C: Bus recovery complete")); +} + +// Safe wrapper around display.display() - detects hung I2C and recovers. +// A normal 1024-byte I2C transfer at 400kHz takes ~25ms. +// If it takes >100ms, something is wrong (EMI glitch or bus hang). +void safeDisplayUpdate() { + unsigned long start = millis(); + display.display(); + unsigned long elapsed = millis() - start; + + if (elapsed > 100) { + // Recover immediately rather than deferring to the next frame — a stuck + // bus would otherwise get one more (also-slow) paint before recovery runs. + debugln(F("I2C: display.display() took too long, recovering now")); + i2cRecoveryNeeded = false; + i2cBusRecover(); + } +} + +void setupButtons() { + #ifndef SIM + // greybox + btn1->pin = 1; + btn2->pin = 2; + btn3->pin = 3; + + #else + btn1->pin = 4; + btn2->pin = 5; + btn3->pin = 6; + #endif + + pinMode(btn1->pin, INPUT_PULLUP); + pinMode(btn2->pin, INPUT_PULLUP); + pinMode(btn3->pin, INPUT_PULLUP); +} + +void readButtons() { + checkButton(btn1); + checkButton(btn2); + checkButton(btn3); + + //force update when button pressed + if ( + btn1->pressed || + btn2->pressed || + btn3->pressed + ) { + forceDisplayRefresh(); + } +} + +void resetButtons() { + resetButton(btn1); + resetButton(btn2); + resetButton(btn3); +} + +void updateButtonHoldState() { + bool b1 = readButtonMultiSample(btn1->pin); + bool b2 = readButtonMultiSample(btn2->pin); + bool b3 = readButtonMultiSample(btn3->pin); + + // Track continuous hold duration per button + if (b1) { if (!btn1Held) { btn1HoldStart = millis(); btn1Held = true; } } + else { btn1Held = false; } + + if (b2) { if (!btn2Held) { btn2HoldStart = millis(); btn2Held = true; } } + else { btn2Held = false; } + + if (b3) { if (!btn3Held) { btn3HoldStart = millis(); btn3Held = true; } } + else { btn3Held = false; } +} + +bool isButtonHeld(int btnNum, unsigned long durationMs) { + unsigned long start; + bool held; + switch(btnNum) { + case 1: start = btn1HoldStart; held = btn1Held; break; + case 2: start = btn2HoldStart; held = btn2Held; break; + case 3: start = btn3HoldStart; held = btn3Held; break; + default: return false; + } + return held && start > 0 && (millis() - start >= durationMs); +} + +bool anyButtonPressed() { + return readButtonMultiSample(btn1->pin) || + readButtonMultiSample(btn2->pin) || + readButtonMultiSample(btn3->pin); +} + +void resetButton(ButtonState* button) { + button->pressed = false; +} + +/** + * @brief Multi-sample button read with EMI rejection + * + * Takes multiple samples with small delays and requires ALL samples + * to show the button pressed. This rejects transient EMI spikes that + * might cause a single false LOW reading. + * + * @param pin The GPIO pin to read + * @return true only if ALL samples show button pressed (LOW) + */ +bool readButtonMultiSample(int pin) { + for (int i = 0; i < BUTTON_SAMPLE_COUNT; i++) { + if (digitalRead(pin) != LOW) { + return false; // Any HIGH reading = not pressed + } + if (i < BUTTON_SAMPLE_COUNT - 1) { + delayMicroseconds(BUTTON_SAMPLE_DELAY_US); + } + } + return true; // All samples were LOW = definitely pressed +} + +/** + * @brief Check button state with debouncing, edge detection, and multi-sample verification + * + * Uses edge detection to only trigger on button PRESS (not while held). + * Button must be released before it can trigger again. + */ +void checkButton(ButtonState* button) { + // Multi-sample read: require consistent LOW across all samples + bool btnCurrentlyPressed = readButtonMultiSample(button->pin); + + if (!btnCurrentlyPressed) { + // Button is released - mark it as ready for next press + button->wasReleased = true; + return; + } + + // Button is pressed - check if we should register this press + // Requires: 1) button was released since last press (edge detection) + // 2) debounce time has passed + bool btnReady = millis() - button->lastPressed >= antiBounceIntv; + + if (button->wasReleased && btnReady) { + button->lastPressed = millis(); + button->pressed = true; + button->wasReleased = false; // Must release before next press + } +} + +////////////////////////////////////////// +// TODO: make display into own class?? +void resetDisplay() { + if (currentPage != lastPage) { + lastPage = currentPage; + recentlyChanged = true; + menuSelectionIndex = 0; + } else { + recentlyChanged = false; + } + display.setTextWrap(false); + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + + display.setTextColor(DISPLAY_TEXT_WHITE); +} + +void forceDisplayRefresh() { + // why does adding work but not subtracting? + displayLastUpdate += 5000; +} + +void switchToDisplayPage(int newDisplayPage) { + // Stamp arrival at the main menu. This is the ONLY path to it (the direct + // `currentPage =` assignments elsewhere are all race-page rotation clamps), + // so autoRaceModeCheck() can trust it to mean "the user just got here". + if (newDisplayPage == PAGE_MAIN_MENU && currentPage != PAGE_MAIN_MENU) { + mainMenuEnteredAtMs = millis(); + } + currentPage = newDisplayPage; + forceDisplayRefresh(); +} + +/////////////////////////////////////////// + +void displaySetup() { + debugln(F("SETTING UP DISPLAY")); + delay(250); // wait for the OLED to power up + + // Set I2C timeout to prevent infinite hangs from EMI-induced bus faults + Wire.setTimeout(100); + +#ifdef USE_1306_DISPLAY + display.begin(SSD1306_SWITCHCAPVCC, I2C_DISPLAY_ADDRESS); +#else + display.begin(I2C_DISPLAY_ADDRESS, true); +#endif + + // 400kHz I2C: reduces display.display() from ~100ms to ~25ms. + // At 100kHz, the 1024-byte framebuffer transfer blocks long enough + // for 2-3 GPS PVT messages (40ms each) to arrive, but the SparkFun + // library's auto-PVT buffer only keeps the latest — losing ~2 samples + // every display refresh (3Hz). At 400kHz the transfer completes within + // a single PVT interval, eliminating the loss. + Wire.setClock(400000); + + display.setTextColor(DISPLAY_TEXT_WHITE); + display.setTextWrap(false); + + setupButtons(); + + displayLastUpdate = millis(); + + currentPage = PAGE_BOOT; + + // silly boot splash, maybe anim? + resetDisplay(); + display.drawBitmap(0, 0, image_data_bird1, 128, 64, 1); + safeDisplayUpdate(); + delay(750); + + displayPage_boot(); +} + +void handleMenuPageSelection() { + if (courseCreatorActive()) { + // All five creator screens route through the model, which owns both + // the row table and the screen transitions. + courseCreatorSelect(); + return; + } + if (currentPage == PAGE_MAIN_MENU) { + if (menuSelectionIndex == 0) { + // Race selected — go directly to race mode, start logging on GPS fix + debugln(F("Main Menu: Race selected")); + raceActive = true; + enableLogging = true; + raceSessionStartedAt = millis(); + // Create CourseManager if not already created by track detection + createLapAnythingCourseManager(); + switchToDisplayPage(GPS_SPEED); + } else if (menuSelectionIndex == 1) { + // Replay selected + debugln(F("Main Menu: Replay selected")); + resetReplayState(); + if (buildReplayFileList()) { + switchToDisplayPage(PAGE_REPLAY_FILE_SELECT); + } else { + strncpy(internalNotification, "No .dovex files\nfound on SD!", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } else if (menuSelectionIndex == 2) { + // Transfer selected — open the Bluetooth-vs-USB submenu + debugln(F("Main Menu: Transfer selected")); + switchToDisplayPage(PAGE_TRANSFER_MENU); + } else if (menuSelectionIndex == 3) { + // Create Course — walk the cones and capture the timing lines. + debugln(F("Main Menu: Create Course selected")); + if (!courseCreatorEnter()) { + // Every screen past the prompt needs a fix (to capture) and the + // clock (to name the file), so refuse up front rather than let the + // user walk a whole course and fail at Save. + strncpy(internalNotification, "Need GPS lock to\ncreate a course", + sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } else { + // Camera selected — paired shows status/unpair, unpaired starts pairing + debugln(F("Main Menu: Camera selected")); + if (!cameraIsPaired()) { + cameraRequestPair(); // pairing begins as the page comes up + } + switchToDisplayPage(PAGE_PAIR_CAMERA); + } + } else if (currentPage == PAGE_PAIR_CAMERA) { + // Only a menu while paired (Back / Test / Unpair) — the unpaired + // pairing screen handles its buttons in the custom branch in + // displayLoop(). Back is index 0 so a late "Cancel" press right after + // a serial capture can't land on Test or Unpair. + if (menuSelectionIndex == 0) { + debugln(F("Camera: Back selected")); + switchToDisplayPage(PAGE_MAIN_MENU); + } else if (menuSelectionIndex == 1) { + debugln(F("Camera: Test selected")); + cameraTestEnterMode(); + switchToDisplayPage(PAGE_CAMERA_TEST); + } else { + debugln(F("Camera: Unpair selected")); + if (cameraRequestUnpair()) { + switchToDisplayPage(PAGE_MAIN_MENU); + } else { + // FSM is mid-session (waking/recording/cooldown) — refuse + strncpy(internalNotification, "Camera busy -\nend session first", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } + } else if (currentPage == PAGE_CAMERA_TEST) { + // Bench-test controls for the paired camera. Items: + // 0 Wake — standby wake burst (no effect on a fully-off camera) + // 1 Record — ce82 shutter toggle (needs R link + ce82 subscribed) + // 2 Power Off — ce82 power hold (needs R link + ce82 subscribed) + // 3 Back + // Record and Power Off both ride the ce82 button characteristic, so + // both need the camera connected to us (R) and subscribed. Share the + // failure-diagnosis path. + if (menuSelectionIndex == 0) { + debugln(F("Camera Test: Wake")); + cameraTestWake(); + } else if (menuSelectionIndex == 1 || menuSelectionIndex == 2) { + const bool ok = (menuSelectionIndex == 1) ? cameraTestRecord() + : cameraTestPowerOff(); + debugln(menuSelectionIndex == 1 ? F("Camera Test: Record") + : F("Camera Test: Power Off")); + if (!ok) { + // Distinguish the failure for the tester: no R-link at all vs + // connected-but-never-subscribed (camera ignoring our buttons). + if (!cameraRemoteLinkUp()) { + strncpy(internalNotification, "No remote link -\nrun Wake first", + sizeof(internalNotification) - 1); + } else if (!cameraCe82Subscribed()) { + strncpy(internalNotification, "Camera not subbed\nto buttons (ce82)", + sizeof(internalNotification) - 1); + } else { + strncpy(internalNotification, "Button send\nrejected by stack", + sizeof(internalNotification) - 1); + } + internalNotification[sizeof(internalNotification) - 1] = '\0'; + // The warning page dismisses to the MAIN MENU — leave bench-test + // mode first or cameraTestActive would stay latched (FSM + // suppressed) with no way back to this menu's Back action. + cameraTestExitMode(); + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } else { + debugln(F("Camera Test: Back")); + cameraTestExitMode(); + switchToDisplayPage(PAGE_PAIR_CAMERA); + } + forceDisplayRefresh(); + } else if (currentPage == PAGE_TRANSFER_MENU) { + 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 + CAMERA_FORCE_RELEASE(); + BLE_SETUP(); + switchToDisplayPage(PAGE_BLUETOOTH); + } else { + // USB mass storage — show the status page, then enumerate the drive. + // Release the camera first: the USB parking branch never runs + // CAMERA_LOOP(), so an in-flight camera session would otherwise be + // frozen (advert broadcasting unserviced / cooldown never expiring) + // for the whole USB session. + debugln(F("Transfer: USB selected")); + // No cable = the USB page would instantly reboot (its parked loop reads + // absent VBUS as "cable pulled"). Guide the user instead of bouncing. + if (!isUsbConnected()) { + strncpy(internalNotification, "Plug in USB\ncable first!", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } else { + CAMERA_FORCE_RELEASE(); + switchToDisplayPage(PAGE_USB_STORAGE); + if (!USB_MSC_ENABLE()) { + // SD busy with another subsystem — bounce back to the submenu + strncpy(internalNotification, "SD busy, cannot\nstart USB mode!", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } + } + } else if (currentPage == PAGE_USB_STORAGE) { + // Exit — reboot to drop the drive and remount a fresh filesystem + 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 + switchToDisplayPage(PAGE_MAIN_MENU); + } else { + selectedReplayFile = menuSelectionIndex; + debug(F("Replay: Selected file: ")); + debugln(replayFiles[selectedReplayFile]); + + // DOVEX instant replay: parse header and go straight to results + if (parseDovexHeader(replayFiles[selectedReplayFile])) { + replayProcessingComplete = true; + switchToDisplayPage(PAGE_REPLAY_RESULTS); + } else { + strncpy(internalNotification, "Cannot read DOVEX\nheader (incomplete?)", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } + } else if (currentPage == PAGE_REPLAY_EXIT) { + if (menuSelectionIndex == 0) { + // Back - return to results + switchToDisplayPage(PAGE_REPLAY_RESULTS); + } else { + // Exit - return to main menu + resetReplayState(); + switchToDisplayPage(PAGE_MAIN_MENU); + } + } else if (currentPage == PAGE_BLUETOOTH) { + // Exit button pressed - go back to main menu and disable bluetooth + debugln(F("Bluetooth: Exit selected")); + BLE_STOP(); + switchToDisplayPage(PAGE_MAIN_MENU); + } else if (currentPage == LOGGING_STOP_CONFIRM) { + if (menuSelectionIndex == 0) { + switchToDisplayPage(GPS_SPEED); + } else { + // LOGGING STOP — the user's explicit "I'm done": stop the camera + // recording immediately too (bypasses its stationary+engine-off + // hold). Auto-idle deliberately does NOT do this — see the comment + // in endRaceSession(). + CAMERA_NOTIFY_SESSION_END(); + endRaceSession(); + switchToDisplayPage(PAGE_MAIN_MENU); + } + debug(F("Stop Logging?: ")); + debugln(menuSelectionIndex == 0 ? "NO" : "YES"); + } +} + +void handleRunningPageSelection() { + if (currentPage == LOGGING_STOP) { + switchToDisplayPage(LOGGING_STOP_CONFIRM); + } else if (currentPage == GPS_LAP_LIST) { + // Middle button cycles lap list pages (both live and replay mode) + current_lap_list_page = current_lap_list_page == (lap_list_pages-1) ? 0 : current_lap_list_page + 1; + forceDisplayRefresh(); + } else if (currentPage == PAGE_REPLAY_RESULTS) { + // Middle button on results does nothing (use left/right for navigation) + } else { + // Speed-aware center-button jump: pace page while moving, best-lap + // page when (nearly) stopped. SIM uses the same behavior — the old + // Wokwi carve-out here toggled panel inversion instead, which is + // invisible in the simulator (inversion happens in the panel, not + // the framebuffer) and read as a dead button. + if (gps_speed_mph <= 5.0) { + currentPage = GPS_LAP_BEST; + } else { + currentPage = GPS_LAP_PACE; + } + switchToDisplayPage(currentPage); + } +} + +/////////////////////////////////////////// +// MANUAL CAMERA-SERIAL ENTRY +// Character wheel for PAGE_CAMERA_SERIAL_ENTRY: digits then letters, +// wrapping in both directions. Buffer + cursor live in BirdsEye.ino +// (cameraSerialEntryBuf / cameraSerialEntryCursor) so the renderer in +// display_pages.ino can see them. +/////////////////////////////////////////// + +static const char kSerialEntryChars[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +static char serialEntryCycle(char c, int dir) { + const int n = (int)(sizeof(kSerialEntryChars) - 1); + int idx = 0; + for (int i = 0; i < n; i++) { + if (kSerialEntryChars[i] == c) { + idx = i; + break; + } + } + idx = (idx + dir + n) % n; + return kSerialEntryChars[idx]; +} + +void displayLoop() { + // GPS-lock hold: while a race session is waiting for a valid GPS lock to + // create its log file (engine running, no lock yet), pin the user to the + // tachometer. Navigation is disabled below until the lock arrives. + if (gpsLockHoldActive) { + currentPage = TACHOMETER; + } + + // Check if I2C recovery was flagged by a previous slow display update + if (i2cRecoveryNeeded) { + i2cRecoveryNeeded = false; + i2cBusRecover(); + } + + // todo: better page handling + if (millis() - displayLastUpdate > (1000 / displayUpdateRateHz)) { + displayLastUpdate = millis(); + + #ifdef ENDURANCE_MODE + bool inEndurance = true; + #else + bool inEndurance = false; + #endif + + bool isCrossing = activeTimerCrossing(); + + // The crossing animation is a RACING overlay, so gate it on being ON a + // racing page rather than on a list of pages to skip. The blocklist this + // replaces never grew as pages were added, so every screen introduced + // since — the camera pages, the replay browser, the transfer menus, even + // the main menu — got the animation painted straight over it the moment + // the vehicle sat inside a crossing zone. Standing still beside a timing + // line is exactly when those screens are in use. + // + // The running rotation is a contiguous block (see BirdsEye.ino): the two + // diagnostic pages at the bottom and the stop-logging page at the top stay + // excluded as before, and everything outside the block — negative menu + // ids, the 90+ confirm/warning/fault pages, the 900+ boot pages — is now + // excluded by construction rather than by remembering to list it. + const bool onRacingPage = (currentPage > GPS_STATS && currentPage < LOGGING_STOP); + + if ( + onRacingPage && + isCrossing && + inEndurance == false + ) { + displayCrossing(); + lastPage = 999; + } else if (currentPage == PAGE_GPS_STATUS) { + displayPage_gps_status(); + } else if (currentPage == PAGE_MAIN_MENU) { + displayPage_main_menu(); + } else if (currentPage == PAGE_BLUETOOTH) { + displayPage_bluetooth(); + } else if (currentPage == PAGE_TRANSFER_MENU) { + displayPage_transfer_menu(); + } else if (currentPage == PAGE_USB_STORAGE) { + displayPage_usb_storage(); + } else if (currentPage == PAGE_PAIR_CAMERA) { + displayPage_pair_camera(); + } else if (currentPage == PAGE_CAMERA_TEST) { + displayPage_camera_test(); + } else if (currentPage == PAGE_COURSE_TRACK) { + displayPage_course_track(); + } else if (currentPage == PAGE_COURSE_TYPE) { + displayPage_course_type(); + } else if (currentPage == PAGE_COURSE_LINES) { + displayPage_course_lines(); + } else if (currentPage == PAGE_COURSE_LINE) { + displayPage_course_line(); + } else if (currentPage == PAGE_COURSE_POINT) { + displayPage_course_point(); + } else if (currentPage == PAGE_CAMERA_SERIAL_ENTRY) { + displayPage_camera_serial_entry(); + } else if (currentPage == PAGE_REPLAY_FILE_SELECT) { + displayPage_replay_file_select(); + } else if (currentPage == PAGE_REPLAY_RESULTS) { + displayPage_replay_results(); + } else if (currentPage == PAGE_REPLAY_EXIT) { + displayPage_replay_exit(); + } else if (currentPage == GPS_STATS) { + displayPage_gps_stats(); + } else if (currentPage == GPS_SPEED) { + displayPage_gps_speed(); + } else if (currentPage == TACHOMETER) { + displayPage_tachometer(); +#if !defined(ENDURANCE_MODE) && BIRDSEYE_ENABLE_SENSOREGG + } else if (currentPage == SENSOR_TEMP) { + displayPage_sensorTemp(); + } else if (currentPage == SENSOR_TEMP2) { + displayPage_sensorTemp2(); +#endif + } else if (currentPage == GPS_LAP_TIME) { + displayPage_gps_lap_time(); + } else if (currentPage == GPS_LAP_PACE) { + displayPage_gps_pace(); + } else if (currentPage == GPS_LAP_BEST) { + #ifndef ENDURANCE_MODE + displayPage_gps_best_lap(); + #else + if (gps_speed_mph <= 10.0) { + displayPage_gps_best_lap(); + } else { + if (lastPage == (GPS_LAP_BEST - 1)) { + currentPage = GPS_SPEED; + } else { + currentPage = (GPS_LAP_BEST - 1); + } + switchToDisplayPage(currentPage); + } + #endif + } else if (currentPage == OPTIMAL_LAP) { + displayPage_optimal_lap(); + } else if (currentPage == GPS_LAP_LIST) { + displayPage_gps_lap_list(); + } else if (currentPage == LOGGING_STOP) { + // dont let us stop logging while were moving, duh + if (gps_speed_mph <= 2.0) { + displayPage_stop_logging(); + } else { + // todo: not super friendly when expanding pages, assuming "logging stop" is always the "last" page + if (lastPage == (LOGGING_STOP - 1)) { + currentPage = runningPageStart; + } else { + currentPage = LOGGING_STOP - 1; + } + switchToDisplayPage(currentPage); + } + } else if (currentPage == LOGGING_STOP_CONFIRM) { + displayPage_stop_logging_confirm(); + } else if (currentPage == GPS_DEBUG) { + displayPage_gps_debug(); + } else if (currentPage == PAGE_INTERNAL_FAULT) { + displayPage_internal_fault(); + } else if (currentPage == PAGE_INTERNAL_WARNING) { + displayPage_internal_warning(); + } else if (currentPage == PAGE_SD_FORMAT) { + displayPage_sd_format(); + } + } + + // todo: better button handling + bool insideMenu = false; + bool buttonsDisabled = false; + int menuLimit = 0; + + if ( + currentPage == PAGE_MAIN_MENU || + currentPage == PAGE_BLUETOOTH || + currentPage == PAGE_TRANSFER_MENU || + currentPage == PAGE_USB_STORAGE || + currentPage == LOGGING_STOP_CONFIRM || + currentPage == PAGE_REPLAY_FILE_SELECT || + currentPage == PAGE_REPLAY_EXIT || + // Camera page is only a menu while paired; while pairing it uses the + // custom (non-menu) button handling below. Deriving this per frame + // flips the page into menu mode the moment a serial is captured. + (currentPage == PAGE_PAIR_CAMERA && cameraIsPaired()) || + currentPage == PAGE_CAMERA_TEST || + courseCreatorActive() + ) { + insideMenu = true; + if (currentPage == PAGE_MAIN_MENU) { + menuLimit = 5; // Race, Review, Transfer, Create Course, Camera + } else if (courseCreatorActive()) { + // Row count is the model's to decide — it changes with course type + // (sprint grows a finish row) and with which screen is up. + menuLimit = course_creator::rowCount(courseCreator); + } else if (currentPage == PAGE_BLUETOOTH) { + menuLimit = 1; // Only "Exit" option + } else if (currentPage == PAGE_TRANSFER_MENU) { + menuLimit = 2; // Bluetooth, USB + } else if (currentPage == PAGE_USB_STORAGE) { + menuLimit = 1; // Only "Exit" option + } else if (currentPage == PAGE_PAIR_CAMERA) { + menuLimit = 3; // Back, Test, Unpair + } else if (currentPage == PAGE_CAMERA_TEST) { + menuLimit = 4; // Wake, Record, Power Off, Back + } else if ( + currentPage == LOGGING_STOP_CONFIRM || + currentPage == PAGE_REPLAY_EXIT + ) { + menuLimit = 2; + } else if (currentPage == PAGE_REPLAY_FILE_SELECT) { + menuLimit = numReplayFiles > 0 ? numReplayFiles : 1; + } + } + + if (currentPage == PAGE_INTERNAL_FAULT) { + buttonsDisabled = true; + } + + // While held on the tachometer waiting for a GPS lock, block all navigation. + if (gpsLockHoldActive) { + buttonsDisabled = true; + } + + // menu operator + if (insideMenu && !buttonsDisabled) { + // we are in a menu do weird menu things + // Statically-rendered menus list their items top-to-bottom (index + // 0,1,2,3 down the panel), so the physical up/down buttons must be + // reversed vs the scrolling menus. The main menu AND the camera pages + // (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). + bool reverseDirection = (currentPage == PAGE_MAIN_MENU || + currentPage == PAGE_PAIR_CAMERA || + currentPage == PAGE_CAMERA_TEST || + courseCreatorActive()); + + // BUTTON UP (or DOWN for reversed menus) + if (btn1->pressed) { + if (reverseDirection) { + // Move UP visually = decrease index + if (menuSelectionIndex == 0) { + menuSelectionIndex = menuLimit-1; + } else { + menuSelectionIndex--; + } + } else { + if (menuSelectionIndex == menuLimit-1) { + menuSelectionIndex = 0; + } else { + menuSelectionIndex++; + } + } + debug(F("menu number: ")); + debugln(menuSelectionIndex); + forceDisplayRefresh(); + } + // BUTTON ENTER + if (btn2->pressed) { + debugln(F("Button Enter")); + handleMenuPageSelection(); + } + // BUTTON DOWN (or UP for reversed menus) + if (btn3->pressed) { + if (reverseDirection) { + // Move DOWN visually = increase index + if (menuSelectionIndex == menuLimit-1) { + menuSelectionIndex = 0; + } else { + menuSelectionIndex++; + } + } else { + if (menuSelectionIndex == 0) { + menuSelectionIndex = menuLimit-1; + } else { + menuSelectionIndex--; + } + } + debug(F("menu number: ")); + debugln(menuSelectionIndex); + forceDisplayRefresh(); + } + } else if (currentPage == PAGE_GPS_STATUS) { + // Boot GPS status page: presses are consumed by gpsStatusPageLoop() + // (BirdsEye.ino) BEFORE displayLoop() runs — nothing to do here. This + // branch exists so the generic running-page navigation below can't + // grab the page. + } else if (currentPage == PAGE_SD_FORMAT) { + // Boot format-confirm page: buttons are consumed by sdFormatPageLoop() + // (BirdsEye.ino) BEFORE displayLoop() runs — nothing to do here. Unlike + // PAGE_INTERNAL_FAULT, buttons stay live (the confirm hold needs them). + } else if (currentPage == PAGE_INTERNAL_WARNING) { + // Warning page: any button returns to main menu + if (btn1->pressed || btn2->pressed || btn3->pressed) { + switchToDisplayPage(PAGE_MAIN_MENU); + } + } else if (currentPage == PAGE_PAIR_CAMERA) { + // Unpaired pairing screen only (the paired variant is a menu above). + // Button map: B1 (Left) = manual serial entry, B2 (Select) = cancel. + if (btn1->pressed) { + debugln(F("Pair Camera: manual entry")); + cameraCancelPair(); + // Fresh entry state every time the page is entered + strcpy(cameraSerialEntryBuf, "AAAAAA"); + cameraSerialEntryCursor = 0; + switchToDisplayPage(PAGE_CAMERA_SERIAL_ENTRY); + } else if (btn2->pressed) { + debugln(F("Pair Camera: cancel")); + cameraCancelPair(); + switchToDisplayPage(PAGE_MAIN_MENU); + } + } else if (currentPage == PAGE_CAMERA_SERIAL_ENTRY) { + // Manual serial entry button map: + // B1 (Left) = cycle char backward (cursor 0-5) / toggle OK-CANCEL + // B2 (Select) = advance cursor; on OK = submit, on CANCEL = main menu + // B3 (Right) = cycle char forward (cursor 0-5) / toggle OK-CANCEL + if (btn1->pressed || btn3->pressed) { + if (cameraSerialEntryCursor < 6) { + cameraSerialEntryBuf[cameraSerialEntryCursor] = serialEntryCycle( + cameraSerialEntryBuf[cameraSerialEntryCursor], btn3->pressed ? 1 : -1); + } else { + // Hop between OK (6) and CANCEL (7) + cameraSerialEntryCursor = cameraSerialEntryCursor == 6 ? 7 : 6; + } + forceDisplayRefresh(); + } + if (btn2->pressed) { + if (cameraSerialEntryCursor < 6) { + cameraSerialEntryCursor++; // next char, then OK, then CANCEL + forceDisplayRefresh(); + } else if (cameraSerialEntryCursor == 6) { + // OK — validate + persist via the camera module + debugln(F("Serial Entry: OK")); + if (cameraSetManualSerial(cameraSerialEntryBuf)) { + switchToDisplayPage(PAGE_PAIR_CAMERA); + } else { + strncpy(internalNotification, "Invalid serial", sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } else { + debugln(F("Serial Entry: cancel")); + switchToDisplayPage(PAGE_MAIN_MENU); + } + } + } else if (!buttonsDisabled){ + // page up/down/enter + // BUTTON LEFT + if (btn1->pressed) { + debugln(F("Button Left")); + // Special handling for replay results - left goes to lap list + if (currentPage == PAGE_REPLAY_RESULTS) { + if (lapHistoryCount > 0) { + current_lap_list_page = 0; + switchToDisplayPage(GPS_LAP_LIST); + } + // Special handling for lap list during replay - left goes back to results + } else if (currentPage == GPS_LAP_LIST && replayProcessingComplete) { + switchToDisplayPage(PAGE_REPLAY_RESULTS); + } else { + if (currentPage <= runningPageStart) { + currentPage = runningPageEnd; + } else { + currentPage--; + } + debug(F("running menu number: ")); + debugln(currentPage); + switchToDisplayPage(currentPage); + } + } + // BUTTON ENTER + if (btn2->pressed) { + debugln(F("Button Middle (running)")); + handleRunningPageSelection(); + } + // BUTTON DOWN/RIGHT + if (btn3->pressed) { + debugln(F("Button Right")); + // Special handling for replay results - right goes to exit page + if (currentPage == PAGE_REPLAY_RESULTS) { + switchToDisplayPage(PAGE_REPLAY_EXIT); + // Special handling for lap list during replay - right goes back to results + } else if (currentPage == GPS_LAP_LIST && replayProcessingComplete) { + switchToDisplayPage(PAGE_REPLAY_RESULTS); + } else { + if (currentPage >= runningPageEnd) { + currentPage = runningPageStart; + } else { + currentPage++; + } + debug(F("running menu number: ")); + debugln(currentPage); + switchToDisplayPage(currentPage); + } + } + } +} diff --git a/BirdsEye/dovex_header.cpp b/BirdsEye/dovex_header.cpp index 7f4574e..80433b0 100644 --- a/BirdsEye/dovex_header.cpp +++ b/BirdsEye/dovex_header.cpp @@ -50,8 +50,9 @@ bool format(char* buf, size_t bufSize, }; // Line 1: column labels (CRLF — matches Arduino Print::println). - // device_name is the trailing column for backwards compatibility. - if (!append("datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name\r\n")) { + // device_name and race_mode are trailing columns for backwards + // compatibility (added in that order; see header comment). + if (!append("datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode\r\n")) { return false; } @@ -62,13 +63,14 @@ bool format(char* buf, size_t bufSize, formatLapField(optStr, sizeof(optStr), meta.optimalMs); const int n = snprintf(p, static_cast(end - p), - "%s,%s,%s,%s,%s,%s,%s\r\n", + "%s,%s,%s,%s,%s,%s,%s,%s\r\n", meta.datetime ? meta.datetime : "", meta.driver ? meta.driver : "", meta.course ? meta.course : "", meta.shortName? meta.shortName: "", bestStr, optStr, - meta.device ? meta.device : ""); + meta.device ? meta.device : "", + meta.raceMode ? meta.raceMode : ""); if (n < 0 || p + n > end) return false; p += n; @@ -119,6 +121,7 @@ bool parse(const char* buf, size_t bufSize, outMeta.bestLap[0] = '\0'; outMeta.optimal[0] = '\0'; outMeta.device[0] = '\0'; + outMeta.raceMode[0] = '\0'; if (buf == nullptr || bufSize < kHeaderSize) return false; @@ -150,25 +153,36 @@ bool parse(const char* buf, size_t bufSize, // Line 2 (metadata values). if (!nextLine(lineBegin, lineLen)) return false; - // Split line 2 by commas into a local copy we can NUL-poke. + // Split line 2 by commas into a local copy. Fields are POSITIONAL, so + // the splitter must preserve empty fields — strtok collapses consecutive + // delimiters, which would let an empty middle column (e.g. a blank + // device_name) shift every later column left. Absent trailing columns + // (older logs) leave their fields "". char metaBuf[256]; boundedCopy(metaBuf, sizeof(metaBuf), lineBegin, lineLen); - char* tok = strtok(metaBuf, ","); - if (tok) boundedCopy(outMeta.datetime, sizeof(outMeta.datetime), tok, strlen(tok)); - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.driver, sizeof(outMeta.driver), tok, strlen(tok)); - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.course, sizeof(outMeta.course), tok, strlen(tok)); - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.shortName, sizeof(outMeta.shortName), tok, strlen(tok)); - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.bestLap, sizeof(outMeta.bestLap), tok, strlen(tok)); - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.optimal, sizeof(outMeta.optimal), tok, strlen(tok)); + const char* fp = metaBuf; + auto nextField = [&](char* dst, size_t dstSize) { + if (fp == nullptr) { + if (dstSize) dst[0] = '\0'; + return; + } + const char* comma = strchr(fp, ','); + const size_t len = comma ? static_cast(comma - fp) : strlen(fp); + boundedCopy(dst, dstSize, fp, len); + fp = comma ? comma + 1 : nullptr; + }; + + nextField(outMeta.datetime, sizeof(outMeta.datetime)); + nextField(outMeta.driver, sizeof(outMeta.driver)); + nextField(outMeta.course, sizeof(outMeta.course)); + nextField(outMeta.shortName, sizeof(outMeta.shortName)); + nextField(outMeta.bestLap, sizeof(outMeta.bestLap)); + nextField(outMeta.optimal, sizeof(outMeta.optimal)); // device_name — trailing column; absent in pre-device_name logs (stays ""). - tok = strtok(nullptr, ","); - if (tok) boundedCopy(outMeta.device, sizeof(outMeta.device), tok, strlen(tok)); + nextField(outMeta.device, sizeof(outMeta.device)); + // race_mode — trailing column; absent in older logs (stays "" = circuit). + nextField(outMeta.raceMode, sizeof(outMeta.raceMode)); // Line 3 (lap column label) — skip; tolerate missing. if (!nextLine(lineBegin, lineLen)) return true; // no laps recorded @@ -182,7 +196,7 @@ bool parse(const char* buf, size_t bufSize, char lapsBuf[kHeaderSize]; boundedCopy(lapsBuf, sizeof(lapsBuf), lineBegin, lineLen); - tok = strtok(lapsBuf, ","); + char* tok = strtok(lapsBuf, ","); while (tok != nullptr && outLapCount < maxLaps) { const unsigned long v = strtoul(tok, nullptr, 10); if (v > 0) { diff --git a/BirdsEye/dovex_header.h b/BirdsEye/dovex_header.h index 7abbf01..47e322b 100644 --- a/BirdsEye/dovex_header.h +++ b/BirdsEye/dovex_header.h @@ -8,7 +8,7 @@ // operate through this interface — the file I/O stays out of here. // // On-disk layout (DOVEX_HEADER_SIZE = 1024 bytes total): -// Line 1 (\r\n): datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name +// Line 1 (\r\n): datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode // Line 2 (\r\n): // Line 3 (\r\n): laps_ms // Line 4 (\r\n): @@ -18,9 +18,13 @@ // mid-session the bytes 0..1023 are left blank but everything after // (the streaming GPS rows) is still valid. // -// device_name is the trailing column so it stays backwards compatible: -// older readers stop at optimal_ms and ignore it, and logs written before -// this column existed parse back with an empty device_name. +// device_name and race_mode are trailing columns so they stay backwards +// compatible: older readers stop at optimal_ms and ignore them, and logs +// written before the columns existed parse back with empty values. +// race_mode is "CIRCUIT" or "SPRINT" (compare case-insensitively; empty +// or absent = CIRCUIT, so every legacy log reads as a circuit session). +// It is deliberately just a loading helper for the webapp — with SPRINT, +// the laps line is a runs line — nothing on-device depends on it. /////////////////////////////////////////// #include @@ -38,6 +42,7 @@ constexpr size_t kCourseLen = 32; constexpr size_t kShortNameLen = 16; constexpr size_t kLapStrLen = 16; // "N/A" or numeric lap time as text constexpr size_t kDeviceLen = 32; +constexpr size_t kRaceModeLen = 8; // "CIRCUIT"/"SPRINT" + NUL // Input to format(). All const char* fields must be non-null and // well-formed; format() does NOT escape commas inside them. @@ -49,6 +54,7 @@ struct Metadata { unsigned long bestLapMs; // 0 -> printed as "N/A" unsigned long optimalMs; // 0 -> printed as "N/A" const char* device; // logging device name; null/"" -> empty column + const char* raceMode; // "CIRCUIT"/"SPRINT"; null/"" -> empty column (= circuit) }; // Output of parse(). Strings are always null-terminated. @@ -60,6 +66,7 @@ struct ParsedHeader { char bestLap[kLapStrLen]; // text — may be "N/A" char optimal[kLapStrLen]; // text — may be "N/A" char device[kDeviceLen]; // logging device name — "" for legacy logs + char raceMode[kRaceModeLen]; // "CIRCUIT"/"SPRINT" — "" for legacy logs (= circuit) }; // Render metadata + lap times into the first kHeaderSize bytes of diff --git a/BirdsEye/firmware_ota.h b/BirdsEye/firmware_ota.h index 669bc21..8be9513 100644 --- a/BirdsEye/firmware_ota.h +++ b/BirdsEye/firmware_ota.h @@ -24,6 +24,9 @@ // // FWDONE -> FWOK: | FWERR:CRC|SIZE|WRITE // FWAPPLY -> FWSTAGE:* , FWAPPLIED | FWERR:... +// FWDFU -> FWDFU:OK, then reboot into the bootloader's +// UF2 mass-storage mode (drag-and-drop .uf2 +// flashing over USB; no size cap, no staging) // // is the target board variant ("sense" / "nonsense"), which the web // app derives authoritatively from the device's own DIS Model Number. It is diff --git a/BirdsEye/firmware_ota.ino b/BirdsEye/firmware_ota.ino index a7edbcf..2e12bd6 100644 --- a/BirdsEye/firmware_ota.ino +++ b/BirdsEye/firmware_ota.ino @@ -37,22 +37,55 @@ extern "C" { // 0x00000000 MBR + SoftDevice S140 7.3.0 // 0x00027000 CODE_REGION_1_START — application starts here (FW_APP_BASE) // ... running application (this firmware) -// 0x000A4000 staging region for the incoming image (FW_STAGE_BASE) +// 0x0008E000 staging region for the incoming image (FW_STAGE_BASE) // 0x000F4000 Adafruit bootloader (FW_BOOTLOADER_ADDR) // 0x000FE000 MBR parameter page // 0x000FF000 bootloader settings page // -// The staging region [FW_STAGE_BASE, FW_BOOTLOADER_ADDR) is 320 KB and must -// not overlap the running app, which leaves [0x27000, 0xA4000) = 512 KB for -// the app — comfortably larger than the current image. PHASE 0 must confirm -// FW_STAGE_BASE sits above this firmware's actual end (check the .map file) -// and below the installed bootloader on the target units. +// The app and the staging region share one 820 KiB stretch +// [0x27000, 0xF4000) = 839 680 B, and BOTH must be able to hold the image: +// the incoming one is staged up top, then copied down over the app. So the +// largest OTA-able image is half that span, and the split should be even. +// It was not: 320 KiB staging / 500 KiB app capped OTA at 320 KiB while +// leaving 180 KiB of app region no image could ever legally reach. Splitting +// evenly (rounded to a 4 KiB erase page) raises the cap to 408 KiB at no +// cost to anything reachable: +// +// staging [0x8E000, 0xF4000) = 417 792 B = 408 KiB <- FW_MAX_IMAGE_SIZE +// app [0x27000, 0x8E000) = 421 888 B = 412 KiB (>= the cap, so any +// legal image fits) +// +// Migration is one-way-safe. A unit still on pre-3.1 firmware stages at the +// OLD 0xA4000 using its OWN constants — staging is chosen at apply time and +// nothing about it is baked into the image — so it installs this firmware +// normally as long as the image clears the old 320 KiB cap. And because the +// new app region ends at 0x8E000, BELOW the old 0xA4000 staging base, an +// image built for this layout can never collide with an old unit's staging +// region. Growing the cap is therefore purely additive for the fleet. +// +// This is the low-risk half of plan 0004. The other half (stage straight +// from SD, deleting the internal staging region entirely and taking the cap +// to ~792 KiB) needs the hardware spikes in that plan and is NOT done here. +// +// PHASE 0 must confirm FW_STAGE_BASE sits above this firmware's actual end +// (check the .map file) and below the installed bootloader on the target +// units. fwDoApply() also checks the first half at runtime. /////////////////////////////////////////// #define FW_APP_BASE 0x00027000UL #define FW_BOOTLOADER_ADDR 0x000F4000UL #define FW_FLASH_PAGE_SIZE 4096UL -#define FW_MAX_IMAGE_SIZE (320UL * 1024UL) -#define FW_STAGE_BASE (FW_BOOTLOADER_ADDR - FW_MAX_IMAGE_SIZE) // 0xA4000 +// Half the app+staging span, rounded down to a whole erase page. Keep +// OTA_IMAGE_MAX_BYTES in .github/workflows/compile-sketch.yml in sync. +#define FW_MAX_IMAGE_SIZE (408UL * 1024UL) +#define FW_STAGE_BASE (FW_BOOTLOADER_ADDR - FW_MAX_IMAGE_SIZE) // 0x8E000 + +// The two halves must fit the span and the app region must be able to hold +// any image the cap admits — get either wrong and the apply either erases +// live code or accepts an image it cannot install. +static_assert(FW_STAGE_BASE % FW_FLASH_PAGE_SIZE == 0, + "FW_STAGE_BASE must be flash-page aligned (it is erased page by page)"); +static_assert(FW_MAX_IMAGE_SIZE <= FW_STAGE_BASE - FW_APP_BASE, + "app region must be at least as large as the max image"); // Bootloader recovery flag written to GPREGRET before the destructive swap. // If the swap is interrupted (power loss mid-erase) the app is left invalid; @@ -65,6 +98,13 @@ extern "C" { // bootloader's magic.) #define FW_GPREGRET_OTA_DFU 0xA8 +// UF2 mass-storage DFU magic (DFU_MAGIC_UF2_RESET on the Adafruit/Seeed +// bootloader): rebooting with this in GPREGRET brings the device up as a +// USB drive — drop a .uf2 on it and the bootloader flashes it. This is the +// FWDFU command's escape hatch: an update path with NO image-size cap and +// no web/BLE streaming, usable on any sealed unit with a USB cable. +#define FW_GPREGRET_UF2_DFU 0x57 + // Minimum battery to allow APPLY. A brownout mid-swap bricks until the // recovery net kicks in, so we gate on a healthy pack. Uses the cached // reading (lastBatteryVoltage) that the BATT command and main loop maintain. @@ -127,6 +167,7 @@ static volatile bool fwPutPending = false; static volatile bool fwDonePending = false; static volatile bool fwApplyPending = false; static volatile bool fwAbortPending = false; +static volatile bool fwDfuPending = false; // Receive bookkeeping. static uint32_t fwBytesReceived = 0; @@ -211,7 +252,8 @@ bool fwIsCommand(const char* cmd) { strncmp(cmd, "FWPUT:", 6) == 0 || strcmp(cmd, "FWDONE") == 0 || strcmp(cmd, "FWAPPLY") == 0 || - strcmp(cmd, "FWABORT") == 0); + strcmp(cmd, "FWABORT") == 0 || + strcmp(cmd, "FWDFU") == 0); } bool fwReceiving() { @@ -299,6 +341,15 @@ void fwHandleCommand(const char* cmd, uint16_t len) { fwAbortPending = true; return; } + + if (strcmp(cmd, "FWDFU") == 0) { + // Reboot into the bootloader's UF2 mass-storage DFU mode. Accepted from + // any state — entering DFU abandons everything anyway, and the deferred + // handler tears down cleanly first. Executed on the main loop like every + // other state-changing FW command. + fwDfuPending = true; + return; + } } /////////////////////////////////////////// @@ -672,7 +723,51 @@ static void fwDoApply() { /////////////////////////////////////////// // Main-loop service /////////////////////////////////////////// +/////////////////////////////////////////// +// FWDFU — reboot into UF2 mass-storage DFU (main loop only) +/////////////////////////////////////////// + +// The "pre-update" escape hatch (plan 0004): reboot into the Adafruit/Seeed +// bootloader's UF2 mode, where the device enumerates as a USB drive and the +// bootloader flashes whatever .uf2 is copied onto it. Unlike the FW* OTA +// this path has NO image-size cap (the bootloader writes the app region +// directly — no staging), so any device carrying this command can always be +// updated with just a USB cable, whatever the future firmware size. +// +// The bootloader is UNCHANGED by this — 0x57 (DFU_MAGIC_UF2_RESET) is a +// stock feature of the shipped Adafruit-based bootloader, the same GPREGRET +// handoff the OTA recovery flag already relies on. If UF2 mode is exited +// without flashing (unplug / reset), the existing app boots normally. +void fwEnterUf2Dfu() { + debugln(F("FW: entering UF2 DFU (bootloader mass-storage mode)")); + + // Acknowledge BEFORE the reboot kills the link — the client otherwise + // never learns whether the command landed. + fwNotify("FWDFU:OK"); + + // Abort any in-flight OTA cleanly: closes the staging file, releases SD. + fwReset(); + + // Let the notify make it over the air (mirrors the disconnect-reboot + // path's settling delay). + delay(200); + + // Same restricted-peripheral access pattern as the apply path: the + // SoftDevice owns POWER while BLE is up, so GPREGRET goes through the + // supervisor calls. + sd_power_gpregret_clr(0, 0xFF); + sd_power_gpregret_set(0, FW_GPREGRET_UF2_DFU); + NVIC_SystemReset(); +} + void FW_OTA_LOOP() { + // Deferred FWDFU: acknowledge, tear down, reboot into UF2 bootloader. + // Checked first — a DFU request outranks any in-flight OTA state. + if (fwDfuPending) { + fwDfuPending = false; + fwEnterUf2Dfu(); // does not return + } + // Deferred FWABORT: tear everything down (closes staging file, frees SD). if (fwAbortPending) { fwAbortPending = false; diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index add230b..3b0139c 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -1,910 +1,933 @@ -/////////////////////////////////////////// -// GPS MODULE -// GPS time functions, configuration, setup, main loop, and frame rate -// Uses SparkFun u-blox GNSS v3 library with UBX PVT binary protocol -/////////////////////////////////////////// - -#include "gps_functions.h" -#include "gps_stats.h" -#include "gps_time.h" -#include "gps_validation.h" -#include "sat_bars.h" - -/////////////////////////////////////////// -// GPS SERIAL BUFFER — two buffers, two failure modes -// -// UARTE0 (per-byte ISR, NVIC prio 3) → core RingBuffer (256 B via the -// SERIAL_BUFFER_SIZE build flag; project.h asserts it) → TIMER3 ISR -// (prio 3, every GPS_DRAIN_INTERVAL_US) → this 4 KB ring → SparkFun -// library via GpsBufferedStream on the main loop. -// -// Downstream stalls (SD garbage collection blocks the main loop -// 100 ms–2 s): the 4 KB ring buffers ~1.6 s of stream while TIMER3 -// keeps draining — the original reason this ISR exists. -// -// Upstream deferral (SoftDevice radio ISRs at prio 0–2 preempt both -// prio-3 ISRs): only the CORE ring absorbs bytes until TIMER3 gets to -// run. 256 B ≈ 44 ms of line rate at 57600 baud, so radio activity -// (SensorEgg scan windows, camera connection events) has huge margin -// before a byte is lost. Both overflow points are counted — see the -// gpsStats* accessors and the GPS debug page. -/////////////////////////////////////////// - -// Forward-declare ISR with C linkage BEFORE Arduino's preprocessor -// auto-generates a C++ prototype (which would conflict with extern "C"). -extern "C" void TIMER3_IRQHandler(void); - -#define GPS_RX_BUF_SIZE 4096 -static uint8_t gpsRxBuf[GPS_RX_BUF_SIZE]; -static volatile uint16_t gpsRxHead = 0; // Written by ISR only -static volatile uint16_t gpsRxTail = 0; // Read by main loop only (via gpsStream) -static volatile bool gpsTimerActive = false; - -// Serial-pipeline health counters (monotonic since boot, read via the -// gpsStats*() accessors, shown on the GPS debug page). ISR writes are a -// few cycles each; no SVCs, no floats. -static volatile uint32_t gpsRingFullEvents = 0; // 4KB ring full at drain time -static volatile uint32_t gpsIsrLatencyMaxUs = 0; // worst TIMER3 fire→entry deferral -static volatile uint16_t gpsDrainMaxBytes = 0; // biggest single-fire drain burst -static volatile uint32_t gpsCoreSatEvents = 0; // drain burst filled the core RX ring -static gps_stats::DropMonitor gpsDropMonitor; // main-loop only (frame-rate window) - -// Stream wrapper: SparkFun library reads from our 4KB buffer instead of Serial1. -// Before the timer ISR is started (during GPS_SETUP), reads pass through to -// GPS_SERIAL directly so that myGNSS.begin() can communicate with the module. -class GpsBufferedStream : public Stream { -public: - int available() override { - if (!gpsTimerActive) return GPS_SERIAL.available(); - return (GPS_RX_BUF_SIZE + gpsRxHead - gpsRxTail) % GPS_RX_BUF_SIZE; - } - int read() override { - if (!gpsTimerActive) return GPS_SERIAL.read(); - if (gpsRxHead == gpsRxTail) return -1; - uint8_t c = gpsRxBuf[gpsRxTail]; - gpsRxTail = (gpsRxTail + 1) % GPS_RX_BUF_SIZE; - return c; - } - int peek() override { - if (!gpsTimerActive) return GPS_SERIAL.peek(); - if (gpsRxHead == gpsRxTail) return -1; - return gpsRxBuf[gpsRxTail]; - } - size_t write(uint8_t c) override { - return GPS_SERIAL.write(c); - } - size_t write(const uint8_t *buffer, size_t size) override { - return GPS_SERIAL.write(buffer, size); - } - void flush() override { - GPS_SERIAL.flush(); - } -}; - -static GpsBufferedStream gpsStream; - -// Timer3 ISR: drains Serial1 into our 4KB buffer every ~10ms. -// Single-producer (ISR writes gpsRxHead), single-consumer (main loop -// reads gpsRxTail via gpsStream) — lock-free ring buffer. -// -// Brief __disable_irq() around each GPS_SERIAL read protects the UART -// driver's internal FIFO from concurrent access by the core's UARTE -// ISR (same NVIC priority 3 as this handler, so it can't preempt us — -// the guard covers it firing *between* our read calls). Each critical -// section is ~0.3µs — well within SoftDevice's 6µs safe window. -void TIMER3_IRQHandler(void) { - if (NRF_TIMER3->EVENTS_COMPARE[0]) { - NRF_TIMER3->EVENTS_COMPARE[0] = 0; - // The COMPARE0_CLEAR short zeroed the counter at the scheduled fire - // time, so capturing now reads how long SoftDevice radio ISRs (the - // only thing above priority 3) deferred this handler, in µs. If a - // deferral swallows a whole period the reading wraps mod the period - // — the drain/saturation counters below catch that case. - NRF_TIMER3->TASKS_CAPTURE[1] = 1; - uint32_t deferralUs = NRF_TIMER3->CC[1]; - if (deferralUs > gpsIsrLatencyMaxUs) gpsIsrLatencyMaxUs = deferralUs; - uint16_t drained = 0; - while (true) { - __disable_irq(); - int c = GPS_SERIAL.available() ? GPS_SERIAL.read() : -1; - __enable_irq(); - if (c < 0) break; - - uint16_t nextHead = (gpsRxHead + 1) % GPS_RX_BUF_SIZE; - if (nextHead == gpsRxTail) { // Buffer full, drop bytes - gpsRingFullEvents++; - break; - } - gpsRxBuf[gpsRxHead] = (uint8_t)c; - gpsRxHead = nextHead; - drained++; - } - if (drained > gpsDrainMaxBytes) gpsDrainMaxBytes = drained; - // Draining a full core ring means it was saturated while we were - // deferred — bytes may already have been dropped upstream (the - // core's store_char discards silently on full). - if (drained >= SERIAL_BUFFER_SIZE - 1) gpsCoreSatEvents++; - } -} - -void startGpsSerialTimer() { - NRF_TIMER3->TASKS_STOP = 1; - NRF_TIMER3->TASKS_CLEAR = 1; - NRF_TIMER3->MODE = TIMER_MODE_MODE_Timer; - NRF_TIMER3->BITMODE = TIMER_BITMODE_BITMODE_32Bit; - NRF_TIMER3->PRESCALER = 4; // 16MHz / 2^4 = 1MHz tick - NRF_TIMER3->CC[0] = GPS_DRAIN_INTERVAL_US; // drain period (see gps_config.h) - NRF_TIMER3->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk; - NRF_TIMER3->INTENSET = TIMER_INTENSET_COMPARE0_Msk; - NVIC_SetPriority(TIMER3_IRQn, 3); // Below SoftDevice (0-2), above main loop - NVIC_ClearPendingIRQ(TIMER3_IRQn); // Clear stale pending interrupt from prior session - NVIC_EnableIRQ(TIMER3_IRQn); - gpsTimerActive = true; - NRF_TIMER3->TASKS_START = 1; - debugln(F("GPS serial buffer timer started")); -} - -void stopGpsSerialTimer() { - NRF_TIMER3->TASKS_STOP = 1; - NRF_TIMER3->INTENCLR = TIMER_INTENCLR_COMPARE0_Msk; - NVIC_DisableIRQ(TIMER3_IRQn); - NVIC_ClearPendingIRQ(TIMER3_IRQn); // Ensure no stale ISR fires after disable - __DSB(); // ARM barrier: NVIC ops complete before flag update - gpsTimerActive = false; -} - -/** - * @brief Returns the GPS time since midnight in milliseconds, or 0 if GPS unavailable. - * The pure math lives in gps_time::timeOfDayMs — this just plumbs through gpsData. - */ -unsigned long getGpsTimeInMilliseconds() { - if (!gpsInitialized) return 0; - return gps_time::timeOfDayMs(gpsData.hour, gpsData.minute, - gpsData.seconds, gpsData.milliseconds); -} - -/** - * @brief Converts GPS date/time to Unix timestamp in seconds. 0 if GPS unavailable. - * gpsData.year is the 2-digit year offset from 2000. - */ -unsigned long getGpsUnixTimestamp() { - if (!gpsInitialized) return 0; - return static_cast(gps_time::unixTimestampSeconds( - 2000 + gpsData.year, gpsData.month, gpsData.day, - gpsData.hour, gpsData.minute, gpsData.seconds)); -} - -/** - * @brief Converts GPS date/time to Unix timestamp with millisecond precision. - * 0 if GPS unavailable. - */ -unsigned long long getGpsUnixTimestampMillis() { - if (!gpsInitialized) return 0; - return gps_time::unixTimestampMillis( - 2000 + gpsData.year, gpsData.month, gpsData.day, - gpsData.hour, gpsData.minute, gpsData.seconds, gpsData.milliseconds); -} - -// PVT callback — called synchronously from checkCallbacks() when a new -// NAV-PVT message arrives. Populates the shared gpsData struct and sets -// the gpsDataFresh flag so GPS_LOOP() knows to run lap-timer / logging. -void onPVTReceived(UBX_NAV_PVT_data_t *pvt) { - // lat/lng stay double (see GpsData); the rest is single-precision with - // reciprocal-constant multiplies — hardware FPU, no software-double - // divides in this 25 Hz callback. - gpsData.latitudeDegrees = pvt->lat / 1e7; - gpsData.longitudeDegrees = pvt->lon / 1e7; - gpsData.altitude = (float)pvt->hMSL * 0.001f; // mm → meters - gpsData.speed = (float)pvt->gSpeed * (1.0f / 514.444f); // mm/s → knots - gpsData.HDOP = (float)pvt->pDOP * 0.01f; // pDOP ≈ HDOP for track use - gpsData.heading = (float)pvt->headMot * 1e-5f; // deg * 1e-5 → degrees - gpsData.horizontalAccuracy = (float)pvt->hAcc * 0.001f; // mm → meters - gpsData.satellites = pvt->numSV; - // A fix is only trustworthy when the module also asserts gnssFixOK — a bare - // fixType >= 2 can appear during convergence with garbage coordinates. - gpsData.fix = (pvt->fixType >= 2) && (pvt->flags.bits.gnssFixOK != 0); - // Time is only usable for naming/saving the log once the module reports the - // date AND time AND a fully-resolved UTC. Before this, the module emits a - // placeholder date (e.g. 2021-03-07) that must NOT drive file creation. - gpsData.timeValid = (pvt->valid.bits.validDate != 0) && - (pvt->valid.bits.validTime != 0) && - (pvt->valid.bits.fullyResolved != 0); - gpsData.year = pvt->year - 2000; - gpsData.month = pvt->month; - gpsData.day = pvt->day; - gpsData.hour = pvt->hour; - gpsData.minute = pvt->min; - gpsData.seconds = pvt->sec; - gpsData.milliseconds = (pvt->iTOW % 1000); // ms from GPS time-of-week - - gpsDataFresh = true; - gpsFrameCounter++; -} - -// NAV-SAT callback — fired by checkCallbacks() while status mode has -// NAV-SAT enabled. Snapshots per-satellite CNO (used-in-nav first, -// strongest first — rules in the host-tested sat_bars unit) for the GPS -// status page's signal bars. -void onNAVSATReceived(UBX_NAV_SAT_data_t *sat) { - sat_bars::SatObs obs[64]; - uint8_t n = sat->header.numSvs; - if (n > 64) n = 64; - uint8_t used = 0; - uint8_t tracked = 0; - for (uint8_t i = 0; i < n; i++) { - obs[i].cno = sat->blocks[i].cno; - obs[i].used = (sat->blocks[i].flags.bits.svUsed != 0); - if (obs[i].used) used++; - if (obs[i].cno > 0) tracked++; // hearing a signal, used in nav or not - } - gpsSatUsedCount = used; - gpsSatTrackedCount = tracked; - gpsSatCnoCount = (uint8_t)sat_bars::selectCnos(obs, n, gpsSatCnos, - sat_bars::kMaxSats); -} - -// Register the message callbacks with the SparkFun library. Needed -// after every myGNSS.begin() — begin() resets library state, dropping -// previously registered callbacks. -static void gpsRegisterCallbacks() { - // Runs under the armed WDT when re-registering after a baud recovery — - // each call is a blocking VALSET/ACK exchange (see GPS_RECONFIGURE). - wdtPet(); - myGNSS.setAutoPVTcallbackPtr(&onPVTReceived); - wdtPet(); - if (gpsNavSatWanted) { - myGNSS.setAutoNAVSATcallbackPtr(&onNAVSATReceived); - wdtPet(); - // NAV-SAT frames are big (8 + 12*numSvs bytes); divide them down to - // ~1 Hz instead of one per nav solution. Plenty for signal bars. - myGNSS.setAutoNAVSATrate(GPS_NAV_RATE_STATUS_HZ); - wdtPet(); - } -} - -// Serial1 open-state guard for baud switches. The core's Uart::end() -// spin-waits on the TXSTOPPED + RXTO events, but a never-begun UARTE is -// disabled and ignores the STOPRX/STOPTX task writes — the events can -// never fire and end() loops forever. (end() has no _begun check; begin() -// does, and is a no-op on an open port, so a baud change NEEDS the end().) -// Track open state ourselves and only end() a port we actually opened. -static bool gpsSerialBegun = false; -static void gpsSerialRestart(unsigned long baud) { - if (gpsSerialBegun) { - GPS_SERIAL.end(); - delay(20); - } - GPS_SERIAL.begin(baud); - gpsSerialBegun = true; -} - -// The baud probe ladder. The module can be in ANY state at boot — every -// boot is a cold start now that sleep is System OFF: -// (1) software backup mode holding a 57600 config (the normal wake), -// (2) already-configured 57600 and running (MCU-only reset: reboot -// combo, watchdog, OTA), -// (3) factory 9600 NMEA (true cold power, or V_BCKP/VCC brownout). -// Sequence: backup-wake byte first (harmless if awake), probe 57600 -// (the common warm case, ~instant), fall back to 9600 + rate switch, -// and only if all that fails pay a cold-boot delay and retry once. -// Caller owns gpsInitialized and the serial timer. -static bool gpsSetupProbe(bool coldRetry) { - // Any UART activity on RX wakes u-blox from powerOff backup mode. - gpsSerialRestart(GPS_BAUD_RATE); - delay(50); - GPS_SERIAL.write(0xFF); - delay(100); - - // 57600 first — warm module answers immediately. Short maxWait: a - // failing begin() is 3 internal ping retries back-to-back with no way - // to pet the WDT in between (see GPS_PROBE_MAXWAIT_MS). - wdtPet(); - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - wdtPet(); - debugln(F("GPS found at 57600 (config retained)")); - return true; - } - wdtPet(); - - // 9600 fallback — factory default after a full config loss. - debugln(F("GPS not at 57600, trying 9600...")); - gpsSerialRestart(9600); - delay(100); - GPS_SERIAL.write(0xFF); // wake again in case the first byte was eaten at the wrong baud - delay(100); - wdtPet(); - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - wdtPet(); - debugln(F("GPS found at 9600, switching to 57600...")); - myGNSS.setSerialRate(GPS_BAUD_RATE); - delay(100); - gpsSerialRestart(GPS_BAUD_RATE); - delay(100); - wdtPet(); - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - wdtPet(); - debugln(F("GPS reconnected at 57600")); - return true; - } - wdtPet(); - debugln(F("GPS lost after baud switch!")); - return false; - } - wdtPet(); - - // Neither baud answered. A module on true cold power may still be - // booting — give it the boot time the old fixed delay(2250) paid up - // front, then run the ladder once more. - if (coldRetry) { - debugln(F("GPS not detected, waiting for cold boot...")); - for (int i = 0; i < 3; i++) { - wdtPet(); - delay(500); - } - return gpsSetupProbe(false); - } - return false; -} - -void GPS_SETUP() { - gpsInitialized = false; // Reset flag at start of setup - - #ifndef SIM - debugln(F("ACTUAL GPS SETUP")); - - // gpsStream wraps GPS_SERIAL: before the timer starts, reads pass - // through directly so myGNSS.begin() can communicate with the module. - if (!gpsSetupProbe(/*coldRetry=*/true)) { - debugln(F("ERROR: GPS not detected at any baud rate!")); - return; // Leave gpsInitialized = false; GPS_STATUS_RETRY_LOOP may re-probe - } - - // Apply config at the current mode targets (boot = status mode: - // 5 Hz + NAV-SAT for the GPS status page) and register callbacks. - GPS_RECONFIGURE(); - gpsRegisterCallbacks(); - - gpsInitialized = true; - debugln(F("GPS initialized successfully (SparkFun UBX PVT)")); - - // Drain any remaining bytes from Serial1 into our buffer, then - // start the timer ISR for continuous background serial drain. - while (GPS_SERIAL.available()) { - uint16_t nextHead = (gpsRxHead + 1) % GPS_RX_BUF_SIZE; - if (nextHead == gpsRxTail) break; - gpsRxBuf[gpsRxHead] = GPS_SERIAL.read(); - gpsRxHead = nextHead; - } - startGpsSerialTimer(); - - // Arm the PVT-arrival watchdog for boot too: a begin() ping proves - // the module answers, not that PVT flows. If nothing arrives within - // 5 s, GPS_LOOP() runs GPS_BAUD_RECOVERY() — the "make sure we are - // actually connected" check for a module in a weird state. - gpsWakeTime = millis(); - gpsWakeValidated = false; - #else - // Simulator placeholder: no u-blox module to probe. The sim host - // injects PVT data by calling onPVTReceived() directly, so there is - // no serial/library setup to do here. - debugln(F("SIM GPS SETUP")); - gpsInitialized = true; - #endif -} - -// Bounded background re-detect for a GPS that failed GPS_SETUP(). -// Called only while the GPS status page is up — each attempt blocks the -// UI ~2.5 s, so it's capped and spaced out. After the retries are spent -// the page shows a permanent "CHECK WIRING" and the device stays usable -// (race mode degrades gracefully without GPS). -static uint8_t gpsSetupRetryCount = 0; -static unsigned long gpsSetupLastRetry = 0; -#define GPS_SETUP_MAX_RETRIES 3 -#define GPS_SETUP_RETRY_INTERVAL_MS 10000 - -bool gpsRetriesExhausted() { - return !gpsInitialized && gpsSetupRetryCount >= GPS_SETUP_MAX_RETRIES; -} - -void GPS_STATUS_RETRY_LOOP() { - #ifndef SIM - if (gpsInitialized) return; - if (gpsSetupRetryCount >= GPS_SETUP_MAX_RETRIES) return; - if (millis() - gpsSetupLastRetry < GPS_SETUP_RETRY_INTERVAL_MS) return; - gpsSetupLastRetry = millis(); - gpsSetupRetryCount++; - debug(F("GPS re-detect attempt ")); - debugln(gpsSetupRetryCount); - - if (!gpsSetupProbe(/*coldRetry=*/false)) return; - - GPS_RECONFIGURE(); - gpsRegisterCallbacks(); - gpsInitialized = true; - gpsRxHead = 0; - gpsRxTail = 0; - startGpsSerialTimer(); - gpsWakeTime = millis(); - gpsWakeValidated = false; - debugln(F("GPS re-detect succeeded")); - #endif -} - -void GPS_LOOP() { - // Safety check: skip if GPS not initialized - if (!gpsInitialized) { - return; - } - - // Process incoming UBX bytes and fire onPVTReceived() callback if a - // complete PVT message has arrived. The callback populates gpsData - // and sets gpsDataFresh = true. - myGNSS.checkUblox(); - myGNSS.checkCallbacks(); - - // PVT arrival watchdog: after GPS_SETUP() or GPS_WAKE(), if no PVT data - // arrives within 5 seconds, the module likely lost its config (V_BCKP - // dropped → reverted to 9600 baud NMEA, or answered the begin() ping in - // some odd state). Attempt baud recovery. - if (!gpsWakeValidated) { - if (gpsDataFresh) { - // PVT arrived — module is alive and configured correctly - gpsWakeValidated = true; - debugln(F("GPS validated: PVT received")); - } else if (millis() - gpsWakeTime >= 5000) { - debugln(F("GPS validation FAILED: no PVT after 5s, attempting recovery")); - if (GPS_BAUD_RECOVERY()) { - // Recovery succeeded — restart the watchdog for validation - gpsWakeTime = millis(); - debugln(F("GPS recovery succeeded, waiting for PVT...")); - } else { - // Recovery failed — give up, mark validated to stop retrying - gpsWakeValidated = true; - debugln(F("GPS recovery FAILED — GPS unavailable this session")); - } - } - } - - if (gpsDataFresh) { - gpsDataFresh = false; - - // Feed fresh GPS data into the active course/timer - if (gpsData.fix && courseManager != nullptr) { - double ltLat = gpsData.latitudeDegrees; - double ltLng = gpsData.longitudeDegrees; - double ltAlt = gpsData.altitude; - double ltSpeed = gpsData.speed; - - courseManager->updateCurrentTime(getGpsTimeInMilliseconds()); - courseManager->loop(ltLat, ltLng, ltAlt, ltSpeed); - } - - #ifdef SD_CARD_LOGGING_ENABLED - // Determine if logging conditions are met. - // File creation requires a VALID GPS time lock (validDate+validTime+ - // fullyResolved), not merely a non-zero day. Before the module resolves - // real time it emits a placeholder date; creating a file from it produced - // garbage-named logs (e.g. 20210307_0000.dovex) that collided every boot - // and corrupted on reboot. With a real lock the module keeps time across - // the V_BCKP backup, so this still fires within ~1 s of a warm wake. - // While we wait, updateGpsLockHold() pins the user to the tachometer. - // Data writing still requires gpsData.fix for valid coordinates. - bool canWriteData = gpsData.fix && sdSetupSuccess && enableLogging && sdDataLogInitComplete; - bool canCreateFile = sdSetupSuccess && enableLogging && !sdDataLogInitComplete && gpsData.timeValid; - - if (canWriteData) { - - ///////////////////////////////////////////////////////////////// - // Log every PVT update (~25Hz) - - // Snapshot GPS values for logging - const double snapLat = gpsData.latitudeDegrees; - const double snapLng = gpsData.longitudeDegrees; - const double snapAlt = gpsData.altitude; - const double snapHdop = gpsData.HDOP; - const double snapSpeed = gpsData.speed; // knots, for sanity - const int snapSats = gpsData.satellites; - const double snapSpeedMph = snapSpeed * 1.15078; - - // Reject obviously-corrupt samples (sats=0, Null Island, NaN/Inf, - // out-of-range lat/lng/alt/hdop/speed). The rule set lives in - // gps_validation::isSampleValid and is exercised by host tests. - const gps_validation::GpsSample snapForCheck = { - snapLat, snapLng, snapAlt, snapHdop, snapSpeedMph, snapSats}; - if (!gps_validation::isSampleValid(snapForCheck)) { - // Skip this sample - data is not trustworthy - } else { - char csvLine[256]; - char latStr[24], lngStr[24], hdopStr[12], speedStr[16], altStr[16]; - char headingStr[12], hAccStr[12]; - - dtostrf(snapLat, 1, 8, latStr); - dtostrf(snapLng, 1, 8, lngStr); - dtostrf(snapHdop, 1, 1, hdopStr); - dtostrf(snapSpeedMph, 1, 2, speedStr); - dtostrf(snapAlt, 1, 2, altStr); - dtostrf(gpsData.heading, 1, 2, headingStr); - dtostrf(gpsData.horizontalAccuracy, 1, 2, hAccStr); - - char accelXStr[12], accelYStr[12], accelZStr[12]; - dtostrf(accelX, 1, 3, accelXStr); - dtostrf(accelY, 1, 3, accelYStr); - dtostrf(accelZ, 1, 3, accelZStr); - - // SensorEgg wireless EGT (Temp1) + cold junction (Junction1), - // degC. Stale link or egg-reported invalid -> literal "nan" so a - // dropout is a visible gap, never a held flat line. These fields - // must NEVER cause the GPS row to be skipped, so they are checked - // here (falling back to "nan") instead of joining the strs[] - // reject-the-row walk below. - char temp1Str[12], junc1Str[12]; - const float snapEgtC = sensoreggEgtC(); - const float snapJuncC = sensoreggJunctionC(); - if (isnan(snapEgtC)) { - strcpy(temp1Str, "nan"); - } else { - dtostrf(snapEgtC, 1, 1, temp1Str); - if (!gps_validation::isNumericString(temp1Str, sizeof(temp1Str) - 1)) { - strcpy(temp1Str, "nan"); - } - } - if (isnan(snapJuncC)) { - strcpy(junc1Str, "nan"); - } else { - dtostrf(snapJuncC, 1, 1, junc1Str); - if (!gps_validation::isNumericString(junc1Str, sizeof(junc1Str) - 1)) { - strcpy(junc1Str, "nan"); - } - } - - // dtostrf() can produce garbage (empty, too-long, non-numeric) on - // some BSPs when given NaN/Inf even though the sample passed - // validation. Walk every formatted string and reject the row if - // any look wrong. - bool stringsValid = true; - const char* strs[] = {latStr, lngStr, hdopStr, speedStr, altStr, - headingStr, hAccStr, accelXStr, accelYStr, accelZStr}; - for (size_t i = 0; i < sizeof(strs)/sizeof(strs[0]) && stringsValid; i++) { - if (!gps_validation::isNumericString(strs[i], 20)) { - stringsValid = false; - } - } - - if (!stringsValid) { - // dtostrf produced garbage - skip this entry silently - } else { - // Arduino lacks %llu in printf; format the 64-bit timestamp - // ourselves into a stack buffer, then splice via %s. - char timestampStr[24]; - gps_time::u64ToDecimalString(getGpsUnixTimestampMillis(), - timestampStr, sizeof(timestampStr)); - - snprintf(csvLine, sizeof(csvLine), "%s,%d,%s,%s,%s,%s,%s,%s,%s,%d,%s,%s,%s,%s,%s", - timestampStr, snapSats, hdopStr, latStr, lngStr, - speedStr, altStr, headingStr, hAccStr, - tachLastReported, accelXStr, accelYStr, accelZStr, - temp1Str, junc1Str); - - size_t written = dataFile.println(csvLine); - if (written == 0) { - // A write failed mid-session. Don't fault — keep racing so lap - // timing / RPM / display stay live; just stop logging for this - // session (closing cleanly rather than truncating-and-restarting - // the same-minute filename). - debugln(F("SD write failed - stopping logging, race continues")); - enableLogging = false; - sdDataLogInitComplete = false; - // Try to salvage the session's lap times / metadata into the - // reserved header before closing. A write failure here usually - // means a flaky card, so this may also fail — but when it - // succeeds the lap list survives instead of being lost with the - // session. writeDovexHeader() no-ops if the file isn't open. - writeDovexHeader(); - dataFile.close(); - releaseSDAccess(SD_ACCESS_LOGGING); - } - } - } - ///////////////////////////////////////////////////////////////// - - // Flush periodically to avoid data loss - every 10 seconds - if (millis() - lastCardFlush > 10000) { - lastCardFlush = millis(); - dataFile.flush(); - } - } else if (canCreateFile && millis() - lastLogCreateAttempt >= 1000) { - // Throttle open attempts to once per second so a problem card can't - // churn SPI at 25 Hz. We never fault out of race mode here — if the - // file can't be created we simply keep waiting (the user stays on the - // tachometer via the GPS-lock hold) and retry next second. - lastLogCreateAttempt = millis(); - debugln(F("Attempt to initialize logfile")); - - if (!acquireSDAccess(SD_ACCESS_LOGGING)) { - // Another subsystem holds the card (BLE/replay). Don't fault — just - // retry on the next throttled pass once it releases. - debugln(F("Cannot start logging - SD card busy, will retry")); - } else { - char dataFileName[80]; - - // DOVEX filename: 20YYMMDD_HHMM.dovex - snprintf(dataFileName, sizeof(dataFileName), - "20%02d%02d%02d_%02d%02d.dovex", - gpsData.year, gpsData.month, gpsData.day, - gpsData.hour, gpsData.minute); - - debug(F("dataFileName: [")); - debug(dataFileName); - debugln(F("]")); - - dataFile.open(dataFileName, O_CREAT | O_WRITE | O_TRUNC); - - if (!dataFile) { - // Open failed — do NOT fault out of race mode. Release the card and - // leave logging pending; updateGpsLockHold() keeps the user on the - // tachometer and we retry on the next throttled pass. - debugln(F("Error opening log file - will retry")); - releaseSDAccess(SD_ACCESS_LOGGING); - } else { - // DOVEX: pre-fill header region with newlines so the FAT clusters - // for it are allocated up front; the metadata header is written - // back into this region at session end. - char padBuf[64]; - memset(padBuf, '\n', sizeof(padBuf)); - bool prefillOk = true; - for (uint32_t i = 0; i < DOVEX_HEADER_SIZE; i += sizeof(padBuf)) { - uint32_t toWrite = min((uint32_t)sizeof(padBuf), DOVEX_HEADER_SIZE - i); - // Verify each write landed. On a card dropping sectors mid-init, - // an unchecked short write would leave a truncated header region - // but we'd still mark logging ready and stream rows into garbage. - if (dataFile.write(padBuf, toWrite) != toWrite) { - prefillOk = false; - break; - } - } - if (!prefillOk) { - debugln(F("Header pre-fill write failed - aborting log init, will retry")); - dataFile.close(); - releaseSDAccess(SD_ACCESS_LOGGING); - } else { - // Cursor is now at exactly DOVEX_HEADER_SIZE - 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")); - debugln(F("CSV header written")); - sdDataLogInitComplete = true; - } - } - } - } - #endif - - // Update display speed with fresh PVT data - gps_speed_mph = gpsData.speed * 1.15078; - } // end if (gpsDataFresh) -} - -void GPS_SLEEP() { - if (!gpsInitialized) return; - stopGpsSerialTimer(); // Stop serial drain ISR during sleep (saves power) - myGNSS.powerOff(0); // 0 = indefinite sleep until woken -} - -/** - * @brief Re-apply all GPS module configuration via VALSET. - * - * The SAM-M10Q has no flash — all config lives in volatile RAM. If V_BCKP - * drops during backup mode (battery sag, loose connector, cranking brownout), - * the module reverts to factory defaults (9600 baud, NMEA, 1Hz). This - * function re-applies the full configuration at the CURRENT mode targets - * (gpsNavRateTarget / gpsNavSatWanted) so wake/recovery paths re-assert - * whatever mode we're actually in instead of clobbering it. It's fast - * (~50ms total) and idempotent — safe to call even if config was retained. - */ -void GPS_RECONFIGURE() { - // The stream restarts around a reconfigure (wake/recovery paths) — - // discard the frame-rate window in progress so the gap isn't - // miscounted as dropped frames. - gps_stats::noteRateChange(gpsDropMonitor); - // This runs under the armed ~4 s WDT when called from GPS_BAUD_RECOVERY - // or GPS_WAKE. Each call below is a VALSET + ACK wait that can block up - // to ~1.1 s on a module that answers the connection ping but responds - // slowly (cold acquisition, marginal wiring) — 8 of them back-to-back - // is a guaranteed watchdog reset. Pet between every exchange. - wdtPet(); - myGNSS.setUART1Output(COM_TYPE_UBX); - wdtPet(); - myGNSS.setNavigationFrequency(gpsNavRateTarget); - wdtPet(); - myGNSS.setDynamicModel(DYN_MODEL_AUTOMOTIVE); - wdtPet(); - myGNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GPS); - wdtPet(); - myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_SBAS); - wdtPet(); - myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_GALILEO); - wdtPet(); - myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_BEIDOU); - wdtPet(); - myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_GLONASS); - wdtPet(); - if (!gpsNavSatWanted) { - myGNSS.setAutoNAVSAT(false); // no periodic NAV-SAT output in race mode - wdtPet(); - } - debugln(F("GPS config re-applied (VALSET)")); -} - -/** - * @brief Switch to status mode: GPS_NAV_RATE_STATUS_HZ nav solutions with - * NAV-SAT output for the GPS status page's signal bars. This is - * also the boot default (the targets initialize to it). - */ -void gpsEnterStatusMode() { - gpsNavRateTarget = GPS_NAV_RATE_STATUS_HZ; - gpsNavSatWanted = true; - gps_stats::noteRateChange(gpsDropMonitor); - if (!gpsInitialized) return; - myGNSS.setNavigationFrequency(gpsNavRateTarget); - myGNSS.setAutoNAVSATcallbackPtr(&onNAVSATReceived); - myGNSS.setAutoNAVSATrate(GPS_NAV_RATE_STATUS_HZ); // ~1 Hz NAV-SAT frames - debugln(F("GPS status mode (5Hz + NAV-SAT)")); -} - -/** - * @brief Switch to race mode: GPS_NAV_RATE_HZ PVT-only. Called when the - * GPS status page exits — this is the steady state for the menu - * and racing. - */ -void gpsEnterRaceMode() { - gpsNavRateTarget = GPS_NAV_RATE_HZ; - gpsNavSatWanted = false; - gps_stats::noteRateChange(gpsDropMonitor); - gpsSatCnoCount = 0; // stale bars must not outlive the mode - gpsSatUsedCount = 0; - gpsSatTrackedCount = 0; - if (!gpsInitialized) return; - myGNSS.setAutoNAVSAT(false); - myGNSS.setNavigationFrequency(gpsNavRateTarget); - debugln(F("GPS race mode (25Hz PVT-only)")); -} - -/** - * @brief Attempt to recover GPS communication when module has reverted to 9600 baud. - * - * If V_BCKP was lost during backup, the module reverts to 9600 baud. - * Our UART is at 57600 so communication is broken. This function: - * 1. Stops the serial timer (switch to direct Serial1 mode) - * 2. Tries to talk at 57600 first (maybe module is fine) - * 3. If that fails, switches to 9600, sends baud change command, switches back - * 4. Re-applies full config and restarts the serial timer - * - * @return true if communication was recovered, false if unrecoverable - */ -bool GPS_BAUD_RECOVERY() { - debugln(F("GPS baud recovery: attempting...")); - - // This path runs from GPS_LOOP() under the armed ~4 s hardware watchdog, - // and it is the slowest thing in the firmware short of the OTA apply. - // A failing myGNSS.begin() is 3 internal ping retries with no pet - // opportunity in between, so every probe uses GPS_PROBE_MAXWAIT_MS - // (3 x 550 ms = ~1.65 s worst case) and is bracketed by pets — against - // a genuinely hung module that out-waits the WDT → reset → re-setup → - // recovery → reset boot loop, the one path built to revive a sick GPS - // must not trip the watchdog. - wdtPet(); - - // Stop timer so GpsBufferedStream passes through to Serial1 directly. - // The SparkFun library needs direct serial access for begin()/setSerialRate(). - stopGpsSerialTimer(); - - // First: try at current baud — module might be fine, just slow to start - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - wdtPet(); - debugln(F("GPS baud recovery: module responding at 57600")); - GPS_RECONFIGURE(); // pets internally per VALSET exchange - gpsRegisterCallbacks(); // begin() resets library state; pets internally - gpsRxHead = 0; - gpsRxTail = 0; - startGpsSerialTimer(); - return true; - } - - // Module not responding at 57600 — try 9600 (factory default) - debugln(F("GPS baud recovery: trying 9600...")); - wdtPet(); // the first begin() just burned ~1.65 s of the 4 s budget - GPS_SERIAL.end(); - delay(50); - GPS_SERIAL.begin(9600); - delay(100); - - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - // Found at 9600 — switch it back to 57600 - debugln(F("GPS baud recovery: found at 9600, switching to 57600")); - wdtPet(); // second begin() done; baud switch + final probe still ahead - myGNSS.setSerialRate(GPS_BAUD_RATE); - delay(100); - GPS_SERIAL.end(); - delay(50); - GPS_SERIAL.begin(GPS_BAUD_RATE); - delay(100); - wdtPet(); - - if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { - wdtPet(); - debugln(F("GPS baud recovery: reconnected at 57600")); - GPS_RECONFIGURE(); - gpsRegisterCallbacks(); - gpsRxHead = 0; - gpsRxTail = 0; - startGpsSerialTimer(); - return true; - } - debugln(F("GPS baud recovery: lost after baud switch!")); - } else { - debugln(F("GPS baud recovery: not found at 9600 either")); - } - - // Restore 57600 even on failure so other code doesn't break - wdtPet(); - GPS_SERIAL.end(); - delay(50); - GPS_SERIAL.begin(GPS_BAUD_RATE); - delay(50); - gpsRxHead = 0; - gpsRxTail = 0; - startGpsSerialTimer(); - return false; -} - -void GPS_WAKE() { - if (!gpsInitialized) return; - - // Clear stale state from before sleep / periodic checks - gpsDataFresh = false; - gpsData.fix = false; - - // Any UART activity on RX wakes u-blox from powerOff backup mode - GPS_SERIAL.write(0xFF); - delay(100); - - // Reset buffer pointers (stale data from before sleep is useless) - gpsRxHead = 0; - gpsRxTail = 0; - - startGpsSerialTimer(); // Resume serial drain ISR - - // Re-apply configuration in case module lost RAM during backup. - // This is idempotent — if config was retained, these are no-ops. - GPS_RECONFIGURE(); - - myGNSS.checkUblox(); - - // Start PVT arrival watchdog. GPS_LOOP() will validate within 5 seconds, - // and trigger baud recovery if no PVT data arrives. - gpsWakeTime = millis(); - gpsWakeValidated = false; -} - -void calculateGPSFrameRate() { - // calculate actual GPS fix frequency - gpsFrameEndTime = millis(); - // Check if the update interval has passed - if (gpsFrameEndTime - gpsFrameStartTime >= 1000) { - unsigned long elapsed = gpsFrameEndTime - gpsFrameStartTime; - // Calculate the frame rate (loops per second) - gpsFrameRate = (float)gpsFrameCounter / (elapsed / 1000.0); - // Feed the drop accounting: expected-vs-received against the - // current nav-rate target (window math in the gps_stats pure unit). - gps_stats::windowUpdate(gpsDropMonitor, gpsFrameCounter, elapsed, - gpsNavRateTarget); - // Reset the loop counter and start time for the next interval - gpsFrameCounter = 0; - gpsFrameStartTime = millis(); - } -} - -// Serial-pipeline health accessors (GPS debug page + GPS stats page). -uint32_t gpsStatsDroppedPvt() { return gpsDropMonitor.droppedTotal; } -uint32_t gpsStatsRingFullEvents() { return gpsRingFullEvents; } -uint32_t gpsStatsIsrLatencyMaxUs() { return gpsIsrLatencyMaxUs; } -uint16_t gpsStatsDrainMaxBytes() { return gpsDrainMaxBytes; } -uint32_t gpsStatsCoreSatEvents() { return gpsCoreSatEvents; } +/////////////////////////////////////////// +// GPS MODULE +// GPS time functions, configuration, setup, main loop, and frame rate +// Uses SparkFun u-blox GNSS v3 library with UBX PVT binary protocol +/////////////////////////////////////////// + +#include "gps_functions.h" +#include "gps_stats.h" +#include "gps_time.h" +#include "gps_validation.h" +#include "nan_bits.h" +#include "sat_bars.h" + +/////////////////////////////////////////// +// GPS SERIAL BUFFER — two buffers, two failure modes +// +// UARTE0 (per-byte ISR, NVIC prio 3) → core RingBuffer (256 B via the +// SERIAL_BUFFER_SIZE build flag; project.h asserts it) → TIMER3 ISR +// (prio 3, every GPS_DRAIN_INTERVAL_US) → this 4 KB ring → SparkFun +// library via GpsBufferedStream on the main loop. +// +// Downstream stalls (SD garbage collection blocks the main loop +// 100 ms–2 s): the 4 KB ring buffers ~1.6 s of stream while TIMER3 +// keeps draining — the original reason this ISR exists. +// +// Upstream deferral (SoftDevice radio ISRs at prio 0–2 preempt both +// prio-3 ISRs): only the CORE ring absorbs bytes until TIMER3 gets to +// run. 256 B ≈ 44 ms of line rate at 57600 baud, so radio activity +// (SensorEgg scan windows, camera connection events) has huge margin +// before a byte is lost. Both overflow points are counted — see the +// gpsStats* accessors and the GPS debug page. +/////////////////////////////////////////// + +// Forward-declare ISR with C linkage BEFORE Arduino's preprocessor +// auto-generates a C++ prototype (which would conflict with extern "C"). +extern "C" void TIMER3_IRQHandler(void); + +#define GPS_RX_BUF_SIZE 4096 +static uint8_t gpsRxBuf[GPS_RX_BUF_SIZE]; +static volatile uint16_t gpsRxHead = 0; // Written by ISR only +static volatile uint16_t gpsRxTail = 0; // Read by main loop only (via gpsStream) +static volatile bool gpsTimerActive = false; + +// Serial-pipeline health counters (monotonic since boot, read via the +// gpsStats*() accessors, shown on the GPS debug page). ISR writes are a +// few cycles each; no SVCs, no floats. +static volatile uint32_t gpsRingFullEvents = 0; // 4KB ring full at drain time +static volatile uint32_t gpsIsrLatencyMaxUs = 0; // worst TIMER3 fire→entry deferral +static volatile uint16_t gpsDrainMaxBytes = 0; // biggest single-fire drain burst +static volatile uint32_t gpsCoreSatEvents = 0; // drain burst filled the core RX ring +static gps_stats::DropMonitor gpsDropMonitor; // main-loop only (frame-rate window) + +// Stream wrapper: SparkFun library reads from our 4KB buffer instead of Serial1. +// Before the timer ISR is started (during GPS_SETUP), reads pass through to +// GPS_SERIAL directly so that myGNSS.begin() can communicate with the module. +class GpsBufferedStream : public Stream { +public: + int available() override { + if (!gpsTimerActive) return GPS_SERIAL.available(); + return (GPS_RX_BUF_SIZE + gpsRxHead - gpsRxTail) % GPS_RX_BUF_SIZE; + } + int read() override { + if (!gpsTimerActive) return GPS_SERIAL.read(); + if (gpsRxHead == gpsRxTail) return -1; + uint8_t c = gpsRxBuf[gpsRxTail]; + gpsRxTail = (gpsRxTail + 1) % GPS_RX_BUF_SIZE; + return c; + } + int peek() override { + if (!gpsTimerActive) return GPS_SERIAL.peek(); + if (gpsRxHead == gpsRxTail) return -1; + return gpsRxBuf[gpsRxTail]; + } + size_t write(uint8_t c) override { + return GPS_SERIAL.write(c); + } + size_t write(const uint8_t *buffer, size_t size) override { + return GPS_SERIAL.write(buffer, size); + } + void flush() override { + GPS_SERIAL.flush(); + } +}; + +static GpsBufferedStream gpsStream; + +// Timer3 ISR: drains Serial1 into our 4KB buffer every ~10ms. +// Single-producer (ISR writes gpsRxHead), single-consumer (main loop +// reads gpsRxTail via gpsStream) — lock-free ring buffer. +// +// Brief __disable_irq() around each GPS_SERIAL read protects the UART +// driver's internal FIFO from concurrent access by the core's UARTE +// ISR (same NVIC priority 3 as this handler, so it can't preempt us — +// the guard covers it firing *between* our read calls). Each critical +// section is ~0.3µs — well within SoftDevice's 6µs safe window. +void TIMER3_IRQHandler(void) { + if (NRF_TIMER3->EVENTS_COMPARE[0]) { + NRF_TIMER3->EVENTS_COMPARE[0] = 0; + // The COMPARE0_CLEAR short zeroed the counter at the scheduled fire + // time, so capturing now reads how long SoftDevice radio ISRs (the + // only thing above priority 3) deferred this handler, in µs. If a + // deferral swallows a whole period the reading wraps mod the period + // — the drain/saturation counters below catch that case. + NRF_TIMER3->TASKS_CAPTURE[1] = 1; + uint32_t deferralUs = NRF_TIMER3->CC[1]; + if (deferralUs > gpsIsrLatencyMaxUs) gpsIsrLatencyMaxUs = deferralUs; + uint16_t drained = 0; + while (true) { + __disable_irq(); + int c = GPS_SERIAL.available() ? GPS_SERIAL.read() : -1; + __enable_irq(); + if (c < 0) break; + + uint16_t nextHead = (gpsRxHead + 1) % GPS_RX_BUF_SIZE; + if (nextHead == gpsRxTail) { // Buffer full, drop bytes + gpsRingFullEvents++; + break; + } + gpsRxBuf[gpsRxHead] = (uint8_t)c; + gpsRxHead = nextHead; + drained++; + } + if (drained > gpsDrainMaxBytes) gpsDrainMaxBytes = drained; + // Draining a full core ring means it was saturated while we were + // deferred — bytes may already have been dropped upstream (the + // core's store_char discards silently on full). + if (drained >= SERIAL_BUFFER_SIZE - 1) gpsCoreSatEvents++; + } +} + +void startGpsSerialTimer() { + NRF_TIMER3->TASKS_STOP = 1; + NRF_TIMER3->TASKS_CLEAR = 1; + NRF_TIMER3->MODE = TIMER_MODE_MODE_Timer; + NRF_TIMER3->BITMODE = TIMER_BITMODE_BITMODE_32Bit; + NRF_TIMER3->PRESCALER = 4; // 16MHz / 2^4 = 1MHz tick + NRF_TIMER3->CC[0] = GPS_DRAIN_INTERVAL_US; // drain period (see gps_config.h) + NRF_TIMER3->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk; + NRF_TIMER3->INTENSET = TIMER_INTENSET_COMPARE0_Msk; + NVIC_SetPriority(TIMER3_IRQn, 3); // Below SoftDevice (0-2), above main loop + NVIC_ClearPendingIRQ(TIMER3_IRQn); // Clear stale pending interrupt from prior session + NVIC_EnableIRQ(TIMER3_IRQn); + gpsTimerActive = true; + NRF_TIMER3->TASKS_START = 1; + debugln(F("GPS serial buffer timer started")); +} + +void stopGpsSerialTimer() { + NRF_TIMER3->TASKS_STOP = 1; + NRF_TIMER3->INTENCLR = TIMER_INTENCLR_COMPARE0_Msk; + NVIC_DisableIRQ(TIMER3_IRQn); + NVIC_ClearPendingIRQ(TIMER3_IRQn); // Ensure no stale ISR fires after disable + __DSB(); // ARM barrier: NVIC ops complete before flag update + gpsTimerActive = false; +} + +/** + * @brief Returns the GPS time since midnight in milliseconds, or 0 if GPS unavailable. + * The pure math lives in gps_time::timeOfDayMs — this just plumbs through gpsData. + */ +unsigned long getGpsTimeInMilliseconds() { + if (!gpsInitialized) return 0; + return gps_time::timeOfDayMs(gpsData.hour, gpsData.minute, + gpsData.seconds, gpsData.milliseconds); +} + +/** + * @brief Converts GPS date/time to Unix timestamp in seconds. 0 if GPS unavailable. + * gpsData.year is the 2-digit year offset from 2000. + */ +unsigned long getGpsUnixTimestamp() { + if (!gpsInitialized) return 0; + return static_cast(gps_time::unixTimestampSeconds( + 2000 + gpsData.year, gpsData.month, gpsData.day, + gpsData.hour, gpsData.minute, gpsData.seconds)); +} + +/** + * @brief Converts GPS date/time to Unix timestamp with millisecond precision. + * 0 if GPS unavailable. + */ +unsigned long long getGpsUnixTimestampMillis() { + if (!gpsInitialized) return 0; + return gps_time::unixTimestampMillis( + 2000 + gpsData.year, gpsData.month, gpsData.day, + gpsData.hour, gpsData.minute, gpsData.seconds, gpsData.milliseconds); +} + +// PVT callback — called synchronously from checkCallbacks() when a new +// NAV-PVT message arrives. Populates the shared gpsData struct and sets +// the gpsDataFresh flag so GPS_LOOP() knows to run lap-timer / logging. +void onPVTReceived(UBX_NAV_PVT_data_t *pvt) { + // lat/lng stay double (see GpsData); the rest is single-precision with + // reciprocal-constant multiplies — hardware FPU, no software-double + // divides in this 25 Hz callback. + gpsData.latitudeDegrees = pvt->lat / 1e7; + gpsData.longitudeDegrees = pvt->lon / 1e7; + gpsData.altitude = (float)pvt->hMSL * 0.001f; // mm → meters + gpsData.speed = (float)pvt->gSpeed * (1.0f / 514.444f); // mm/s → knots + gpsData.HDOP = (float)pvt->pDOP * 0.01f; // pDOP ≈ HDOP for track use + gpsData.heading = (float)pvt->headMot * 1e-5f; // deg * 1e-5 → degrees + gpsData.horizontalAccuracy = (float)pvt->hAcc * 0.001f; // mm → meters + gpsData.satellites = pvt->numSV; + // A fix is only trustworthy when the module also asserts gnssFixOK — a bare + // fixType >= 2 can appear during convergence with garbage coordinates. + gpsData.fix = (pvt->fixType >= 2) && (pvt->flags.bits.gnssFixOK != 0); + // Time is only usable for naming/saving the log once the module reports the + // date AND time AND a fully-resolved UTC. Before this, the module emits a + // placeholder date (e.g. 2021-03-07) that must NOT drive file creation. + gpsData.timeDateValid = (pvt->valid.bits.validDate != 0) && + (pvt->valid.bits.validTime != 0); + gpsData.timeResolved = (pvt->valid.bits.fullyResolved != 0); + gpsData.timeValid = gpsData.timeDateValid && gpsData.timeResolved; + gpsData.year = pvt->year - 2000; + gpsData.month = pvt->month; + gpsData.day = pvt->day; + gpsData.hour = pvt->hour; + gpsData.minute = pvt->min; + gpsData.seconds = pvt->sec; + gpsData.milliseconds = (pvt->iTOW % 1000); // ms from GPS time-of-week + + gpsDataFresh = true; + gpsPvtSequence++; // monotonic; for consumers that run after GPS_LOOP() + gpsFrameCounter++; +} + +// NAV-SAT callback — fired by checkCallbacks() while status mode has +// NAV-SAT enabled. Snapshots per-satellite CNO (used-in-nav first, +// strongest first — rules in the host-tested sat_bars unit) for the GPS +// status page's signal bars. +void onNAVSATReceived(UBX_NAV_SAT_data_t *sat) { + sat_bars::SatObs obs[64]; + uint8_t n = sat->header.numSvs; + if (n > 64) n = 64; + uint8_t used = 0; + uint8_t tracked = 0; + for (uint8_t i = 0; i < n; i++) { + obs[i].cno = sat->blocks[i].cno; + obs[i].used = (sat->blocks[i].flags.bits.svUsed != 0); + if (obs[i].used) used++; + if (obs[i].cno > 0) tracked++; // hearing a signal, used in nav or not + } + gpsSatUsedCount = used; + gpsSatTrackedCount = tracked; + gpsSatCnoCount = (uint8_t)sat_bars::selectCnos(obs, n, gpsSatCnos, + sat_bars::kMaxSats); +} + +// Register the message callbacks with the SparkFun library. Needed +// after every myGNSS.begin() — begin() resets library state, dropping +// previously registered callbacks. +static void gpsRegisterCallbacks() { + // Runs under the armed WDT when re-registering after a baud recovery — + // each call is a blocking VALSET/ACK exchange (see GPS_RECONFIGURE). + wdtPet(); + myGNSS.setAutoPVTcallbackPtr(&onPVTReceived); + wdtPet(); + if (gpsNavSatWanted) { + myGNSS.setAutoNAVSATcallbackPtr(&onNAVSATReceived); + wdtPet(); + // NAV-SAT frames are big (8 + 12*numSvs bytes); divide them down to + // ~1 Hz instead of one per nav solution. Plenty for signal bars. + myGNSS.setAutoNAVSATrate(GPS_NAV_RATE_STATUS_HZ); + wdtPet(); + } +} + +// Serial1 open-state guard for baud switches. The core's Uart::end() +// spin-waits on the TXSTOPPED + RXTO events, but a never-begun UARTE is +// disabled and ignores the STOPRX/STOPTX task writes — the events can +// never fire and end() loops forever. (end() has no _begun check; begin() +// does, and is a no-op on an open port, so a baud change NEEDS the end().) +// Track open state ourselves and only end() a port we actually opened. +static bool gpsSerialBegun = false; +static void gpsSerialRestart(unsigned long baud) { + if (gpsSerialBegun) { + GPS_SERIAL.end(); + delay(20); + } + GPS_SERIAL.begin(baud); + gpsSerialBegun = true; +} + +// The baud probe ladder. The module can be in ANY state at boot — every +// boot is a cold start now that sleep is System OFF: +// (1) software backup mode holding a 57600 config (the normal wake), +// (2) already-configured 57600 and running (MCU-only reset: reboot +// combo, watchdog, OTA), +// (3) factory 9600 NMEA (true cold power, or V_BCKP/VCC brownout). +// Sequence: backup-wake byte first (harmless if awake), probe 57600 +// (the common warm case, ~instant), fall back to 9600 + rate switch, +// and only if all that fails pay a cold-boot delay and retry once. +// Caller owns gpsInitialized and the serial timer. +static bool gpsSetupProbe(bool coldRetry) { + // Any UART activity on RX wakes u-blox from powerOff backup mode. + gpsSerialRestart(GPS_BAUD_RATE); + delay(50); + GPS_SERIAL.write(0xFF); + delay(100); + + // 57600 first — warm module answers immediately. Short maxWait: a + // failing begin() is 3 internal ping retries back-to-back with no way + // to pet the WDT in between (see GPS_PROBE_MAXWAIT_MS). + wdtPet(); + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + wdtPet(); + debugln(F("GPS found at 57600 (config retained)")); + return true; + } + wdtPet(); + + // 9600 fallback — factory default after a full config loss. + debugln(F("GPS not at 57600, trying 9600...")); + gpsSerialRestart(9600); + delay(100); + GPS_SERIAL.write(0xFF); // wake again in case the first byte was eaten at the wrong baud + delay(100); + wdtPet(); + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + wdtPet(); + debugln(F("GPS found at 9600, switching to 57600...")); + myGNSS.setSerialRate(GPS_BAUD_RATE); + delay(100); + gpsSerialRestart(GPS_BAUD_RATE); + delay(100); + wdtPet(); + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + wdtPet(); + debugln(F("GPS reconnected at 57600")); + return true; + } + wdtPet(); + debugln(F("GPS lost after baud switch!")); + return false; + } + wdtPet(); + + // Neither baud answered. A module on true cold power may still be + // booting — give it the boot time the old fixed delay(2250) paid up + // front, then run the ladder once more. + if (coldRetry) { + debugln(F("GPS not detected, waiting for cold boot...")); + for (int i = 0; i < 3; i++) { + wdtPet(); + delay(500); + } + return gpsSetupProbe(false); + } + return false; +} + +void GPS_SETUP() { + gpsInitialized = false; // Reset flag at start of setup + + #ifndef SIM + debugln(F("ACTUAL GPS SETUP")); + + // gpsStream wraps GPS_SERIAL: before the timer starts, reads pass + // through directly so myGNSS.begin() can communicate with the module. + if (!gpsSetupProbe(/*coldRetry=*/true)) { + debugln(F("ERROR: GPS not detected at any baud rate!")); + return; // Leave gpsInitialized = false; GPS_STATUS_RETRY_LOOP may re-probe + } + + // Apply config at the current mode targets (boot = status mode: + // 5 Hz + NAV-SAT for the GPS status page) and register callbacks. + GPS_RECONFIGURE(); + gpsRegisterCallbacks(); + + gpsInitialized = true; + debugln(F("GPS initialized successfully (SparkFun UBX PVT)")); + + // Drain any remaining bytes from Serial1 into our buffer, then + // start the timer ISR for continuous background serial drain. + while (GPS_SERIAL.available()) { + uint16_t nextHead = (gpsRxHead + 1) % GPS_RX_BUF_SIZE; + if (nextHead == gpsRxTail) break; + gpsRxBuf[gpsRxHead] = GPS_SERIAL.read(); + gpsRxHead = nextHead; + } + startGpsSerialTimer(); + + // Arm the PVT-arrival watchdog for boot too: a begin() ping proves + // the module answers, not that PVT flows. If nothing arrives within + // 5 s, GPS_LOOP() runs GPS_BAUD_RECOVERY() — the "make sure we are + // actually connected" check for a module in a weird state. + gpsWakeTime = millis(); + gpsWakeValidated = false; + #else + // Simulator placeholder: no u-blox module to probe. The sim host + // injects PVT data by calling onPVTReceived() directly, so there is + // no serial/library setup to do here. + debugln(F("SIM GPS SETUP")); + gpsInitialized = true; + #endif +} + +// Bounded background re-detect for a GPS that failed GPS_SETUP(). +// Called only while the GPS status page is up — each attempt blocks the +// UI ~2.5 s, so it's capped and spaced out. After the retries are spent +// the page shows a permanent "CHECK WIRING" and the device stays usable +// (race mode degrades gracefully without GPS). +static uint8_t gpsSetupRetryCount = 0; +static unsigned long gpsSetupLastRetry = 0; +#define GPS_SETUP_MAX_RETRIES 3 +#define GPS_SETUP_RETRY_INTERVAL_MS 10000 + +bool gpsRetriesExhausted() { + return !gpsInitialized && gpsSetupRetryCount >= GPS_SETUP_MAX_RETRIES; +} + +void GPS_STATUS_RETRY_LOOP() { + #ifndef SIM + if (gpsInitialized) return; + if (gpsSetupRetryCount >= GPS_SETUP_MAX_RETRIES) return; + if (millis() - gpsSetupLastRetry < GPS_SETUP_RETRY_INTERVAL_MS) return; + gpsSetupLastRetry = millis(); + gpsSetupRetryCount++; + debug(F("GPS re-detect attempt ")); + debugln(gpsSetupRetryCount); + + if (!gpsSetupProbe(/*coldRetry=*/false)) return; + + GPS_RECONFIGURE(); + gpsRegisterCallbacks(); + gpsInitialized = true; + gpsRxHead = 0; + gpsRxTail = 0; + startGpsSerialTimer(); + gpsWakeTime = millis(); + gpsWakeValidated = false; + debugln(F("GPS re-detect succeeded")); + #endif +} + +void GPS_LOOP() { + // Safety check: skip if GPS not initialized + if (!gpsInitialized) { + return; + } + + // Process incoming UBX bytes and fire onPVTReceived() callback if a + // complete PVT message has arrived. The callback populates gpsData + // and sets gpsDataFresh = true. + myGNSS.checkUblox(); + myGNSS.checkCallbacks(); + + // PVT arrival watchdog: after GPS_SETUP() or GPS_WAKE(), if no PVT data + // arrives within 5 seconds, the module likely lost its config (V_BCKP + // dropped → reverted to 9600 baud NMEA, or answered the begin() ping in + // some odd state). Attempt baud recovery. + if (!gpsWakeValidated) { + if (gpsDataFresh) { + // PVT arrived — module is alive and configured correctly + gpsWakeValidated = true; + debugln(F("GPS validated: PVT received")); + } else if (millis() - gpsWakeTime >= 5000) { + debugln(F("GPS validation FAILED: no PVT after 5s, attempting recovery")); + if (GPS_BAUD_RECOVERY()) { + // Recovery succeeded — restart the watchdog for validation + gpsWakeTime = millis(); + debugln(F("GPS recovery succeeded, waiting for PVT...")); + } else { + // Recovery failed — give up, mark validated to stop retrying + gpsWakeValidated = true; + debugln(F("GPS recovery FAILED — GPS unavailable this session")); + } + } + } + + if (gpsDataFresh) { + gpsDataFresh = false; + + // Feed fresh GPS data into the active course/timer. courseManager and + // sprintTimer are mutually exclusive by construction (mode follows the + // detected track's folder) — exactly one branch runs per session. + if (gpsData.fix && courseManager != nullptr) { + double ltLat = gpsData.latitudeDegrees; + double ltLng = gpsData.longitudeDegrees; + double ltAlt = gpsData.altitude; + double ltSpeed = gpsData.speed; + + courseManager->updateCurrentTime(getGpsTimeInMilliseconds()); + courseManager->loop(ltLat, ltLng, ltAlt, ltSpeed); + } else if (gpsData.fix && sprintTimer != nullptr) { + sprintTimer->updateCurrentTime(getGpsTimeInMilliseconds()); + sprintTimer->loop(gpsData.latitudeDegrees, gpsData.longitudeDegrees, + gpsData.altitude, gpsData.speed); + } + + #ifdef SD_CARD_LOGGING_ENABLED + // Determine if logging conditions are met. + // File creation requires a VALID GPS time lock (validDate+validTime+ + // fullyResolved), not merely a non-zero day. Before the module resolves + // real time it emits a placeholder date; creating a file from it produced + // garbage-named logs (e.g. 20210307_0000.dovex) that collided every boot + // and corrupted on reboot. With a real lock the module keeps time across + // the V_BCKP backup, so this still fires within ~1 s of a warm wake. + // While we wait, updateGpsLockHold() pins the user to the tachometer. + // Data writing still requires gpsData.fix for valid coordinates. + bool canWriteData = gpsData.fix && sdSetupSuccess && enableLogging && sdDataLogInitComplete; + bool canCreateFile = sdSetupSuccess && enableLogging && !sdDataLogInitComplete && gpsData.timeValid; + + if (canWriteData) { + + ///////////////////////////////////////////////////////////////// + // Log every PVT update (~25Hz) + + // Snapshot GPS values for logging + const double snapLat = gpsData.latitudeDegrees; + const double snapLng = gpsData.longitudeDegrees; + const double snapAlt = gpsData.altitude; + const double snapHdop = gpsData.HDOP; + const double snapSpeed = gpsData.speed; // knots, for sanity + const int snapSats = gpsData.satellites; + const double snapSpeedMph = snapSpeed * 1.15078; + + // Reject obviously-corrupt samples (sats=0, Null Island, NaN/Inf, + // out-of-range lat/lng/alt/hdop/speed). The rule set lives in + // gps_validation::isSampleValid and is exercised by host tests. + const gps_validation::GpsSample snapForCheck = { + snapLat, snapLng, snapAlt, snapHdop, snapSpeedMph, snapSats}; + if (!gps_validation::isSampleValid(snapForCheck)) { + // Skip this sample - data is not trustworthy + } else { + char csvLine[256]; + char latStr[24], lngStr[24], hdopStr[12], speedStr[16], altStr[16]; + char headingStr[12], hAccStr[12]; + + dtostrf(snapLat, 1, 8, latStr); + dtostrf(snapLng, 1, 8, lngStr); + dtostrf(snapHdop, 1, 1, hdopStr); + dtostrf(snapSpeedMph, 1, 2, speedStr); + dtostrf(snapAlt, 1, 2, altStr); + dtostrf(gpsData.heading, 1, 2, headingStr); + dtostrf(gpsData.horizontalAccuracy, 1, 2, hAccStr); + + char accelXStr[12], accelYStr[12], accelZStr[12]; + dtostrf(accelX, 1, 3, accelXStr); + dtostrf(accelY, 1, 3, accelYStr); + dtostrf(accelZ, 1, 3, accelZStr); + + // 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 + // visible gap, never a held flat line; a v1 egg logs Temp2 as + // "nan" every row. These fields must NEVER cause the GPS row to + // be skipped, so they are checked here (falling back to "nan") + // instead of joining the strs[] reject-the-row walk below. + // isNanF, not isnan: -Ofast folds isnan() to false. (These + // columns previously survived that only because dtostrf(NaN) + // emits "nan" and isNumericString rejects it into the same + // fallback - luck, not design.) + char temp1Str[12], junc1Str[12], temp2Str[12]; + const float snapEgtC = sensoreggEgtC(); + const float snapJuncC = sensoreggJunctionC(); + const float snapAuxC = sensoreggAuxC(); + if (isNanF(snapEgtC)) { + strcpy(temp1Str, "nan"); + } else { + dtostrf(snapEgtC, 1, 1, temp1Str); + if (!gps_validation::isNumericString(temp1Str, sizeof(temp1Str) - 1)) { + strcpy(temp1Str, "nan"); + } + } + if (isNanF(snapJuncC)) { + strcpy(junc1Str, "nan"); + } else { + dtostrf(snapJuncC, 1, 1, junc1Str); + if (!gps_validation::isNumericString(junc1Str, sizeof(junc1Str) - 1)) { + strcpy(junc1Str, "nan"); + } + } + if (isNanF(snapAuxC)) { + strcpy(temp2Str, "nan"); + } else { + dtostrf(snapAuxC, 1, 1, temp2Str); + if (!gps_validation::isNumericString(temp2Str, sizeof(temp2Str) - 1)) { + strcpy(temp2Str, "nan"); + } + } + + // dtostrf() can produce garbage (empty, too-long, non-numeric) on + // some BSPs when given NaN/Inf even though the sample passed + // validation. Walk every formatted string and reject the row if + // any look wrong. + bool stringsValid = true; + const char* strs[] = {latStr, lngStr, hdopStr, speedStr, altStr, + headingStr, hAccStr, accelXStr, accelYStr, accelZStr}; + for (size_t i = 0; i < sizeof(strs)/sizeof(strs[0]) && stringsValid; i++) { + if (!gps_validation::isNumericString(strs[i], 20)) { + stringsValid = false; + } + } + + if (!stringsValid) { + // dtostrf produced garbage - skip this entry silently + } else { + // Arduino lacks %llu in printf; format the 64-bit timestamp + // ourselves into a stack buffer, then splice via %s. + char timestampStr[24]; + gps_time::u64ToDecimalString(getGpsUnixTimestampMillis(), + timestampStr, sizeof(timestampStr)); + + 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); + + size_t written = dataFile.println(csvLine); + if (written == 0) { + // A write failed mid-session. Don't fault — keep racing so lap + // timing / RPM / display stay live; just stop logging for this + // session (closing cleanly rather than truncating-and-restarting + // the same-minute filename). + debugln(F("SD write failed - stopping logging, race continues")); + enableLogging = false; + sdDataLogInitComplete = false; + // Try to salvage the session's lap times / metadata into the + // reserved header before closing. A write failure here usually + // means a flaky card, so this may also fail — but when it + // succeeds the lap list survives instead of being lost with the + // session. writeDovexHeader() no-ops if the file isn't open. + writeDovexHeader(); + dataFile.close(); + releaseSDAccess(SD_ACCESS_LOGGING); + } + } + } + ///////////////////////////////////////////////////////////////// + + // Flush periodically to avoid data loss - every 10 seconds + if (millis() - lastCardFlush > 10000) { + lastCardFlush = millis(); + dataFile.flush(); + } + } else if (canCreateFile && millis() - lastLogCreateAttempt >= 1000) { + // Throttle open attempts to once per second so a problem card can't + // churn SPI at 25 Hz. We never fault out of race mode here — if the + // file can't be created we simply keep waiting (the user stays on the + // tachometer via the GPS-lock hold) and retry next second. + lastLogCreateAttempt = millis(); + debugln(F("Attempt to initialize logfile")); + + if (!acquireSDAccess(SD_ACCESS_LOGGING)) { + // Another subsystem holds the card (BLE/replay). Don't fault — just + // retry on the next throttled pass once it releases. + debugln(F("Cannot start logging - SD card busy, will retry")); + } else { + char dataFileName[80]; + + // DOVEX filename: 20YYMMDD_HHMM.dovex + snprintf(dataFileName, sizeof(dataFileName), + "20%02d%02d%02d_%02d%02d.dovex", + gpsData.year, gpsData.month, gpsData.day, + gpsData.hour, gpsData.minute); + + debug(F("dataFileName: [")); + debug(dataFileName); + debugln(F("]")); + + dataFile.open(dataFileName, O_CREAT | O_WRITE | O_TRUNC); + + if (!dataFile) { + // Open failed — do NOT fault out of race mode. Release the card and + // leave logging pending; updateGpsLockHold() keeps the user on the + // tachometer and we retry on the next throttled pass. + debugln(F("Error opening log file - will retry")); + releaseSDAccess(SD_ACCESS_LOGGING); + } else { + // DOVEX: pre-fill header region with newlines so the FAT clusters + // for it are allocated up front; the metadata header is written + // back into this region at session end. + char padBuf[64]; + memset(padBuf, '\n', sizeof(padBuf)); + bool prefillOk = true; + for (uint32_t i = 0; i < DOVEX_HEADER_SIZE; i += sizeof(padBuf)) { + uint32_t toWrite = min((uint32_t)sizeof(padBuf), DOVEX_HEADER_SIZE - i); + // Verify each write landed. On a card dropping sectors mid-init, + // an unchecked short write would leave a truncated header region + // but we'd still mark logging ready and stream rows into garbage. + if (dataFile.write(padBuf, toWrite) != toWrite) { + prefillOk = false; + break; + } + } + if (!prefillOk) { + debugln(F("Header pre-fill write failed - aborting log init, will retry")); + dataFile.close(); + releaseSDAccess(SD_ACCESS_LOGGING); + } else { + // Cursor is now at exactly DOVEX_HEADER_SIZE + 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")); + debugln(F("CSV header written")); + sdDataLogInitComplete = true; + } + } + } + } + #endif + + // Update display speed with fresh PVT data + gps_speed_mph = gpsData.speed * 1.15078; + } // end if (gpsDataFresh) +} + +void GPS_SLEEP() { + if (!gpsInitialized) return; + stopGpsSerialTimer(); // Stop serial drain ISR during sleep (saves power) + myGNSS.powerOff(0); // 0 = indefinite sleep until woken +} + +/** + * @brief Re-apply all GPS module configuration via VALSET. + * + * The SAM-M10Q has no flash — all config lives in volatile RAM. If V_BCKP + * drops during backup mode (battery sag, loose connector, cranking brownout), + * the module reverts to factory defaults (9600 baud, NMEA, 1Hz). This + * function re-applies the full configuration at the CURRENT mode targets + * (gpsNavRateTarget / gpsNavSatWanted) so wake/recovery paths re-assert + * whatever mode we're actually in instead of clobbering it. It's fast + * (~50ms total) and idempotent — safe to call even if config was retained. + */ +void GPS_RECONFIGURE() { + // The stream restarts around a reconfigure (wake/recovery paths) — + // discard the frame-rate window in progress so the gap isn't + // miscounted as dropped frames. + gps_stats::noteRateChange(gpsDropMonitor); + // This runs under the armed ~4 s WDT when called from GPS_BAUD_RECOVERY + // or GPS_WAKE. Each call below is a VALSET + ACK wait that can block up + // to ~1.1 s on a module that answers the connection ping but responds + // slowly (cold acquisition, marginal wiring) — 8 of them back-to-back + // is a guaranteed watchdog reset. Pet between every exchange. + wdtPet(); + myGNSS.setUART1Output(COM_TYPE_UBX); + wdtPet(); + myGNSS.setNavigationFrequency(gpsNavRateTarget); + wdtPet(); + myGNSS.setDynamicModel(DYN_MODEL_AUTOMOTIVE); + wdtPet(); + myGNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GPS); + wdtPet(); + myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_SBAS); + wdtPet(); + myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_GALILEO); + wdtPet(); + myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_BEIDOU); + wdtPet(); + myGNSS.enableGNSS(false, SFE_UBLOX_GNSS_ID_GLONASS); + wdtPet(); + if (!gpsNavSatWanted) { + myGNSS.setAutoNAVSAT(false); // no periodic NAV-SAT output in race mode + wdtPet(); + } + debugln(F("GPS config re-applied (VALSET)")); +} + +/** + * @brief Switch to status mode: GPS_NAV_RATE_STATUS_HZ nav solutions with + * NAV-SAT output for the GPS status page's signal bars. This is + * also the boot default (the targets initialize to it). + */ +void gpsEnterStatusMode() { + gpsNavRateTarget = GPS_NAV_RATE_STATUS_HZ; + gpsNavSatWanted = true; + gps_stats::noteRateChange(gpsDropMonitor); + if (!gpsInitialized) return; + myGNSS.setNavigationFrequency(gpsNavRateTarget); + myGNSS.setAutoNAVSATcallbackPtr(&onNAVSATReceived); + myGNSS.setAutoNAVSATrate(GPS_NAV_RATE_STATUS_HZ); // ~1 Hz NAV-SAT frames + debugln(F("GPS status mode (5Hz + NAV-SAT)")); +} + +/** + * @brief Switch to race mode: GPS_NAV_RATE_HZ PVT-only. Called when the + * GPS status page exits — this is the steady state for the menu + * and racing. + */ +void gpsEnterRaceMode() { + gpsNavRateTarget = GPS_NAV_RATE_HZ; + gpsNavSatWanted = false; + gps_stats::noteRateChange(gpsDropMonitor); + gpsSatCnoCount = 0; // stale bars must not outlive the mode + gpsSatUsedCount = 0; + gpsSatTrackedCount = 0; + if (!gpsInitialized) return; + myGNSS.setAutoNAVSAT(false); + myGNSS.setNavigationFrequency(gpsNavRateTarget); + debugln(F("GPS race mode (25Hz PVT-only)")); +} + +/** + * @brief Attempt to recover GPS communication when module has reverted to 9600 baud. + * + * If V_BCKP was lost during backup, the module reverts to 9600 baud. + * Our UART is at 57600 so communication is broken. This function: + * 1. Stops the serial timer (switch to direct Serial1 mode) + * 2. Tries to talk at 57600 first (maybe module is fine) + * 3. If that fails, switches to 9600, sends baud change command, switches back + * 4. Re-applies full config and restarts the serial timer + * + * @return true if communication was recovered, false if unrecoverable + */ +bool GPS_BAUD_RECOVERY() { + debugln(F("GPS baud recovery: attempting...")); + + // This path runs from GPS_LOOP() under the armed ~4 s hardware watchdog, + // and it is the slowest thing in the firmware short of the OTA apply. + // A failing myGNSS.begin() is 3 internal ping retries with no pet + // opportunity in between, so every probe uses GPS_PROBE_MAXWAIT_MS + // (3 x 550 ms = ~1.65 s worst case) and is bracketed by pets — against + // a genuinely hung module that out-waits the WDT → reset → re-setup → + // recovery → reset boot loop, the one path built to revive a sick GPS + // must not trip the watchdog. + wdtPet(); + + // Stop timer so GpsBufferedStream passes through to Serial1 directly. + // The SparkFun library needs direct serial access for begin()/setSerialRate(). + stopGpsSerialTimer(); + + // First: try at current baud — module might be fine, just slow to start + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + wdtPet(); + debugln(F("GPS baud recovery: module responding at 57600")); + GPS_RECONFIGURE(); // pets internally per VALSET exchange + gpsRegisterCallbacks(); // begin() resets library state; pets internally + gpsRxHead = 0; + gpsRxTail = 0; + startGpsSerialTimer(); + return true; + } + + // Module not responding at 57600 — try 9600 (factory default) + debugln(F("GPS baud recovery: trying 9600...")); + wdtPet(); // the first begin() just burned ~1.65 s of the 4 s budget + GPS_SERIAL.end(); + delay(50); + GPS_SERIAL.begin(9600); + delay(100); + + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + // Found at 9600 — switch it back to 57600 + debugln(F("GPS baud recovery: found at 9600, switching to 57600")); + wdtPet(); // second begin() done; baud switch + final probe still ahead + myGNSS.setSerialRate(GPS_BAUD_RATE); + delay(100); + GPS_SERIAL.end(); + delay(50); + GPS_SERIAL.begin(GPS_BAUD_RATE); + delay(100); + wdtPet(); + + if (myGNSS.begin(gpsStream, GPS_PROBE_MAXWAIT_MS)) { + wdtPet(); + debugln(F("GPS baud recovery: reconnected at 57600")); + GPS_RECONFIGURE(); + gpsRegisterCallbacks(); + gpsRxHead = 0; + gpsRxTail = 0; + startGpsSerialTimer(); + return true; + } + debugln(F("GPS baud recovery: lost after baud switch!")); + } else { + debugln(F("GPS baud recovery: not found at 9600 either")); + } + + // Restore 57600 even on failure so other code doesn't break + wdtPet(); + GPS_SERIAL.end(); + delay(50); + GPS_SERIAL.begin(GPS_BAUD_RATE); + delay(50); + gpsRxHead = 0; + gpsRxTail = 0; + startGpsSerialTimer(); + return false; +} + +void GPS_WAKE() { + if (!gpsInitialized) return; + + // Clear stale state from before sleep / periodic checks + gpsDataFresh = false; + gpsData.fix = false; + + // Any UART activity on RX wakes u-blox from powerOff backup mode + GPS_SERIAL.write(0xFF); + delay(100); + + // Reset buffer pointers (stale data from before sleep is useless) + gpsRxHead = 0; + gpsRxTail = 0; + + startGpsSerialTimer(); // Resume serial drain ISR + + // Re-apply configuration in case module lost RAM during backup. + // This is idempotent — if config was retained, these are no-ops. + GPS_RECONFIGURE(); + + myGNSS.checkUblox(); + + // Start PVT arrival watchdog. GPS_LOOP() will validate within 5 seconds, + // and trigger baud recovery if no PVT data arrives. + gpsWakeTime = millis(); + gpsWakeValidated = false; +} + +void calculateGPSFrameRate() { + // calculate actual GPS fix frequency + gpsFrameEndTime = millis(); + // Check if the update interval has passed + if (gpsFrameEndTime - gpsFrameStartTime >= 1000) { + unsigned long elapsed = gpsFrameEndTime - gpsFrameStartTime; + // Calculate the frame rate (loops per second) + gpsFrameRate = (float)gpsFrameCounter / (elapsed / 1000.0); + // Feed the drop accounting: expected-vs-received against the + // current nav-rate target (window math in the gps_stats pure unit). + gps_stats::windowUpdate(gpsDropMonitor, gpsFrameCounter, elapsed, + gpsNavRateTarget); + // Reset the loop counter and start time for the next interval + gpsFrameCounter = 0; + gpsFrameStartTime = millis(); + } +} + +// Serial-pipeline health accessors (GPS debug page + GPS stats page). +uint32_t gpsStatsDroppedPvt() { return gpsDropMonitor.droppedTotal; } +uint32_t gpsStatsRingFullEvents() { return gpsRingFullEvents; } +uint32_t gpsStatsIsrLatencyMaxUs() { return gpsIsrLatencyMaxUs; } +uint16_t gpsStatsDrainMaxBytes() { return gpsDrainMaxBytes; } +uint32_t gpsStatsCoreSatEvents() { return gpsCoreSatEvents; } diff --git a/BirdsEye/gps_status_page.cpp b/BirdsEye/gps_status_page.cpp index 77f3d68..a33e9f9 100644 --- a/BirdsEye/gps_status_page.cpp +++ b/BirdsEye/gps_status_page.cpp @@ -9,6 +9,15 @@ void begin(State& s, uint32_t nowMs) { s.lockArmed = false; } +TimeSync timeSyncState(bool dateTimeValid, bool fullyResolved) { + if (!dateTimeValid) return TimeSync::kNoDateTime; + // fullyResolved without date/time is not a state the module produces, + // but ordering the checks this way means the page reports the EARLIER + // outstanding milestone if it ever did. + if (!fullyResolved) return TimeSync::kResolving; + return TimeSync::kLocked; +} + uint32_t countdownSecondsLeft(const State& s, uint32_t nowMs) { if (!s.lockArmed) return 0; const uint32_t elapsed = nowMs - s.lockSinceMs; diff --git a/BirdsEye/gps_status_page.h b/BirdsEye/gps_status_page.h index 4def2cf..ca23199 100644 --- a/BirdsEye/gps_status_page.h +++ b/BirdsEye/gps_status_page.h @@ -52,6 +52,34 @@ struct State { bool lockArmed = false; // countdown running }; +/////////////////////////////////////////// +// TIME-SYNC PROGRESS (renderer hint) +// +// A position fix and a usable clock are separate milestones, and the +// second is much slower: `fullyResolved` needs the UTC/leap-second +// parameters, which the receiver decodes from the GPS navigation +// message — subframe 4 page 18, repeating every ~12.5 minutes. A clean +// 3D fix in under a minute followed by several more minutes of no +// timeValid is normal on a cold start, and weak signal makes it worse +// (tracking a satellite is a far lower bar than decoding its data bits +// without errors). +// +// The page used to collapse all of that into "FIX (time sync)", which +// reads as a fix *type* rather than "fix acquired, clock pending" — so +// a perfectly healthy device looked broken and got power-cycled, which +// restarts the 12.5-minute clock and makes it strictly worse. This +// classifier exists so the page can say which milestone is outstanding. +/////////////////////////////////////////// +enum class TimeSync : uint8_t { + kNoDateTime, // module hasn't asserted validDate+validTime yet + kResolving, // date/time present, waiting on fullyResolved (the slow one) + kLocked, // all three — gpsData.timeValid is true +}; + +// Which time milestone is outstanding. `dateTimeValid` is validDate AND +// validTime; `fullyResolved` is the UTC-resolution bit. +TimeSync timeSyncState(bool dateTimeValid, bool fullyResolved); + // Countdown progress for the renderer: seconds remaining (1..3) while // the auto-close countdown is armed, 0 when it is not. uint32_t countdownSecondsLeft(const State& s, uint32_t nowMs); diff --git a/BirdsEye/images.h b/BirdsEye/images.h index 4161c2f..0567c2f 100644 --- a/BirdsEye/images.h +++ b/BirdsEye/images.h @@ -1,205 +1,77 @@ -#ifndef _DOVES_IMAGES_H -#define _DOVES_IMAGES_H - - static const unsigned char PROGMEM image_data_calculating1[] = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, - 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff - }; - - static const unsigned char PROGMEM image_data_calculating2[] = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00 - }; - - static const unsigned char PROGMEM image_data_bird1[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x73, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc1, 0xf2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xf2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0xff, 0xf4, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xf5, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x3f, 0xff, 0xf1, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x71, 0xff, 0xff, 0xe2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x67, 0xff, 0xff, 0xe6, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfd, 0x0f, 0xff, 0xff, 0xfe, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfd, 0x3f, 0xff, 0xff, 0xfe, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x07, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xdb, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xef, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xd7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xff, 0xff, 0x83, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xf3, 0x7f, 0xff, 0xfe, 0x03, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xf6, 0x1f, 0xff, 0xf8, 0x0f, 0xff, 0xff, 0xfc, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xe7, 0x0f, 0xff, 0xe0, 0x3f, 0xff, 0xff, 0xfc, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xef, 0x81, 0xff, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xef, 0xc0, 0x7e, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xcf, 0xe0, 0x7f, 0x01, 0xff, 0xff, 0xfc, 0xfe, 0x7f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xdf, 0xf0, 0xff, 0x87, 0xc0, 0x7f, 0xf0, 0xfe, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xd8, 0x3f, 0xff, 0xfe, 0x07, 0x7f, 0xe5, 0xfc, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xda, 0x03, 0xff, 0xf0, 0x07, 0x7f, 0xfb, 0xff, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xda, 0x00, 0xff, 0xe4, 0x07, 0x7f, 0xe7, 0xff, 0xc7, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xda, 0x04, 0x07, 0xe6, 0x0e, 0x7f, 0xe7, 0xff, 0xf1, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x1b, 0x0c, 0x23, 0xf7, 0x1c, 0xff, 0xf9, 0xff, 0xfd, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0x39, 0xf8, 0xf9, 0xf3, 0xf8, 0xff, 0xfc, 0xff, 0xfb, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf9, 0xfc, 0xf1, 0xfc, 0x78, 0xf1, 0xff, 0xfe, 0xff, 0xf3, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfb, 0xfe, 0x07, 0xfe, 0x3c, 0x03, 0xff, 0xfe, 0xff, 0xe7, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf0, 0x7f, 0x0f, 0xff, 0x9f, 0x9f, 0xff, 0xff, 0x7f, 0xcf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf0, 0x7f, 0x1f, 0xff, 0xcf, 0xff, 0xff, 0xff, 0x7f, 0xdf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf9, 0xfe, 0x3f, 0xff, 0xe7, 0xff, 0xff, 0xff, 0x7f, 0xbf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf9, 0xfc, 0xff, 0xff, 0xf3, 0xff, 0xff, 0xff, 0x3f, 0x3f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf3, 0xf9, 0xff, 0xff, 0xf9, 0xff, 0xff, 0xff, 0xbc, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xf7, 0xfb, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xf7, 0xbe, 0x7f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe7, 0xf3, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xf1, 0xbf, 0x3f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe7, 0xf7, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xf0, 0x7f, 0x9f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe7, 0xf7, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xfa, 0x7f, 0xcf, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xef, 0xf7, 0xff, 0xff, 0xff, 0xe7, 0xff, 0xf3, 0xff, 0xef, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe9, 0xf7, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xf3, 0xff, 0xe7, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe1, 0xf7, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xf7, 0xff, 0xf7, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xe5, 0xf3, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xf7, 0xff, 0xe7, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfd, 0xf3, 0xff, 0xe0, 0xe0, 0x1d, 0xff, 0xef, 0xff, 0x0f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfd, 0xfb, 0xff, 0xcc, 0x03, 0x0d, 0xff, 0xcf, 0xf8, 0x67, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0xf9, 0xff, 0x9f, 0xff, 0xdd, 0xff, 0xdf, 0xff, 0xe3, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0xfd, 0xff, 0x3f, 0xff, 0xfc, 0xff, 0xbf, 0xff, 0xf8, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfe, 0xfc, 0xfe, 0x7f, 0x80, 0x39, 0xfe, 0x7f, 0xff, 0xfe, 0x7f, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfe, 0x7e, 0x7c, 0xfc, 0x1f, 0x81, 0xfe, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0x7f, 0x18, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x8f, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0x3f, 0x83, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfc, 0x3f, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xff - }; - +#ifndef _DOVES_IMAGES_H +#define _DOVES_IMAGES_H + +// Only real artwork lives here. The two "calculating" crossing frames used +// to sit alongside the bird as hand-stored 1 KB bitmaps, but they were pure +// 16 px block patterns — they are now generated at draw time by the +// host-tested crossing_pattern unit (2048 B of flash reclaimed, output +// proven byte-identical by crossing_pattern_test.cpp). + + static const unsigned char PROGMEM image_data_bird1[] = { + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x73, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc1, 0xf2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xf2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0xff, 0xf4, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xf5, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x3f, 0xff, 0xf1, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x71, 0xff, 0xff, 0xe2, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x67, 0xff, 0xff, 0xe6, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfd, 0x0f, 0xff, 0xff, 0xfe, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfd, 0x3f, 0xff, 0xff, 0xfe, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x07, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xdb, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xef, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xd7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xff, 0xff, 0x83, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xf3, 0x7f, 0xff, 0xfe, 0x03, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xf6, 0x1f, 0xff, 0xf8, 0x0f, 0xff, 0xff, 0xfc, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xe7, 0x0f, 0xff, 0xe0, 0x3f, 0xff, 0xff, 0xfc, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xef, 0x81, 0xff, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xef, 0xc0, 0x7e, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xcf, 0xe0, 0x7f, 0x01, 0xff, 0xff, 0xfc, 0xfe, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xdf, 0xf0, 0xff, 0x87, 0xc0, 0x7f, 0xf0, 0xfe, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xd8, 0x3f, 0xff, 0xfe, 0x07, 0x7f, 0xe5, 0xfc, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xda, 0x03, 0xff, 0xf0, 0x07, 0x7f, 0xfb, 0xff, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xda, 0x00, 0xff, 0xe4, 0x07, 0x7f, 0xe7, 0xff, 0xc7, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xda, 0x04, 0x07, 0xe6, 0x0e, 0x7f, 0xe7, 0xff, 0xf1, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x1b, 0x0c, 0x23, 0xf7, 0x1c, 0xff, 0xf9, 0xff, 0xfd, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0x39, 0xf8, 0xf9, 0xf3, 0xf8, 0xff, 0xfc, 0xff, 0xfb, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf9, 0xfc, 0xf1, 0xfc, 0x78, 0xf1, 0xff, 0xfe, 0xff, 0xf3, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfb, 0xfe, 0x07, 0xfe, 0x3c, 0x03, 0xff, 0xfe, 0xff, 0xe7, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf0, 0x7f, 0x0f, 0xff, 0x9f, 0x9f, 0xff, 0xff, 0x7f, 0xcf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf0, 0x7f, 0x1f, 0xff, 0xcf, 0xff, 0xff, 0xff, 0x7f, 0xdf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf9, 0xfe, 0x3f, 0xff, 0xe7, 0xff, 0xff, 0xff, 0x7f, 0xbf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf9, 0xfc, 0xff, 0xff, 0xf3, 0xff, 0xff, 0xff, 0x3f, 0x3f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf3, 0xf9, 0xff, 0xff, 0xf9, 0xff, 0xff, 0xff, 0xbc, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf7, 0xfb, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xf7, 0xbe, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe7, 0xf3, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xf1, 0xbf, 0x3f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe7, 0xf7, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xf0, 0x7f, 0x9f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe7, 0xf7, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xfa, 0x7f, 0xcf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xef, 0xf7, 0xff, 0xff, 0xff, 0xe7, 0xff, 0xf3, 0xff, 0xef, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe9, 0xf7, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xf3, 0xff, 0xe7, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe1, 0xf7, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xf7, 0xff, 0xf7, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe5, 0xf3, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xf7, 0xff, 0xe7, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfd, 0xf3, 0xff, 0xe0, 0xe0, 0x1d, 0xff, 0xef, 0xff, 0x0f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfd, 0xfb, 0xff, 0xcc, 0x03, 0x0d, 0xff, 0xcf, 0xf8, 0x67, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0xf9, 0xff, 0x9f, 0xff, 0xdd, 0xff, 0xdf, 0xff, 0xe3, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0xfd, 0xff, 0x3f, 0xff, 0xfc, 0xff, 0xbf, 0xff, 0xf8, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfe, 0xfc, 0xfe, 0x7f, 0x80, 0x39, 0xfe, 0x7f, 0xff, 0xfe, 0x7f, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfe, 0x7e, 0x7c, 0xfc, 0x1f, 0x81, 0xfe, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0x7f, 0x18, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x8f, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0x3f, 0x83, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0x3f, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0xff, 0xff + }; + #endif \ No newline at end of file diff --git a/BirdsEye/nan_bits.h b/BirdsEye/nan_bits.h new file mode 100644 index 0000000..44200bd --- /dev/null +++ b/BirdsEye/nan_bits.h @@ -0,0 +1,32 @@ +#pragma once + +/////////////////////////////////////////// +// NaN DETECTION THAT SURVIVES -Ofast +// +// The Seeed nRF52 platform compiles sketches with -Ofast, which +// includes -ffinite-math-only: GCC constant-folds isnan()/isinf() to +// FALSE and assumes NaN never exists in comparisons. Field-confirmed +// on the DovesSensorEgg (2026-07-27), and this repo has the same +// exposure: the Temp1 page's isnan() gates fold out, so a stale egg +// link rendered lroundf(NaN) garbage ("-214748") instead of '---'. +// (The DOVEX temp columns survived by luck alone - dtostrf(NaN) emits +// "nan", which the isNumericString guard then rejects into the same +// literal "nan" the isnan branch would have written.) Host builds +// don't use -Ofast, so the host test suite cannot see any of this. +// +// This helper inspects the IEEE-754 bit pattern via memcpy, which the +// optimizer cannot fold away: exponent all-ones + nonzero mantissa. +// RULE: device-compiled code (the .ino and every module it links) must +// use isNanF() and must never compare against a possibly-NaN value +// without checking it first. Plain isnan() is reserved for host-only +// code. +/////////////////////////////////////////// + +#include +#include + +static inline bool isNanF(float f) { + uint32_t u; + memcpy(&u, &f, sizeof u); + return (u & 0x7F800000u) == 0x7F800000u && (u & 0x007FFFFFu) != 0u; +} diff --git a/BirdsEye/project.h b/BirdsEye/project.h index bef1f10..625db39 100644 --- a/BirdsEye/project.h +++ b/BirdsEye/project.h @@ -170,6 +170,13 @@ inline void dummy_debug(...) { #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 @@ -218,17 +225,38 @@ struct TrackLayout { 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 and a representative lat/lon from the first course. +// 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 }; /////////////////////////////////////////// @@ -239,6 +267,8 @@ struct TrackMetadata { 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/sd_functions.h b/BirdsEye/sd_functions.h index 5921af4..baa788d 100644 --- a/BirdsEye/sd_functions.h +++ b/BirdsEye/sd_functions.h @@ -7,6 +7,9 @@ // access mutex to avoid corrupting SdFat's internal state. /////////////////////////////////////////// +#include + +#include "course_creator.h" // CreatedCourseWrite carries a walked course #include "sd_access_policy.h" // SD access modes — used by acquireSDAccess / releaseSDAccess. Aliases of @@ -49,9 +52,15 @@ void releaseSDAccess(int mode); // error path forgets to release. void forceReleaseSDAccess(); -// Build "/TRACKS/.json" into the caller's filepath buffer. -// Caller MUST provide at least FILEPATH_MAX bytes. -void makeFullTrackPath(const char* trackName, char* filepath); +// Build "/TRACKS/.json" (TRACK_KIND_CIRCUIT) or +// "/TRACKS/SPRINT/.json" (TRACK_KIND_SPRINT) into the caller's +// filepath buffer. Caller MUST provide at least FILEPATH_MAX bytes. +void makeFullTrackPath(const char* trackName, char* filepath, uint8_t kind); + +// Walk one track folder, appending entries to locations[] and +// trackManifest[] (shared caps) with the given TRACK_KIND_*. Caller must +// hold the SD mutex. Returns false only if the directory can't be opened. +bool scanTrackDir(const char* folder, uint8_t kind); // Initialize the SD card (with EMI-tolerant retries). Returns true // on success. Populates the global SD object. On failure, probes the @@ -87,3 +96,39 @@ bool buildTrackList(); // trackLayouts[]. Auto-detects new (object) vs legacy (bare array) // JSON format. Returns one of the PARSE_STATUS_* codes. int parseTrackFile(char* filepath); + +/////////////////////////////////////////// +// TRACK WRITING (on-device course creator, plan 0002 §5) +/////////////////////////////////////////// + +// Why a course write failed, so the creator can say something more useful +// than "error" on a 128x64 screen. +enum SdCourseWriteResult : uint8_t { + SD_COURSE_WRITE_OK = 0, + SD_COURSE_WRITE_BUSY, // another subsystem holds the card + SD_COURSE_WRITE_NO_TRACK, // append target missing or unparseable + SD_COURSE_WRITE_TOO_BIG, // the course would not fit the 4 KB parse budget + SD_COURSE_WRITE_IO, // open/write/rename failed + SD_COURSE_WRITE_EXISTS, // a track file of that name is already there +}; + +// One walked course, ready to be written. +struct CreatedCourseWrite { + const course_creator::State* course = nullptr; + bool newTrack = false; // create a track file vs append to an existing one + const char* trackName = ""; // file basename, without folder or .json + const char* shortName = ""; // new tracks only + const char* courseName = ""; + const char* dateCreated = ""; // sprint only; "" on circuit courses +}; + +// Write a freshly-walked course to the card. +// +// A NEW track becomes /TRACKS/.json (or /TRACKS/SPRINT/.json) +// holding exactly this course. An APPEND parses the existing file, adds the +// course to its "courses" array, and rewrites it — via a temp file and a +// rename, so a power loss mid-write cannot leave a half-written track file +// where a working one used to be. +// +// Takes the SD mutex itself; the caller must not hold it. +SdCourseWriteResult sdSaveCreatedCourse(const CreatedCourseWrite& req); diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino index f9c2c36..b4ca2d7 100644 --- a/BirdsEye/sd_functions.ino +++ b/BirdsEye/sd_functions.ino @@ -5,6 +5,8 @@ #include "sd_functions.h" +#include "track_json.h" + /** * @brief Attempt to acquire SD card access for a subsystem * @param mode The access mode being requested (SD_ACCESS_*) @@ -58,10 +60,11 @@ void forceReleaseSDAccess() { taskEXIT_CRITICAL(); } -void makeFullTrackPath(const char* trackName, char* filepath) { +void makeFullTrackPath(const char* trackName, char* filepath, uint8_t kind) { // Use snprintf for bounds safety - prevents buffer overflow // Caller MUST provide buffer of at least FILEPATH_MAX bytes - snprintf(filepath, FILEPATH_MAX, "/TRACKS/%s.json", trackName); + const char* folder = (kind == TRACK_KIND_SPRINT) ? trackFolderSprint : trackFolder; + snprintf(filepath, FILEPATH_MAX, "%s/%s.json", folder, trackName); } // (Re)initialize the SD card at a given SPI clock. Re-calling SD.begin() is @@ -121,12 +124,19 @@ bool SD_SETUP() { return false; } -// Make sure /TRACKS exists (blank soldered-in card; SdFat's open() never -// creates parent directories). Caller must already hold the SD mutex. -// Returns true when the folder exists or was created. +// Make sure /TRACKS and /TRACKS/SPRINT exist (blank soldered-in card; +// SdFat's open() never creates parent directories). Caller must already +// hold the SD mutex. Returns true when the circuit folder exists or was +// created — the sprint subfolder is best-effort (a failure there must +// not take out circuit operation). bool sdEnsureTracksFolder() { - if (SD.exists(trackFolder)) return true; - return SD.mkdir(trackFolder); + if (!SD.exists(trackFolder) && !SD.mkdir(trackFolder)) { + return false; + } + if (!SD.exists(trackFolderSprint) && !SD.mkdir(trackFolderSprint)) { + debugln(F("WARNING: could not create /TRACKS/SPRINT")); + } + return true; } /////////////////////////////////////////// @@ -256,12 +266,32 @@ bool buildTrackList() { numOfLocations = 0; trackManifestCount = 0; - // If the TRACKS directory exists, open it - if (!trackDir.open(trackFolder)) { + // Circuit tracks are mandatory (the original folder); the sprint scan is + // best-effort — a missing/unreadable /TRACKS/SPRINT must never take out + // circuit operation. Both share the locations[]/manifest arrays and caps. + if (!scanTrackDir(trackFolder, TRACK_KIND_CIRCUIT)) { debugln(F("Failed to open TRACKS folder.")); releaseSDAccess(SD_ACCESS_TRACK_PARSE); return false; } + scanTrackDir(trackFolderSprint, TRACK_KIND_SPRINT); + + debug(F("Tracks found: ")); + debugln(numOfLocations); + debug(F("Manifest entries: ")); + debugln(trackManifestCount); + + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return true; +} + +// Walk one track folder, appending to locations[] and trackManifest[] +// (shared caps). Caller must hold the SD mutex. Returns false only when +// the directory cannot be opened. +bool scanTrackDir(const char* folder, uint8_t kind) { + if (!trackDir.open(folder)) { + return false; + } // Reset the file to the first position in the directory trackDir.rewind(); @@ -273,6 +303,13 @@ bool buildTrackList() { break; } + // Skip sub-directories (the /TRACKS scan would otherwise list the + // SPRINT folder itself as a "track"). + if (file.isDir()) { + file.close(); + continue; + } + // Create a buffer to store the filename char filename[25]; @@ -319,6 +356,7 @@ bool buildTrackList() { trackManifest[trackManifestCount].filename[sizeof(trackManifest[0].filename) - 1] = '\0'; trackManifest[trackManifestCount].lat = firstLat; trackManifest[trackManifestCount].lon = firstLon; + trackManifest[trackManifestCount].kind = kind; trackManifestCount++; } } @@ -334,13 +372,6 @@ bool buildTrackList() { // Close the directory to free up any memory it's using trackDir.close(); - - debug(F("Tracks found: ")); - debugln(numOfLocations); - debug(F("Manifest entries: ")); - debugln(trackManifestCount); - - releaseSDAccess(SD_ACCESS_TRACK_PARSE); return true; } @@ -422,6 +453,11 @@ int parseTrackFile(char* filepath) { const char* longName = trackJson["longName"] | ""; const char* shortName = trackJson["shortName"] | ""; const char* defaultCourse = trackJson["defaultCourse"] | ""; + // Track-level type marker ("sprint"). Redundant with the folder the + // file lives in — the folder is authoritative — but parsed as cheap + // validation / future-proofing (plan 0002 keeps modes an open enum). + const char* trackType = trackJson["type"] | ""; + activeTrackMetadata.isSprint = (strcasecmp(trackType, "sprint") == 0); strncpy(activeTrackMetadata.longName, longName, sizeof(activeTrackMetadata.longName) - 1); activeTrackMetadata.longName[sizeof(activeTrackMetadata.longName) - 1] = '\0'; @@ -445,6 +481,7 @@ int parseTrackFile(char* filepath) { activeTrackMetadata.longName[0] = '\0'; activeTrackMetadata.shortName[0] = '\0'; activeTrackMetadata.defaultCourse[0] = '\0'; + activeTrackMetadata.isSprint = false; } else { debugln(F("ParseTrackFile: Unknown JSON format")); trackFile.close(); @@ -505,6 +542,27 @@ int parseTrackFile(char* filepath) { trackLayouts[numOfTracks].hasSector3 = false; } + // Sprint-only: separate finish line (optional — same idiom as sectors) + if (layout.containsKey("finish_a_lat") && layout.containsKey("finish_a_lng") && + layout.containsKey("finish_b_lat") && layout.containsKey("finish_b_lng")) { + trackLayouts[numOfTracks].finish_a_lat = layout["finish_a_lat"]; + trackLayouts[numOfTracks].finish_a_lng = layout["finish_a_lng"]; + trackLayouts[numOfTracks].finish_b_lat = layout["finish_b_lat"]; + trackLayouts[numOfTracks].finish_b_lng = layout["finish_b_lng"]; + trackLayouts[numOfTracks].hasFinish = true; + #ifdef HAS_DEBUG + debugln(F(" Finish line data loaded (sprint)")); + #endif + } else { + trackLayouts[numOfTracks].hasFinish = false; + } + + // Sprint-only: sortable ISO date_created (drives newest-course pick) + const char* dateCreated = layout["date_created"] | ""; + strncpy(trackLayouts[numOfTracks].date_created, dateCreated, + sizeof(trackLayouts[numOfTracks].date_created) - 1); + trackLayouts[numOfTracks].date_created[sizeof(trackLayouts[numOfTracks].date_created) - 1] = '\0'; + numOfTracks++; } } @@ -515,3 +573,178 @@ int parseTrackFile(char* filepath) { releaseSDAccess(SD_ACCESS_TRACK_PARSE); return PARSE_STATUS_GOOD; } + +/////////////////////////////////////////// +// TRACK WRITING (on-device course creator, plan 0002 §5) +// +// This is the firmware's first track-file WRITER — until the course +// creator it only ever read them. The JSON text itself is built by the +// host-tested track_json unit; everything here is file plumbing. +/////////////////////////////////////////// + +// Scratch for one serialized course. A course is four coordinate lines at +// ~28 bytes a field plus keys — comfortably under 700 bytes even with all +// four lines and a date stamp. Static, not stack: the sketch keeps big +// buffers out of the main loop's stack (same reason jsonFileBuffer is). +static char courseJsonBuffer[768]; +// A course parsed back into its own document so it can be grafted into the +// existing track. Sized to hold that same one course. +static StaticJsonDocument<768> courseJson; + +/** + * @brief Append a course to an existing track file. + * + * Read-modify-write, the same shape settings.ino uses, with one addition: + * the rewrite goes to a temp file that REPLACES the original only once it + * is safely closed. Writing in place would mean a power loss (or a card + * yank) mid-serialize leaves a truncated file where a working track used + * to be — and this runs in a field, on a battery, at an event. + * + * Caller must hold the SD mutex. + */ +static SdCourseWriteResult appendCourseToTrackFile(const char* filepath, + const char* courseText) { + trackFile.open(filepath, O_READ); + if (!trackFile) { + debugln(F("SaveCourse: append target missing")); + return SD_COURSE_WRITE_NO_TRACK; + } + const int bytesRead = trackFile.read(jsonFileBuffer, sizeof(jsonFileBuffer) - 1); + trackFile.close(); + if (bytesRead <= 0) return SD_COURSE_WRITE_NO_TRACK; + jsonFileBuffer[bytesRead] = '\0'; + + trackJson.clear(); + if (deserializeJson(trackJson, jsonFileBuffer) != DeserializationError::Ok) { + debugln(F("SaveCourse: existing track will not parse")); + return SD_COURSE_WRITE_NO_TRACK; + } + + // Both on-disk shapes are appendable: the object format's "courses" + // array, and the legacy bare array which IS the course list. + JsonArray courses; + if (trackJson.is()) { + courses = trackJson["courses"]; + if (courses.isNull()) courses = trackJson.createNestedArray("courses"); + } else if (trackJson.is()) { + courses = trackJson.as(); + } else { + return SD_COURSE_WRITE_NO_TRACK; + } + if (courses.isNull()) return SD_COURSE_WRITE_NO_TRACK; + + if ((int)courses.size() >= MAX_LAYOUTS) { + // The device only ever loads MAX_LAYOUTS courses, so an appended one + // past the cap would be written and then silently ignored. + debugln(F("SaveCourse: track already holds MAX_LAYOUTS courses")); + return SD_COURSE_WRITE_TOO_BIG; + } + + courseJson.clear(); + if (deserializeJson(courseJson, courseText) != DeserializationError::Ok) { + return SD_COURSE_WRITE_TOO_BIG; + } + const size_t before = courses.size(); + courses.add(courseJson); + // The whole file has to fit the 4 KB parse budget on the next boot, so a + // grafted course that overflowed the document must not reach the card. + if (courses.size() != before + 1 || trackJson.overflowed()) { + debugln(F("SaveCourse: track file is full")); + return SD_COURSE_WRITE_TOO_BIG; + } + + char tempPath[FILEPATH_MAX]; + snprintf(tempPath, sizeof(tempPath), "%s.tmp", filepath); + SD.remove(tempPath); // a temp left by an interrupted earlier attempt + + File32 outFile; + outFile.open(tempPath, O_CREAT | O_WRITE | O_TRUNC); + if (!outFile) return SD_COURSE_WRITE_IO; + const size_t written = serializeJson(trackJson, outFile); + outFile.sync(); + outFile.close(); + + if (written == 0) { + SD.remove(tempPath); + return SD_COURSE_WRITE_IO; + } + + // Swap only now that the replacement is complete on the card. + if (!SD.remove(filepath)) { + SD.remove(tempPath); + return SD_COURSE_WRITE_IO; + } + if (!SD.rename(tempPath, filepath)) { + debugln(F("SaveCourse: rename failed")); + return SD_COURSE_WRITE_IO; + } + return SD_COURSE_WRITE_OK; +} + +SdCourseWriteResult sdSaveCreatedCourse(const CreatedCourseWrite& req) { + if (req.course == nullptr) return SD_COURSE_WRITE_IO; + + const uint8_t kind = (req.course->kind == course_creator::CourseKind::kSprint) + ? TRACK_KIND_SPRINT + : TRACK_KIND_CIRCUIT; + + const int courseLen = track_json::formatCourse( + courseJsonBuffer, sizeof(courseJsonBuffer), + *req.course, req.courseName, req.dateCreated); + if (courseLen < 0) return SD_COURSE_WRITE_TOO_BIG; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("SaveCourse: SD busy")); + return SD_COURSE_WRITE_BUSY; + } + + // Both folders, since SdFat's open() never creates parents and a course + // can be the very first thing ever written to a blank soldered-in card. + if (!sdEnsureTracksFolder() || + (kind == TRACK_KIND_SPRINT && !SD.exists(trackFolderSprint))) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return SD_COURSE_WRITE_IO; + } + + char filepath[FILEPATH_MAX]; + makeFullTrackPath(req.trackName, filepath, kind); + + SdCourseWriteResult result; + if (req.newTrack) { + if (SD.exists(filepath)) { + // Names carry the GPS clock down to the minute, so this means the + // same minute twice — refuse rather than overwrite someone's course. + debugln(F("SaveCourse: track file already exists")); + result = SD_COURSE_WRITE_EXISTS; + } else { + const int fileLen = track_json::formatTrackFile( + jsonFileBuffer, sizeof(jsonFileBuffer), + req.trackName, req.shortName, + *req.course, req.courseName, req.dateCreated); + if (fileLen < 0) { + result = SD_COURSE_WRITE_TOO_BIG; + } else { + File32 outFile; + outFile.open(filepath, O_CREAT | O_WRITE | O_TRUNC); + if (!outFile) { + result = SD_COURSE_WRITE_IO; + } else { + const size_t written = outFile.write(jsonFileBuffer, (size_t)fileLen); + outFile.sync(); + outFile.close(); + result = (written == (size_t)fileLen) ? SD_COURSE_WRITE_OK : SD_COURSE_WRITE_IO; + if (result != SD_COURSE_WRITE_OK) SD.remove(filepath); + } + } + } + } else { + result = appendCourseToTrackFile(filepath, courseJsonBuffer); + } + + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + // The manifest drives proximity detection, so a course that isn't in it + // doesn't exist as far as the next session is concerned. + if (result == SD_COURSE_WRITE_OK) buildTrackList(); + return result; +} diff --git a/BirdsEye/sensoregg.h b/BirdsEye/sensoregg.h index b779ffd..7dd0c2b 100644 --- a/BirdsEye/sensoregg.h +++ b/BirdsEye/sensoregg.h @@ -12,10 +12,11 @@ // is dropped from the rotation, and BLE goes back to lazy init. This // header's contract below describes the enabled build. // -// Receives the DovesSensorEgg's PW-ADV-1 advertising broadcasts (see -// sensoregg_protocol.h for the byte layout) and exposes the latest EGT / -// cold-junction reading to the logger and display. POC scope: one egg, -// one temperature channel ("Temp1") plus its cold junction ("Junction1"). +// Receives the DovesSensorEgg's PW-ADV advertising broadcasts, v1 and +// v2 (see sensoregg_protocol.h for the byte layouts), and exposes the +// latest readings to the logger and display. Scope: one egg, EGT +// ("Temp1") + cold junction ("Junction1"), and on v2 eggs the aux +// intake-air thermistor ("Temp2") + a real battery percent. // // RADIO ROLE: pure OBSERVER on the shared SoftDevice. Passive scanning // only — we never transmit a SCAN_REQ, never connect, and hold no GATT @@ -83,6 +84,15 @@ bool sensoreggAppHung(); float sensoreggEgtC(); float sensoreggJunctionC(); +// v2 aux thermistor (intake air, "Temp2") in degC. NaN when the link is +// stale, the egg is v1 (no aux field), or the egg reported the invalid +// sentinel (divider open/shorted). +float sensoreggAuxC(); + +// Egg battery percent 0-100; 0xFF = unknown (stale link, v1 stub, or no +// pack fitted on the egg). +uint8_t sensoreggBatteryPct(); + // True while fresh AND the egg flags a thermocouple fault (open / // out-of-range probe, MCP9600 STATUS input-range bit). bool sensoreggTcFault(); diff --git a/BirdsEye/sensoregg.ino b/BirdsEye/sensoregg.ino index 74e04db..711a70f 100644 --- a/BirdsEye/sensoregg.ino +++ b/BirdsEye/sensoregg.ino @@ -1,8 +1,8 @@ /////////////////////////////////////////// // SENSOREGG MODULE (wireless EGT pod — passive BLE observer) // -// Receives the DovesSensorEgg's PW-ADV-1 broadcasts and exposes the -// latest EGT / cold-junction reading. See sensoregg.h for the role, +// Receives the DovesSensorEgg's PW-ADV broadcasts (v1 and v2) and +// exposes the latest readings. See sensoregg.h for the role, // pairing, and threading contracts, and sensoregg_protocol.{h,cpp} // (host-tested) for the byte layout and staleness rule. // @@ -43,7 +43,8 @@ static const uint8_t kSensorEggMac[6] = SENSOREGG_MAC; // ---- RX double-buffer (BLE scan callback fills, SENSOREGG_LOOP drains; // mirrors camera_ble.ino's ce81 idiom: payload arrays are plain RAM, the // per-slot ready flags are the synchronization points) ---- -static uint8_t eggBuf[2][sensoregg_protocol::kPayloadLen]; +static uint8_t eggBuf[2][sensoregg_protocol::kPayloadLenMax]; +static volatile uint8_t eggLen[2] = {0, 0}; // actual bytes captured static volatile uint32_t eggAtMs[2] = {0, 0}; static volatile bool eggReady[2] = {false, false}; static volatile uint8_t eggWriteIdx = 0; // callback writes (Bluefruit task) @@ -91,7 +92,15 @@ static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report) { sensoregg_protocol::matchesMagic(buf, len) && sensoreggMacAccepted(report->peer_addr.addr)) { const uint8_t w = eggWriteIdx; - memcpy(eggBuf[w], buf, sensoregg_protocol::kPayloadLen); + // Capture up to the largest known layout; the parser applies the + // per-version length gate. (The old fixed-14 copy silently truncated + // v2 frames — bytes 14-15 never reached the parser.) + const uint8_t copyLen = + len < sensoregg_protocol::kPayloadLenMax + ? len + : (uint8_t)sensoregg_protocol::kPayloadLenMax; + memcpy(eggBuf[w], buf, copyLen); + eggLen[w] = copyLen; eggAtMs[w] = millis(); eggReady[w] = true; eggWriteIdx = w ^ 1; @@ -145,14 +154,15 @@ void SENSOREGG_SETUP() { void SENSOREGG_LOOP() { // Drain everything queued (usually 0 or 1 slots); the newest parse wins. while (eggReady[eggReadIdx]) { - uint8_t local[sensoregg_protocol::kPayloadLen]; + uint8_t local[sensoregg_protocol::kPayloadLenMax]; memcpy(local, eggBuf[eggReadIdx], sizeof(local)); + const uint8_t localLen = eggLen[eggReadIdx]; const uint32_t atMs = eggAtMs[eggReadIdx]; eggReady[eggReadIdx] = false; eggReadIdx ^= 1; sensoregg_protocol::Reading r; - if (sensoregg_protocol::parsePayload(local, sizeof(local), r)) { + if (sensoregg_protocol::parsePayload(local, localLen, r)) { eggReading = r; eggRxMs = atMs; eggHaveReading = true; @@ -200,6 +210,20 @@ float sensoreggJunctionC() { return eggReading.junctionC; } +float sensoreggAuxC() { + // Same gating as the EGT: stale link or hung app -> NaN. Also NaN when + // the egg is v1 (no aux field) or its divider reported the sentinel. + if (!sensoreggLinkUp() || sensoreggAppHung()) return NAN; + return eggReading.auxC; +} + +uint8_t sensoreggBatteryPct() { + // 0xFF = unknown: stale/hung link, v1 stub, or the egg's own + // no-pack-fitted gate. Never report a stale percent as current. + if (!sensoreggLinkUp() || sensoreggAppHung()) return 0xFF; + return eggReading.battery; +} + bool sensoreggTcFault() { // A frozen payload's fault flag is stale information — suppress it. return sensoreggLinkUp() && !sensoreggAppHung() && eggReading.tcFault; @@ -237,6 +261,8 @@ bool sensoreggLinkUp() { return false; } bool sensoreggAppHung() { return false; } float sensoreggEgtC() { return NAN; } float sensoreggJunctionC() { return NAN; } +float sensoreggAuxC() { return NAN; } +uint8_t sensoreggBatteryPct() { return 0xFF; } bool sensoreggTcFault() { return false; } uint16_t sensoreggSequence() { return 0; } diff --git a/BirdsEye/sensoregg_protocol.cpp b/BirdsEye/sensoregg_protocol.cpp index 2ea3a80..3141b28 100644 --- a/BirdsEye/sensoregg_protocol.cpp +++ b/BirdsEye/sensoregg_protocol.cpp @@ -38,7 +38,13 @@ bool parsePayload(const uint8_t* data, size_t len, Reading& out) { if (!matchesMagic(data, len)) { return false; } - if (data[4] != kProtocolVersion) { + const uint8_t ver = data[4]; + if (ver != kProtocolVersion && ver != kProtocolVersionV2) { + return false; + } + // A version's own length gate: a v2 frame truncated to 14-15 bytes is + // corrupt, not a v1 frame — reject rather than mis-parse. + if (ver == kProtocolVersionV2 && len < kPayloadLenV2) { return false; } @@ -50,6 +56,9 @@ bool parsePayload(const uint8_t* data, size_t len, Reading& out) { out.status = data[10]; out.battery = data[11]; out.sequence = (uint16_t)((uint16_t)data[12] | ((uint16_t)data[13] << 8)); + out.auxC = (ver >= kProtocolVersionV2) ? decodeDeciC(data[14], data[15]) + : NAN; + out.protoVersion = ver; return true; } diff --git a/BirdsEye/sensoregg_protocol.h b/BirdsEye/sensoregg_protocol.h index fdb1a96..d751e51 100644 --- a/BirdsEye/sensoregg_protocol.h +++ b/BirdsEye/sensoregg_protocol.h @@ -1,27 +1,31 @@ #pragma once /////////////////////////////////////////// -// SENSOREGG PW-ADV-1 PROTOCOL +// SENSOREGG PW-ADV PROTOCOL (v1 + v2) // Parser for the DovesSensorEgg wireless thermocouple pod's BLE -// advertising payload (spec PW-ADV-1). The egg is a pure BROADCASTER: -// it packs EGT + cold-junction temperatures into a 14-byte Manufacturer -// Specific Data AD structure and advertises at ~10 Hz; the logger is a -// passive OBSERVER that never connects. This unit owns the byte layout -// and the staleness/validity rules so they are host-testable; the BLE -// plumbing (Bluefruit.Scanner) stays in sensoregg.ino. +// advertising payload. The egg is a pure BROADCASTER: it packs its +// readings into a Manufacturer Specific Data AD structure and +// advertises at ~10 Hz; the logger is a passive OBSERVER that never +// connects. This unit owns the byte layout and the staleness/validity +// rules so they are host-testable; the BLE plumbing (Bluefruit.Scanner) +// stays in sensoregg.ino. // -// Payload layout (bytes, all multi-byte fields little-endian). NOTE: -// Bluefruit's addManufacturerData()/parseReportByType() pass the buffer -// through RAW — the company ID is INSIDE the array, not prepended: +// v2 (2026-07-27) appends to v1 — bytes 0-13 keep their exact v1 +// offsets, so both versions share one decode path. Layout (little- +// endian fields). NOTE: Bluefruit's addManufacturerData() / +// parseReportByType() pass the buffer through RAW — the company ID is +// INSIDE the array, not prepended: // 0-1 company ID 0xFF 0xFF (SIG test/internal ID) // 2-3 magic 'P' 'W' (0x50 0x57) — disambiguates other 0xFFFF users -// 4 pod type / protocol version (0x01) +// 4 protocol version (0x01 = 14-byte v1, 0x02 = 16-byte v2) // 5 flags: bit0 = pairing window active, bit1 = thermocouple fault // 6-7 EGT, int16 deci-degC (6500 = 650.0 C); 0x8000 = invalid // 8-9 cold junction, int16 deci-degC; 0x8000 = invalid // 10 raw MCP9600 STATUS register (0x04) -// 11 battery percent (stub, always 0xFF) +// 11 battery percent 0-100 (real on v2 eggs; 0xFF = unknown/stub) // 12-13 free-running sequence counter (wraps) +// 14-15 [v2] aux thermistor (intake air), int16 deci-degC; 0x8000 = +// invalid. Absent on v1 — parses as NaN. // // Pure logic — no Arduino headers — so it is exercised by host tests. /////////////////////////////////////////// @@ -31,9 +35,14 @@ namespace sensoregg_protocol { -// Full payload length. Shorter reports are rejected; longer ones are -// accepted (forward compatibility — a future egg may append fields). -constexpr size_t kPayloadLen = 14; +// Per-version payload lengths. Shorter-than-the-version's reports are +// rejected; longer ones are accepted (forward compatibility — a future +// egg may append more fields). kPayloadLen doubles as the minimum +// acceptable length for the cheap scan-callback gate; kPayloadLenMax +// sizes RX buffers. +constexpr size_t kPayloadLen = 14; // v1 (and the common prefix) +constexpr size_t kPayloadLenV2 = 16; // v2 = v1 + aux thermistor +constexpr size_t kPayloadLenMax = kPayloadLenV2; // Company ID + magic, exactly as they appear at the start of the payload. constexpr uint8_t kMagic[4] = {0xFF, 0xFF, 0x50, 0x57}; @@ -46,8 +55,11 @@ constexpr uint8_t kMagic[4] = {0xFF, 0xFF, 0x50, 0x57}; // bursts on a BLE-busy bench. constexpr uint16_t kCompanyId = 0xFFFF; -// Byte 4 — the only protocol version this parser understands. -constexpr uint8_t kProtocolVersion = 0x01; +// Byte 4 — the protocol versions this parser understands. v1 eggs are +// still accepted (their aux temperature parses as NaN), so a fleet can +// mix firmware ages without the logger going blind to either. +constexpr uint8_t kProtocolVersion = 0x01; // original 14-byte layout +constexpr uint8_t kProtocolVersionV2 = 0x02; // + aux thermistor // int16 sentinel for "no valid reading" (the egg emits this instead of // casting NaN/out-of-range floats to int16, which is UB). @@ -92,12 +104,15 @@ constexpr uint32_t kScannerSelfHealMs = 30000; struct Reading { float egtC = 0.0f; // NaN when the egg sent the invalid sentinel float junctionC = 0.0f; // NaN when the egg sent the invalid sentinel + float auxC = 0.0f; // v2 aux thermistor (intake air); NaN on v1 + // eggs and on the invalid sentinel uint8_t flags = 0; bool pairingActive = false; // flags bit0 bool tcFault = false; // flags bit1 (MCP9600 STATUS input-range) uint8_t status = 0; // raw MCP9600 STATUS register - uint8_t battery = 0; // stub, 0xFF on current eggs + uint8_t battery = 0; // percent 0-100 on v2 eggs; 0xFF unknown uint16_t sequence = 0; + uint8_t protoVersion = 0; // byte 4 of the accepted payload }; // True when data starts with the 4-byte company-ID+magic prefix (cheap diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index c43d628..3000619 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -105,6 +105,12 @@ static void ensureDefaultSettings() { { "waypoint_detection_distance", "30" }, { "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. + { "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 }; char buf[48]; diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index ad18a69..e899116 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -121,6 +121,8 @@ set(SIM_CORE_SOURCES # Pure-logic units the compiled firmware modules call (same files the # host test harness in tests/ builds). ${BIRDSEYE_DIR}/camera_fsm.cpp + ${BIRDSEYE_DIR}/course_creator.cpp + ${BIRDSEYE_DIR}/crossing_pattern.cpp ${BIRDSEYE_DIR}/dovex_header.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_status_page.cpp @@ -132,10 +134,14 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/sd_access_policy.cpp ${BIRDSEYE_DIR}/sd_format_page.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp + ${BIRDSEYE_DIR}/sprint_select.cpp ${BIRDSEYE_DIR}/tach_filter.cpp + ${BIRDSEYE_DIR}/track_json.cpp ${BIRDSEYE_DIR}/wake_cause.cpp # Real DovesLapTimer sources — the lap/sector timing IS the demo. ${doveslaptimer_SOURCE_DIR}/src/DovesLapTimer.cpp + ${doveslaptimer_SOURCE_DIR}/src/CrossingEngine.cpp + ${doveslaptimer_SOURCE_DIR}/src/SprintTimer.cpp ${doveslaptimer_SOURCE_DIR}/src/WaypointLapTimer.cpp ${doveslaptimer_SOURCE_DIR}/src/CourseDetector.cpp ${doveslaptimer_SOURCE_DIR}/src/CourseManager.cpp diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index d1500c9..aafdb2b 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -1,8 +1,16 @@ -gps_status_no_fix 900 fb82cebc +gps_status_no_fix 900 f2276ffc main_menu_race -1 34b50cd7 -main_menu_transfer -1 c79664ba +main_menu_transfer -1 40da09f8 transfer_menu -4 3013bbe2 bluetooth_waiting -2 c0f9211b warning_no_dovex 100 f800764b +warning_create_needs_gps 100 45982329 +course_type_select -12 35882070 +course_lines_circuit_empty -13 0a3d740a +course_line_detail -14 5ff1d3f5 +course_point_idle -15 28961e61 +course_line_point_a_done -14 646ca520 +main_menu_after_create -1 34b50cd7 +main_menu_parked_on_line -1 34b50cd7 pair_camera_unpaired -6 5d1129cc camera_serial_entry -7 56588408 diff --git a/BirdsEye/sim/golden_main.cpp b/BirdsEye/sim/golden_main.cpp index b642eb4..c65a47a 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -78,8 +78,48 @@ constexpr int kPageTransferMenu = -4; constexpr int kPageBluetooth = -2; constexpr int kPagePairCamera = -6; constexpr int kPageCameraSerialEntry = -7; +constexpr int kPageCourseTrack = -11; +constexpr int kPageCourseType = -12; +constexpr int kPageCourseLines = -13; +constexpr int kPageCourseLine = -14; +constexpr int kPageCoursePoint = -15; constexpr int kPageWarning = 100; +// Midpoint of the preloaded OKC track's start/finish line — parking here puts +// the vehicle inside that line's crossing zone. +constexpr double kOkcStartLat = 28.41271928; +constexpr double kOkcStartLon = -81.37965158; + +// Somewhere with no track in the manifest, so the creator's prompt has +// nothing to offer and the flow starts on the type picker. (The preloaded +// asset track is OKC; this is deliberately nowhere near it.) +constexpr double kOpenGroundLat = 39.5; +constexpr double kOpenGroundLon = -98.35; + +// Feed the firmware a fix good enough to name a file and capture a point. +// One PVT per step batch is the documented injection rate. `speedMph` is the +// auto-race lever: at or above 10 mph a settled main-menu frame enters race mode. +void injectFix(int frames, double lat, double lon, double hAccM = 1.2, + double speedMph = 0.0) { + for (int i = 0; i < frames; i++) { + SimPvt p{}; + // 2026-08-03T14:32Z — the creator stamps names from this, so the + // golden also pins the generated-name format. + p.timestamp_ms = 1785076320000ull + (unsigned long long)i * 40ull; + p.lat = lat; + p.lng = lon; + p.altitude_m = 100.0; + p.speed_mph = speedMph; + p.heading_deg = 0.0; + p.h_acc_m = hAccM; + p.hdop = 0.8; + p.sats = 12; + p.fix = 1; + sim_inject_pvt(&p); + sim_step_millis(40); + } +} + void runScript() { sim_init(); @@ -111,9 +151,87 @@ void runScript() { press(1); capture("warning_no_dovex", kPageWarning); - // Any button dismisses the warning back to the menu; walk to Camera - // (index 3) and select -> unpaired pairing screen. + // Any button dismisses the warning back to the menu; walk to Create + // Course (index 3) and select. With no fix injected yet, the creator + // refuses up front rather than letting the user walk a course it could + // neither capture nor name. + press(1); + press(2); + press(2); + press(2); + press(1); + capture("warning_create_needs_gps", kPageWarning); + + // Dismiss, feed a real fix, and enter the creator for real. Nothing in + // the manifest is within the detection radius out here, so the track + // prompt is skipped and the type picker comes up first. + press(1); + injectFix(60, kOpenGroundLat, kOpenGroundLon); + press(2); + press(2); + press(2); + press(1); + capture("course_type_select", kPageCourseType); + + // Circuit -> the line menu, with Save refused until a start line exists. + press(1); + capture("course_lines_circuit_empty", kPageCourseLines); + + // Open the start/finish line -> Point A / Point B, neither captured. press(1); + capture("course_line_detail", kPageCourseLine); + + // Point A -> the capture screen, idle, showing live accuracy. + press(1); + capture("course_point_idle", kPageCoursePoint); + + // Run a real averaging hold: start it, then feed fixes across the + // window. The commit drops back to the line detail with A captured. + press(1); + injectFix(90, kOpenGroundLat, kOpenGroundLon); + capture("course_line_point_a_done", kPageCourseLine); + + // Now roll, and leave. Auto-race fires from the main menu above 10 mph and + // used to be checked the instant the page changed, so exiting ANY page while + // moving landed on the menu and entered race mode on the very next loop pass + // — the menu never got drawn. The creator is simply where it was found: it + // is the one screen used out on the course with the vehicle possibly rolling. + // Regression fixture: without the settle window the capture below sees a race + // page instead of the menu and fails loudly. + injectFix(20, kOpenGroundLat, kOpenGroundLon, 1.2, 15.0); + + // Back out: row 3 of the line detail is Back (discards the scratch + // line), then row 4 of the line menu is Cancel (leaves the creator). + press(2); + press(2); + press(2); + press(1); + press(2); + press(2); + press(2); + press(2); + press(1); + capture("main_menu_after_create", kPageMainMenu); + + // Coast to a stop before the rest of the walk — gpsData holds its last value + // between PVTs, so a latched 15 mph would trip auto-race once the settle + // window expired and break every fixture after this one. + injectFix(20, kOpenGroundLat, kOpenGroundLon, 1.2, 0.0); + + // Sit on the menu parked ON the OKC start/finish line, with the track + // detected. Locks that the menu still renders as a menu in that state — its + // hash is expected to be IDENTICAL to main_menu_after_create above. + // + // NOT a regression test for the crossing overlay leaking onto non-racing + // pages, though it is the closest this harness gets: reaching a true + // crossing flag needs an ARMED timer, and OKC ships eight courses, so + // CourseDetector never locks one without actually driving a lap. Verified by + // restoring the old blocklist — this fixture does not change. The overlay + // gate itself is argued from the page-id ranges, not proven here. + injectFix(120, kOkcStartLat, kOkcStartLon); + capture("main_menu_parked_on_line", kPageMainMenu); + + press(2); press(2); press(2); press(2); @@ -121,7 +239,7 @@ void runScript() { capture("pair_camera_unpaired", kPagePairCamera); // Left on the unpaired pairing screen opens the manual 6-char serial - // entry page (custom button branch) — the eighth fixture. + // entry page (custom button branch). press(0); capture("camera_serial_entry", kPageCameraSerialEntry); } diff --git a/BirdsEye/sim/sdfat_shim/SdFat.h b/BirdsEye/sim/sdfat_shim/SdFat.h index aa93494..b0cf62f 100644 --- a/BirdsEye/sim/sdfat_shim/SdFat.h +++ b/BirdsEye/sim/sdfat_shim/SdFat.h @@ -122,6 +122,9 @@ class SdFat { bool exists(const char* path); bool mkdir(const char* path); bool remove(const char* path); + // Used by the on-device course creator's write-to-temp-then-swap, so a + // power loss mid-rewrite can't leave a truncated track file behind. + bool rename(const char* oldPath, const char* newPath); File32 open(const char* path, oflag_t oflag = O_READ); // On-device format: wipes the VFS clean (no FAT to build). diff --git a/BirdsEye/sim/sdfat_shim/sim_vfs.cpp b/BirdsEye/sim/sdfat_shim/sim_vfs.cpp index f7b0d81..e0331c9 100644 --- a/BirdsEye/sim/sdfat_shim/sim_vfs.cpp +++ b/BirdsEye/sim/sdfat_shim/sim_vfs.cpp @@ -257,6 +257,19 @@ bool SdFat::remove(const char* path) { return files().erase(normalize(path)) > 0; } +bool SdFat::rename(const char* oldPath, const char* newPath) { + const std::string from = normalize(oldPath); + const std::string to = normalize(newPath); + auto it = files().find(from); + if (it == files().end()) return false; + // SdFat refuses to clobber an existing destination; match that so the + // firmware's remove-then-rename ordering stays load-bearing here too. + if (files().count(to)) return false; + files()[to] = it->second; + files().erase(it); + return true; +} + File32 SdFat::open(const char* path, oflag_t oflag) { File32 f; f.open(path, oflag); diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index f0496b0..28b85a9 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -25,9 +25,11 @@ #include #include +#include #include "SdFat.h" +#include "course_creator.h" #include "gps_status_page.h" #include "sd_format_page.h" #include "wake_cause.h" @@ -53,6 +55,10 @@ float activeTimerPaceDifference(); float activeTimerTotalDistance(); unsigned long activeTimerOptimalLapTime(); bool activeTimerSectorsConfigured(); +SprintTimer* getActiveTimerSprint(); +bool sprintModeIsActive(); +bool activeTimerRunActive(); +bool createSprintSession(); void trackDetectionLoop(); void endRaceSession(); void createLapAnythingCourseManager(); @@ -60,6 +66,12 @@ void checkAutoIdle(); void autoRaceModeCheck(); void gpsStatusPageLoop(); void sdFormatPageLoop(); +int courseCreatorPage(); +bool courseCreatorActive(); +bool courseCreatorEnter(); +void courseCreatorSave(); +void courseCreatorSelect(); +void courseCreatorLoop(); void updateGpsLockHold(); void writeDovexHeader(); bool isUsbConnected(); @@ -78,6 +90,11 @@ void displayPage_transfer_menu(); void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); +void displayPage_course_track(); +void displayPage_course_type(); +void displayPage_course_lines(); +void displayPage_course_line(); +void displayPage_course_point(); void displayPage_camera_serial_entry(); void displayPage_replay_file_select(); void displayPage_replay_results(); @@ -89,6 +106,7 @@ void displayPage_gps_pace(); void displayPage_gps_best_lap(); void displayPage_tachometer(); void displayPage_sensorTemp(); +void displayPage_sensorTemp2(); void displayPage_optimal_lap(); void displayPage_gps_lap_list(); void displayPage_stop_logging(); @@ -157,7 +175,8 @@ bool parseDovexHeader(const char* filename); bool acquireSDAccess(int mode); void releaseSDAccess(int mode); void forceReleaseSDAccess(); -void makeFullTrackPath(const char* trackName, char* filepath); +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); bool SD_SETUP(); diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index 0c99349..5a22b4e 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -95,6 +95,8 @@ bool sensoreggLinkUp() { return false; } bool sensoreggAppHung() { return false; } float sensoreggEgtC() { return NAN; } float sensoreggJunctionC() { return NAN; } +float sensoreggAuxC() { return NAN; } +uint8_t sensoreggBatteryPct() { return 0xFF; } bool sensoreggTcFault() { return false; } uint16_t sensoreggSequence() { return 0; } diff --git a/BirdsEye/sprint_select.cpp b/BirdsEye/sprint_select.cpp new file mode 100644 index 0000000..e63f53b --- /dev/null +++ b/BirdsEye/sprint_select.cpp @@ -0,0 +1,51 @@ +#include "sprint_select.h" + +#include + +namespace sprint_select { + +int compareDateCreated(const char* a, const char* b) { + const bool aEmpty = (a == nullptr) || (a[0] == '\0'); + const bool bEmpty = (b == nullptr) || (b[0] == '\0'); + if (aEmpty && bEmpty) return 0; + if (aEmpty) return -1; // undated sorts oldest + if (bEmpty) return 1; + return strcmp(a, b); +} + +int newestCourseIndex(const char* const* dates, int count) { + if (count <= 0 || dates == nullptr) return -1; + int best = 0; + for (int i = 1; i < count; i++) { + // >= : ties resolve to the LAST tied index (later file entries were + // appended later — see header). + if (compareDateCreated(dates[i], dates[best]) >= 0) { + best = i; + } + } + return best; +} + +bool isSameDay(const char* dateCreated, const char* todayIsoDate) { + if (dateCreated == nullptr || todayIsoDate == nullptr) return false; + // Both must carry at least a full "YYYY-MM-DD". + if (strlen(dateCreated) < 10 || strlen(todayIsoDate) < 10) return false; + return strncmp(dateCreated, todayIsoDate, 10) == 0; +} + +Kind chooseKind(bool circuitInRange, bool sprintInRange, Pref pref, + bool sprintCourseCreatedToday) { + if (sprintInRange && !circuitInRange) return kSprint; + if (circuitInRange && !sprintInRange) return kCircuit; + if (!circuitInRange && !sprintInRange) return kCircuit; // caller gates + + // Both kinds in range — the preference setting breaks the tie. + if (pref == kPrefSprint) { + return kSprint; + } + // Circuit preference yields only on an event day: the nearby sprint + // track has a course laid out today. + return sprintCourseCreatedToday ? kSprint : kCircuit; +} + +} // namespace sprint_select diff --git a/BirdsEye/sprint_select.h b/BirdsEye/sprint_select.h new file mode 100644 index 0000000..8973193 --- /dev/null +++ b/BirdsEye/sprint_select.h @@ -0,0 +1,88 @@ +#pragma once + +/////////////////////////////////////////// +// SPRINT MODE SELECTION LOGIC (pure, host-tested) +// +// Sprint courses are dated, disposable layouts of a persistent venue +// (autocross re-lays cones every event), so course selection is +// date-based: load the newest course by its `date_created` field — +// a sortable ISO-8601 timestamp ("YYYY-MM-DDTHH:MM", any prefix +// accepted) stamped by whatever created the course (webapp or the +// future on-device creator). Lexicographic max == newest. +// +// Mode selection follows the detected track's folder (/TRACKS vs +// /TRACKS/SPRINT). When BOTH kinds are within detection range, the +// `race_mode` preference setting breaks the tie (plan 0002 §7 Q1): +// - pref circuit (default): circuit wins — UNLESS the sprint track +// has a course created *today* (it's an event day), then sprint. +// - pref sprint: sprint always wins (fixed sprint courses, e.g. a +// permanent rally layout, are never re-created per event). +// The preference never overrides what is actually detected — with only +// one kind in range, that kind is used regardless of the setting. +// +// No Arduino types — compiled into both the firmware and the host +// test harness (tests/sprint_select_test.cpp). +/////////////////////////////////////////// + +namespace sprint_select { + +// Track kinds — values mirror TrackManifestEntry::kind. +enum Kind : unsigned char { + kCircuit = 0, + kSprint = 1, +}; + +// The race_mode preference setting ("circuit" default / "sprint"). +enum Pref : unsigned char { + kPrefCircuit = 0, + kPrefSprint = 1, +}; + +/** + * @brief Compare two date_created strings (sortable ISO-8601 prefixes). + * + * Plain lexicographic compare — valid because the format is fixed-width + * most-significant-first. NULL or empty sorts oldest (a course with no + * date must never beat a dated one). + * + * @return <0 if a older than b, 0 if equal, >0 if a newer than b. + */ +int compareDateCreated(const char* a, const char* b); + +/** + * @brief Index of the newest course by date_created. + * + * Ties (including the all-empty legacy case) resolve to the LAST + * tied index — later entries in a track file were appended later, so + * on missing/equal dates the most recently added course wins. + * + * @param dates Array of date_created strings (entries may be NULL/empty). + * @param count Number of entries. + * @return Index of the newest course, or -1 when count <= 0. + */ +int newestCourseIndex(const char* const* dates, int count); + +/** + * @brief True when a date_created string falls on the given day. + * + * Compares the "YYYY-MM-DD" prefix (10 chars). Either argument + * NULL/shorter than a full date returns false. + */ +bool isSameDay(const char* dateCreated, const char* todayIsoDate); + +/** + * @brief The mode tiebreak: which track kind should the session use? + * + * @param circuitInRange A circuit track is within detection radius. + * @param sprintInRange A sprint track is within detection radius. + * @param pref The race_mode preference setting. + * @param sprintCourseCreatedToday The in-range sprint track's newest + * course was created today (event-day heuristic; only consulted when + * both kinds are in range and pref is circuit). + * @return The kind to use. With neither in range, returns kCircuit + * (callers gate on having a detection at all). + */ +Kind chooseKind(bool circuitInRange, bool sprintInRange, Pref pref, + bool sprintCourseCreatedToday); + +} // namespace sprint_select diff --git a/BirdsEye/tach_filter.cpp b/BirdsEye/tach_filter.cpp index d6f55c7..0a7a4dc 100644 --- a/BirdsEye/tach_filter.cpp +++ b/BirdsEye/tach_filter.cpp @@ -30,4 +30,40 @@ 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) { + if (cylinderCount < kMinCylinders) return kMinCylinders; + if (cylinderCount > kMaxCylinders) return kMaxCylinders; + return cylinderCount; +} + +} // namespace + +float revsPerPulse(int cylinderCount, bool wastedSpark) { + const float pulsesPerRev = (float)clampCylinders(cylinderCount) * + (wastedSpark ? kPulsesPerRevWasted : kPulsesPerRevSingle); + return 1.0f / pulsesPerRev; +} + +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; +} + } // namespace tach_filter diff --git a/BirdsEye/tach_filter.h b/BirdsEye/tach_filter.h index ac6e82e..56f4d8a 100644 --- a/BirdsEye/tach_filter.h +++ b/BirdsEye/tach_filter.h @@ -1,5 +1,7 @@ #pragma once +#include + /////////////////////////////////////////// // TACHOMETER KALMAN FILTER // The 1-D Kalman filter that turns mean inter-pulse periods into a @@ -50,4 +52,62 @@ void update(Kalman& k, float rpmMeasured, int periodCount); // Non-positive inputs return 0. 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: +// +// pulses per rev = cylinders x (wasted spark ? 1.0 : 0.5) +// +// 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. +// +// 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. +/////////////////////////////////////////// + +// 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 + +// 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. +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. +// +// 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 +// 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; + +// 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); + } // namespace tach_filter diff --git a/BirdsEye/track_json.cpp b/BirdsEye/track_json.cpp new file mode 100644 index 0000000..e15385f --- /dev/null +++ b/BirdsEye/track_json.cpp @@ -0,0 +1,229 @@ +#include "track_json.h" + +#include + +namespace track_json { + +using course_creator::CourseKind; +using course_creator::Line; +using course_creator::LineId; +using course_creator::lineDone; +using course_creator::lineOf; + +namespace { + +// Small append helper shared by the emitters. Tracks a write cursor and +// latches failure, so each emitter can append freely and check once at the +// end instead of testing every call. +struct Writer { + char* out; + size_t size; + size_t used = 0; + bool overflowed = false; + + Writer(char* o, size_t s) : out(o), size(s) { + if (out == nullptr || size == 0) overflowed = true; + } + + void put(const char* text, size_t len) { + if (overflowed) return; + if (used + len + 1 > size) { // +1 keeps room for the NUL + overflowed = true; + return; + } + memcpy(out + used, text, len); + used += len; + } + + void put(const char* text) { put(text, strlen(text)); } + + void coord(double value) { + if (overflowed) return; + char buf[kCoordSize]; + const int n = formatFixed(buf, sizeof(buf), value, kCoordDecimals); + if (n < 0) { + overflowed = true; + return; + } + put(buf, static_cast(n)); + } + + // "key":value — the only shape this file emits for numbers. + void coordField(const char* key, double value) { + put("\""); + put(key); + put("\":"); + coord(value); + } + + void stringField(const char* key, const char* value) { + put("\""); + put(key); + put("\":\""); + put(value ? value : ""); + put("\""); + } + + int finish() { + if (overflowed) return -1; + out[used] = '\0'; + return static_cast(used); + } +}; + +// Longest field name emitted: "sector_2_a_lat" (14) + NUL. +constexpr size_t kFieldKeySize = 20; + +// prefix + suffix into `key`. Truncation is impossible with the prefixes +// this file uses, but the bound keeps that true if one is ever added. +void buildKey(char* key, size_t keySize, const char* prefix, const char* suffix) { + const size_t p = strlen(prefix); + const size_t s = strlen(suffix); + if (p + s + 1 > keySize) { + key[0] = '\0'; + return; + } + memcpy(key, prefix, p); + memcpy(key + p, suffix, s); + key[p + s] = '\0'; +} + +// Emit the four coordinates of one line under a field prefix, e.g. +// "sector_2" -> sector_2_a_lat / _a_lng / _b_lat / _b_lng. +void putLine(Writer& w, const char* prefix, const Line& line) { + static const char* const kSuffixes[] = {"_a_lat", "_a_lng", "_b_lat", "_b_lng"}; + const double values[] = {line.aLat, line.aLon, line.bLat, line.bLon}; + + char key[kFieldKeySize]; + for (uint8_t i = 0; i < 4; i++) { + if (i > 0) w.put(","); + buildKey(key, sizeof(key), prefix, kSuffixes[i]); + w.coordField(key, values[i]); + } +} + +} // namespace + +int formatFixed(char* out, size_t outSize, double value, uint8_t decimals) { + if (out == nullptr || outSize == 0 || decimals > 9) return -1; + + bool negative = value < 0.0; + if (negative) value = -value; + + // 10^decimals — small enough that the loop beats pulling in pow(). + int64_t scale = 1; + for (uint8_t i = 0; i < decimals; i++) scale *= 10; + + // Round half away from zero. Coordinates scaled by 1e8 stay far inside + // the range where a double holds an integer exactly, so this is lossless. + const int64_t scaled = static_cast(value * static_cast(scale) + 0.5); + const int64_t whole = scaled / scale; + int64_t frac = scaled % scale; + + // Build back-to-front into a scratch buffer, then copy. + char tmp[32]; + size_t n = 0; + + for (uint8_t i = 0; i < decimals; i++) { + tmp[n++] = static_cast('0' + static_cast(frac % 10)); + frac /= 10; + } + if (decimals > 0) tmp[n++] = '.'; + + int64_t w = whole; + if (w == 0) { + tmp[n++] = '0'; + } else { + while (w > 0 && n < sizeof(tmp)) { + tmp[n++] = static_cast('0' + static_cast(w % 10)); + w /= 10; + } + } + // A value that rounds to zero is not negative — "-0.00000000" would be + // read back as a real coordinate at the equator, which it is. + if (negative && scaled != 0) tmp[n++] = '-'; + + if (n + 1 > outSize) return -1; + for (size_t i = 0; i < n; i++) out[i] = tmp[n - 1 - i]; + out[n] = '\0'; + return static_cast(n); +} + +int formatCourse(char* out, size_t outSize, + const course_creator::State& course, + const char* courseName, + const char* dateCreated) { + Writer w(out, outSize); + const bool sprint = course.kind == CourseKind::kSprint; + + w.put("{"); + w.stringField("name", courseName); + w.put(","); + + putLine(w, "start", lineOf(course, LineId::kStart)); + + // Optional lines are emitted only when captured — parseTrackFile() + // probes for them with containsKey(), so an absent line and a line of + // zeroes are very different things. + const Line& s2 = lineOf(course, LineId::kSector2); + if (lineDone(s2)) { + w.put(","); + putLine(w, "sector_2", s2); + } + const Line& s3 = lineOf(course, LineId::kSector3); + if (lineDone(s3)) { + w.put(","); + putLine(w, "sector_3", s3); + } + if (sprint) { + const Line& fin = lineOf(course, LineId::kFinish); + if (lineDone(fin)) { + w.put(","); + putLine(w, "finish", fin); + } + // Sprint-only by contract: the webapp documents Course.dateCreated as + // sprint-only, and sprint_select is the only reader. + if (dateCreated != nullptr && dateCreated[0] != '\0') { + w.put(","); + w.stringField("date_created", dateCreated); + } + } + + w.put("}"); + return w.finish(); +} + +int formatTrackFile(char* out, size_t outSize, + const char* longName, const char* shortName, + const course_creator::State& course, + const char* courseName, + const char* dateCreated) { + Writer w(out, outSize); + const bool sprint = course.kind == CourseKind::kSprint; + + w.put("{"); + w.stringField("longName", longName); + w.put(","); + w.stringField("shortName", shortName); + w.put(","); + if (sprint) { + // Redundant with the folder (which is authoritative) but the webapp + // validates against it, so a file that claims what it is travels + // correctly even if someone moves it by hand. + w.stringField("type", "sprint"); + w.put(","); + } + w.stringField("defaultCourse", courseName); + w.put(",\"courses\":["); + if (w.finish() < 0) return -1; + + const int courseLen = formatCourse(out + w.used, outSize - w.used, + course, courseName, dateCreated); + if (courseLen < 0) return -1; + w.used += static_cast(courseLen); + + w.put("]}"); + return w.finish(); +} + +} // namespace track_json diff --git a/BirdsEye/track_json.h b/BirdsEye/track_json.h new file mode 100644 index 0000000..17b98c6 --- /dev/null +++ b/BirdsEye/track_json.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include + +#include "course_creator.h" + +/////////////////////////////////////////// +// TRACK JSON WRITER (pure, host-tested) +// +// The firmware's first track-file WRITER — until the on-device course +// creator (plan 0002 §5) it only ever read them. Emits the same object +// format `parseTrackFile()` reads and the webapp round-trips: +// +// {"longName":"N260803_1432","shortName":"08031432","type":"sprint", +// "defaultCourse":"N260803_1432", +// "courses":[{"name":"...","start_a_lat":...}]} +// +// Text is built by hand rather than through ArduinoJson because the +// coordinate formatting is the hard part either way: Arduino's snprintf +// has no working "%f" on this core (which is why the sketch reaches for +// dtostrf everywhere), and dtostrf does not exist on the host. So +// coordinates go through formatFixed() — integer math, identical on both +// targets, testable to the last digit. Same reasoning as gps_time's +// hand-rolled u64ToDecimalString. +// +// Every emitter returns the number of characters written (excluding the +// NUL) or -1 when the buffer is too small. A truncated track file is +// worse than no file, so callers must check. +// +// No Arduino headers — compiled into both the firmware and the host test +// harness (tests/track_json_test.cpp). +/////////////////////////////////////////// + +namespace track_json { + +// Decimal places used for every coordinate. 1e-8 degrees is ~1.1 mm — +// far below GPS noise, and it matches the precision the DOVEX rows and +// the webapp's track files already carry. +constexpr uint8_t kCoordDecimals = 8; + +// Longest coordinate this can emit: sign + 3 whole digits + '.' + 8 = 13, +// plus NUL. +constexpr size_t kCoordSize = 16; + +/** + * @brief Fixed-point decimal formatter, e.g. -97.12345678. + * + * Rounds half away from zero. Values are scaled through int64, which is + * exact for any coordinate (180 * 1e8 is far inside the 2^53 range where + * doubles represent integers exactly). + * + * @return chars written excluding the NUL, or -1 if it does not fit. + */ +int formatFixed(char* out, size_t outSize, double value, uint8_t decimals); + +/** + * @brief Emit one course object — the element of the "courses" array. + * + * Optional lines are emitted only when captured, matching how + * parseTrackFile() probes for them with containsKey(). `dateCreated` is + * written for SPRINT courses only: the webapp documents Course.dateCreated + * as sprint-only and the device's own newest-course selection is the only + * thing that reads it back. + * + * @return chars written excluding the NUL, or -1 if it does not fit. + */ +int formatCourse(char* out, size_t outSize, + const course_creator::State& course, + const char* courseName, + const char* dateCreated); + +/** + * @brief Emit a complete new track file holding exactly this one course. + * + * @return chars written excluding the NUL, or -1 if it does not fit. + */ +int formatTrackFile(char* out, size_t outSize, + const char* longName, const char* shortName, + const course_creator::State& course, + const char* courseName, + const char* dateCreated); + +} // namespace track_json diff --git a/BirdsEye/wake_cause.cpp b/BirdsEye/wake_cause.cpp index 17fc8d4..8582eb2 100644 --- a/BirdsEye/wake_cause.cpp +++ b/BirdsEye/wake_cause.cpp @@ -20,4 +20,10 @@ Cause decode(const Regs& regs, const PinMasks& pins) { return Cause::kColdBoot; // reset pin or power-on (RESETREAS empty) } +bool tachIdleIsHigh(unsigned highSamples, unsigned totalSamples) { + // Majority vote; ties (including 0 samples) fall to idle-high so a + // floating input under the pull-up keeps the original SENSE-LOW arm. + return highSamples * 2 >= totalSamples; +} + } // namespace wake_cause diff --git a/BirdsEye/wake_cause.h b/BirdsEye/wake_cause.h index e4f1f2f..1ab6c37 100644 --- a/BirdsEye/wake_cause.h +++ b/BirdsEye/wake_cause.h @@ -63,4 +63,18 @@ struct PinMasks { // VBUS is next, then watchdog, then soft reset, then cold boot. Cause decode(const Regs& regs, const PinMasks& pins); +// Shutdown-side companion to the boot decode: decide the tach pin's +// parked idle level from a burst of samples taken right before System +// OFF entry, so SENSE can be armed for the OPPOSITE level. The tach +// pickup's output stage (Schmitt inverter + optocoupler) idles high or +// low depending on the circuit build, and runtime RPM counting cannot +// tell the two apart — a spark pulse yields exactly one falling edge +// either way. But arming SENSE toward the idle level satisfies DETECT +// immediately and System OFF wake-resets within a second (the +// battery-sleep instant-reboot bug). Majority vote over the samples; +// a tie or an empty burst resolves to idle-high — the pull-up's level +// on a floating/disconnected tach input, and the historical hard-coded +// assumption. +bool tachIdleIsHigh(unsigned highSamples, unsigned totalSamples); + } // namespace wake_cause diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b52fc..6364fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,231 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **RPM is now correct on engines that aren't single-cylinder karts** + (plan 0003). The pickup counts ignition sparks, and the logger treated one + spark as one revolution — true only for a 2-stroke, or a 4-stroke with + wasted spark, running one cylinder. On anything else RPM was out by a fixed + factor: a twin firing every revolution read **double** the real speed. + Two new settings fix it, editable over Bluetooth: + - **Spark Mode** — `wasted` (one spark per revolution: 2-stroke, or + 4-stroke wasted spark) or `single` (4-stroke single-fire, one spark per + two revolutions). + - **Cylinders** — the cylinders the **pickup can see**, which is not always + the engine's. A clamp around one plug wire of a twin sees one, so that + stays at 1; only a shared coil or all-cylinder harness sees them all. + - The defaults reproduce the old behaviour exactly, so a logger you never + configure reads identically to before. + - Knock-on benefit: the RPM thresholds the device acts on — auto-race entry, + and the camera's wake / start-recording / stop triggers — now mean what + they say on every engine, instead of firing at half the real RPM on a twin. + - The ignition-noise debounce follows the setting too, so the higher spark + rate of a multi-cylinder engine doesn't run into it and read low. + +### Fixed +- **The crossing animation no longer paints over menus and setup screens.** + The flag animation shown while the vehicle is inside a timing-line zone was + gated by a list of pages to *skip*, and that list never grew as pages were + added — so every screen introduced since got the animation drawn straight + over it whenever the lap timer said "in the zone". Standing still beside a + timing line is exactly when you are using those screens, so the camera + pages, replay browser, transfer menus, the main menu and the course creator + could all be interrupted by it. It is now shown only on the live racing + pages, which is the only place it ever meant anything. +- **Leaving a page while moving no longer throws you straight into race + mode.** Auto-race fires from the main menu above 500 RPM or 10 mph — but it + was checked the instant the menu appeared, so exiting any page while rolling + landed on the menu and entered race mode on the very next loop pass, about + four milliseconds later. The menu was never drawn; from the driver's seat + the device just did something on its own. Auto-race now waits for the menu + to have been **settled for three seconds** — nothing arriving, no buttons + pressed — which also stops it firing while you are actively navigating the + menu at speed. The ordinary case is untouched: a device parked on the menu + has been quiet for minutes before you drive off. +- **The GPS status page no longer reads as though a good fix is a bad one.** + Once a position fix came up, the page said `FIX (time sync)` — which parses + as a *kind* of fix (a time-only, position-less one) rather than what it + actually meant: position is good, the clock isn't ready yet. A perfectly + healthy device looked broken, and the natural response — power-cycling — + restarts the very countdown being waited on. It now reads `FIX ok UTC..`, + and a new line says which milestone is outstanding: `UTC: no date/time` + while the module has neither, `UTC: resolving <=12m` once it has the date + and time but not the fully-resolved UTC. That second one is the slow, normal + case — the receiver has to decode the leap-second parameters out of the GPS + navigation message, which only repeats every ~12.5 minutes, so a clean fix + in under a minute followed by several more minutes of waiting is expected, + not a fault. Weak signal makes it worse: tracking a satellite well enough to + range off it is a far lower bar than decoding its data bits cleanly. + - The constellation readout (`Mode:GPS-only`) still occupies that line once + the clock is locked, so nothing was lost — the diagnostic only takes the + space while there is something to diagnose. + +### Added +- **Create a course on the device — walk the cones, no laptop** (plan 0002 + §5). New **Create** entry on the main menu: pick the track you're at (or + start a new one), pick Circuit or Sprint, then capture each timing line by + standing at the cone and holding for three seconds. Autocross venues + re-lay their course every event, so this is what makes a sprint course + authorable at the event instead of the night before on a computer. Circuit + courses work the same way, with one fewer line to walk. + - **Points are averaged, not snapshotted.** "Save current pos" collects + fixes for three seconds — around 75 of them at 25 Hz — and stores the + mean. You're standing at the cone anyway, so the accuracy is free. Loose + fixes are dropped, and a hold that can't gather enough usable ones says + so and asks you to try again rather than quietly writing a bad line. + - **No typing on the device.** Names are generated from the GPS clock + (`N260803_1432`, unique to the minute) and are meant to be renamed in + the web app afterwards — which is also why the whole name fits the + track browser instead of being cut off. + - **Save is refused, with the reason, until the course is actually + usable**: a start line always, a finish line for sprint, and the sector + rules the web app's editor enforces — so a course written here can + always be opened and edited there later. + - **Back is a real undo.** Re-walk one endpoint of a line and change your + mind, and the stored line is untouched. + - Adding a course to an existing track rewrites the file through a temp + copy, so losing power mid-save can't leave a broken track file behind. + - Needs a GPS fix and time lock, and says so up front rather than at the + end of a walked course. +- **BLE sprint-track sync — `TSLIST` / `TSGET:` / `TSPUT:` / `TSDEL:`** + (plan 0002). The four existing track verbs gained `TS`-prefixed twins that + target `/TRACKS/SPRINT` instead of `/TRACKS`, so sprint courses can be + pushed and pulled over Bluetooth like circuit tracks — previously the new + folder was only reachable by USB mass storage. `TSLIST` answers with its + own `TSFILE:` / `TSEND` tokens so a sprint enumeration can never be mistaken + for a circuit one; the other three reuse the existing replies. The variants + share the circuit implementations via a `kind` parameter rather than + duplicating handlers, and the filename validator stays strict — the target + folder is chosen by the opcode and never parsed from the wire, so a client + still cannot path out of the tracks folders. + +### Changed +- **OTA image cap raised 320 KiB → 408 KiB by splitting the flash evenly** + (plan 0004, low-risk half). The app and the OTA staging region share one + 820 KiB stretch, and both must be able to hold the image — so the largest + installable image is half that span. The split was lopsided: 320 KiB of + staging against 500 KiB of app region, which capped OTA at 320 KiB while + leaving ~180 KiB of app region that no legal image could ever reach. The + staging base moves `0xA4000` → `0x8E000`, making it 408 KiB of staging + against 412 KiB of app. Nothing about the apply sequence, the `FW*` + protocol, or the CRC changes — only two constants, now backed by + `static_assert`s for page alignment and app-region fit. The beta image + went from 99.0% of the cap to 77.6%, which is what unblocks further + firmware work. **No web-app change is needed**: the client never enforced + a cap of its own, it relies on the device's `FWERR:SIZE`. + - Migration is one-way-safe: staging is chosen at apply time from the + *installed* firmware's constants, so a unit still on 3.0.x stages at the + old `0xA4000` and installs this build normally. And because the new app + region ends below the old staging base, an image built for this layout + can never collide with an old unit's staging region. + - Until every field unit has taken a build with the new layout, images + must stay under the legacy 320 KiB to remain OTA-installable on the + stragglers — CI now warns when a build crosses that line, naming the + USB `FWDFU` → UF2 path as the fallback for those units. + - The other half of plan 0004 — staging straight from the SD card, which + deletes the internal staging region and takes the cap to ~792 KiB — + still needs its hardware spikes and is **not** included here. +- **The crossing animation is generated, not stored — 2,048 B of flash + reclaimed.** The two "calculating" frames shown while inside a crossing + zone were hand-stored 1 KB PROGMEM bitmaps, but both were pure block + patterns: eight 16x16 px cells on the odd row bands, the two frames + offset by one cell. They are now emitted by the host-tested + `crossing_pattern` unit and drawn with `fillRect()`. The output is + proven byte-identical to the bitmaps it replaces — + `crossing_pattern_test.cpp` pins the original 2 KB as goldens and + rasterizes the generated rectangles against them, so what shows on the + device is unchanged. The bird splash stays a real bitmap (it is actual + artwork, not a pattern). + +### Added +- **`FWDFU` BLE command — reboot into UF2 mass-storage DFU** (backwards + compatible — MINOR). Sends `FWDFU:OK`, then reboots into the stock + Adafruit/Seeed bootloader's UF2 mode: the device shows up as a USB + drive and flashing is a drag-and-drop of a `.uf2` file — no web app, no + nRF Connect, **no OTA image-size cap** (the bootloader writes the app + region directly; no staging). This is the "pre-update" for the planned + SD-staged OTA rework (plan 0004): any device updated to a build + carrying `FWDFU` has a permanent, size-unlimited update path over a + USB cable. The bootloader itself is unchanged. +- **Sprint mode (plan 0002) — point-to-point run timing for autocross / + hillclimb events** (backwards compatible — MINOR). Sprint tracks live in + the new `/TRACKS/SPRINT/` SD folder (circuit tracks are untouched); the + detected track's folder selects the mode automatically. A sprint course + is a start line + a separate `finish_*` line (+ up to two optional + sector lines) with a sortable `date_created` stamp — the newest course + is always loaded (autocross venues re-lay the course every event; the + host-tested `sprint_select` unit owns the ordering + tiebreak rules). + Runs are timed by the DovesLapTimer library's new `SprintTimer` (BETA): + re-crossing the start cancels + restarts a run, finish crossings with no + active run are ignored, DNF records nothing. Between runs the device + stays in race mode with every page live — Current Lap and Pace show + `*waiting*` — and auto-idle becomes engine-aware in sprint (a running + engine at the start line never ends the session; each completed run + re-arms the grace period). Run times land in the existing lap history / + DOVEX laps line ("laps" verbiage is kept everywhere by design). +- **New `race_mode` setting** (`circuit` default / `sprint`): ONLY the + tiebreak when both a circuit and a sprint track are within detection + range — `circuit` yields to a sprint track whose newest course was + created today (event day); `sprint` always prefers the sprint track + (fixed layouts, e.g. a permanent rally course). Never overrides what is + actually detected. +- **DOVEX `race_mode` trailing header column** (`CIRCUIT`/`SPRINT`, empty + = circuit; same backwards-compatible append mechanism as + `device_name`): a loading helper so the webapp knows to interpret the + laps line as runs. Also fixed the header parser to preserve empty + middle fields — the old strtok splitter let a blank column shift every + later column left. +- **SensorEgg PW-ADV v2 support** (backwards compatible — MINOR). The + passive observer now accepts the egg's 16-byte v2 payload alongside v1: + a new aux intake-air thermistor (`Temp2`) and a real battery percent. + v1 eggs keep working (their `Temp2` parses as `nan`); a v2 frame + truncated below 16 bytes is rejected as corrupt rather than mis-read + as v1. The RX path previously truncated captures at 14 bytes — v2 + bytes never reached the parser — and now captures up to the largest + known layout with per-slot lengths. +- **`Temp2` race page**: second temperature page after Temp1, same + layout and staleness rules ('---' on stale/v1/invalid), with the egg's + battery percent as the subtext (`--` when unknown). Beta channel only, + like the rest of the SensorEgg POC. +- **DOVEX `Temp2` trailing column** (backwards compatible append, same + mechanism as `device_name` and `Temp1`/`Junction1`): aux intake-air + temp in °C, literal `nan` on stale link / v1 egg / invalid divider. + Never causes a GPS row to be skipped; written on every build so the + format does not fork by channel. + +### Fixed +- **Battery sleep instantly reboot-looped when the tach line idles low.** + System OFF entry hardcoded the tach wake as `SENSE-LOW` (assuming an + idle-high line), but the pickup circuit's Schmitt-inverter + + optocoupler output stage can idle low — DETECT was satisfied the + moment System OFF was entered and the device reset within a second + (USB-powered sleep was unaffected: a cable parks in the charging loop + and never enters System OFF). Runtime RPM counting can't expose the + polarity — a spark pulse yields one falling edge either way. Shutdown + now samples the parked tach line (15 reads over ~30 ms, majority vote + in the host-tested `wake_cause::tachIdleIsHigh()`) and arms SENSE for + the opposite level; ties/floating inputs keep the original SENSE-LOW. +- **`isnan()` was compiled out of egg paths by `-Ofast`** (the platform + builds sketches with `-ffinite-math-only`, which constant-folds + `isnan()` to false). The Temp1 race page rendered `lroundf(NaN)` + garbage (`-214748`) instead of `---` on a stale link; the DOVEX temp + columns survived only because `dtostrf(NaN)` happens to emit a string + the numeric guard rejects into the same `nan` fallback. Egg paths now + use `isNanF()` (bit-pattern check, `nan_bits.h`) which the optimizer + cannot fold. + ### Changed +- **CI/production builds pass `-DDOVES_DISABLE_DEBUG`** (new DovesLapTimer + BETA flag): the library's debug strings + print call-sites are dead + weight on hardware builds (no debug Stream is ever attached) and the + beta image had crossed 100% of the 320 KB OTA self-flash cap — it was + already at 98.2% before sprint mode. Dropping the resident debug pipeline + brings the image back under the cap with headroom instead of moving the + OTA staging layout. Local IDE debug builds are unaffected (the macro is + opt-in per build). +- 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). - Companion web app references updated from HackTheTrack.net to [LapWingData.com](https://LapWingData.com) in the README and project docs (site rename; no firmware behavior change). @@ -820,7 +1044,7 @@ all breaking under this project's semver policy. before the app region is ever erased, and a GPREGRET bootloader-recovery flag so an interrupted swap leaves the unit re-flashable over BLE. The request characteristic max length was raised to 244 to carry ~240-byte - image chunks. See `docs/firmware-ota-phase0.md` for the apply-strategy + image chunks. See `docs/plans/0000-firmware-ota-phase0.md` for the apply-strategy decision and the hardware spikes that gate it. (The previously added `BLEDfu` buttonless Secure DFU service remains registered for the one-time fleet-migration push via the nRF Connect mobile app.) diff --git a/CLAUDE.md b/CLAUDE.md index 5d97d11..4bb10b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1256 +1,1419 @@ -# BirdsEye - Project Guide - -> **MAINTAINERS: Keep this file updated when adding/removing files, changing pin -> assignments, modifying subsystem interfaces, or altering the build configuration. -> This file is loaded into Claude's context window on every session and must -> accurately reflect the current state of the project.** - -## Maintaining the Quality Bar - -This project went through a deliberate hardening pass (tests, CI, static -analysis, security, release pipeline, docs). **Keep it there.** When making -any change — whether you're Claude or a human contributor — hold the line: - -- **Add tests when possible.** New pure logic (math, parsing, validation, - formatting, anything Arduino-free) belongs in a `BirdsEye/*.{h,cpp}` unit - with a matching `tests/_test.cpp`. If you're touching existing logic - that *could* be a pure unit but isn't yet, prefer extracting it so it can - be tested rather than leaving it tangled in an `.ino`. Don't add untested - pure logic when a test is feasible. -- **Keep the CHANGELOG updated.** Any user-visible change gets an entry under - `[Unreleased]` in `CHANGELOG.md` (Added / Changed / Removed / Fixed / - Security). Flag breaking changes explicitly — they drive the next version - number per the semver policy in that file. -- **Keep CI green and meaningful.** The checks (compile-sketch + flash-size - gate, arduino-lint, unit-tests, clang-tidy, coverage) must pass. Fix the - root cause rather than loosening a check; if a clang-tidy finding is a - genuine false positive, suppress that one line with `// NOLINT(check)` and - a reason, never by disabling the check globally. The coverage floor - (`COVERAGE_MIN` in `coverage.yml`) is intentionally low — raise it as - coverage grows; don't lower it to pass. -- **Keep the docs in sync.** Update this file's File Map and the relevant - subsystem section, plus `ARCHITECTURE.md`, when you add/remove a module or - change a subsystem interface. Stale docs are worse than none. -- **Hold the conventions.** No Arduino `String` in hot paths, all SD access - through the mutex, never `analogRead()`, `TIMER3` reserved, ISRs trivially - short. See *Development Conventions* at the bottom for the full list. -- **One concern per PR.** Keep refactors, behavior changes, and new tests in - separate PRs so each is reviewable and revertable on its own. -- **Once CI is green on a PR, STOP.** Report the green status once and end. - Do NOT schedule recurring re-checks, polling wake-ups, or "babysit" - timers on a passing PR — they burn the owner's session usage confirming - nothing changed. Watch a PR only when explicitly asked, and even then a - green CI run ends the loop. - -The goal: every change should leave the codebase at least as professional as -it found it. If a shortcut would lower the bar, flag it instead of taking it. - -## What Is BirdsEye? - -A high-precision GPS lap timer and data logger for motorsports / track days. -Built on the **Seeed XIAO nRF52840 Sense** (ARM Cortex-M4, 256 KB RAM, BLE 5.0, onboard LSM6DS3 IMU). - -Core capabilities: -- 25 Hz GPS lap timing with sector support (DovesLapTimer library) -- **"Just Drive" auto-detection** via CourseManager: automatic track - proximity matching, course detection, and Lap Anything fallback -- RPM monitoring via inductive tachometer pickup -- Accelerometer logging (g-force X/Y/Z) via onboard LSM6DS3 IMU -- DOVEX data logging with reserved 1 KB header (crash-safe GPS data) -- 8+ display pages on a 128x64 OLED (3 Hz refresh) -- Bluetooth LE file download to companion apps / LapWingData.com -- On-device session replay: instant DOVEX header replay -- **Insta360 X4 camera auto-record**: emulates the Insta360 GPS Remote as a - pure BLE peripheral — wakes the camera on engine start, records via a ce82 - shutter toggle, stops and powers off automatically (see subsystem 13) -- **SensorEgg wireless EGT (POC)**: passive BLE observer receives the - DovesSensorEgg thermocouple pod's advertising broadcasts (`PW-ADV-1`), - logs `Temp1`/`Junction1` DOVEX columns + a Temp1 race page (subsystem 14) - ---- - -## File Map - -All sketch sources live in `BirdsEye/` so the folder name matches the -`.ino` filename Arduino IDE expects. Each module has both a `.ino` -(implementation) and a `.h` (public interface, documentation). - -### Sketch Sources (`BirdsEye/`) - -| File | Purpose | -|---|---| -| `BirdsEye.ino` | Entry point: globals, `setup()`, `loop()`, state machine, course/timer helpers | -| `project.h` | Shared types (`ButtonState`, `TrackLayout`, `TrackManifestEntry`, `TrackMetadata`), debug macros, `MAX_*` constants | -| `display_config.h` | Display driver abstraction (SH110X vs SSD1306 toggle) | -| `gps_config.h` | GPS configuration constants (baud rate, nav rate, serial port) | -| `images.h` | PROGMEM bitmap data (splash screen, animations) | -| `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 | -| `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 | -| `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) | -| `settings.{h,ino}` | Persistent JSON settings on SD (`/SETTINGS.json`), `getSetting()`/`setSetting()` | -| `tachometer.{h,ino}` | Falling-edge ISR on D0, Kalman-filtered RPM calculation | -| `usb_msc.{h,ino}` | USB Mass Storage (TinyUSB MSC): SD card as a drag-and-drop drive (see subsystem 12) | - -### Pure-Logic Units (`BirdsEye/*.{h,cpp}`) - -Arduino-free `.cpp` files — compiled into the firmware AND into the -host test harness (`tests/`). No Arduino headers, so they build on a -desktop toolchain. This is where logic worth unit-testing lives. - -| File | Purpose | -|---|---| -| `haversine.{h,cpp}` | Great-circle distance in miles (track proximity) | -| `gps_stats.{h,cpp}` | GPS pipeline drop accounting: expected-vs-received PVT window math (exact fractional carry, 1-frame jitter slack, capped credit, rate-switch suppression) feeding the debug-page `Drops` counter | -| `gps_time.{h,cpp}` | Leap-year/Unix-epoch math, `u64ToDecimalString` | -| `gps_validation.{h,cpp}` | PVT sample sanity gate + dtostrf-output check | -| `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 | -| `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) | -| `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-1` advertising payload parser (magic filter, int16 deci-°C decode with `0x8000`→NaN sentinel, flags, sequence) + wrap-safe 1 s staleness rule + passive-scan tuning constants | -| `wake_cause.{h,cpp}` | Boot wake-cause decode: RESETREAS + GPIO LATCH register snapshots → tach / button / USB / watchdog / soft-reset / cold boot (System OFF shutdown, subsystem 10) | -| `gps_status_page.{h,cpp}` | GPS status boot page state machine: hold, 3 s auto-close after fix+timeValid, button skip, exit destination (menu vs race), idle → shutdown | -| `sd_format_page.{h,cpp}` | SD format-confirm boot page state machine: Select held 3 s continuously → format (release restarts the full window; other buttons never confirm), 5 min idle → shutdown | -| `sat_bars.{h,cpp}` | Status-page satellite signal bars: NAV-SAT CNO selection (used-in-nav first, strongest first) + bar x/w/h layout math for the 128×~30 px bottom half | - -### Simulator (`BirdsEye/sim/`) - -Host build of the REAL firmware TU under the `SIM` flag (browser/WASM -target in a later phase; native + CI today). The `.ino` sources compile -unmodified — all sim behavior lives in `BirdsEye/sim/` or behind `SIM`. -See `ARCHITECTURE.md` → *Simulator* and the phased plan in the simulator -handoff spec. - -| Path | Purpose | -|---|---| -| `sim_main.cpp` | Single TU replicating Arduino's .ino concatenation (bluetooth/camera_ble/usb_msc/firmware_ota deliberately absent) + host glue (`sim_init`/`sim_step_millis`/buttons/state peeks) | -| `sim_prototypes.h` | Hand-written stand-in for Arduino's auto-generated prototypes | -| `virtual_clock.{h,cpp}` | Host-advanced virtual time; `delay()` consumes it; no wall clock (determinism) | -| `arduino_shim/` | Arduino core + nRF52 registers/SoftDevice/FreeRTOS surface, Wire/SPI, LSM6DS3 (settable), Bluefruit types | -| `busio_shim/` | The display stack's entire hardware boundary: `Adafruit_I2CDevice` whose `begin()` is true and whose writes discard — everything above it (GFX/GrayOLED/SH110X) is the REAL pinned library, so the framebuffer is pixel-perfect | -| `sdfat_shim/` | In-memory VFS implementing the exact SdFat subset the firmware calls; preloads `assets/` (fixed SETTINGS.json + OKC track) via cmake-embedded byte arrays | -| `stubs/` | No-op surfaces of the excluded modules + SparkFun GNSS driver (real header, stubbed methods — PVT is injected directly into `onPVTReceived()`) | -| `frame_hash.{h,cpp}` | FNV-1a 32 over the 1024-byte framebuffer (golden fixtures, viewer dirty-check, future HIL tap) | -| `png_dump.{h,cpp}` | Dependency-free PNG writer (stored-deflate + repo crc32) for eyeballing frames | -| `native_main.cpp` | Phase-1 driver: boot → skip GPS status page → 60 s soak, state prints | -| `golden_main.cpp` | Phase-2 driver: scripted real-menu walk capturing 8 golden page hashes (`golden/golden_hashes.txt`; regenerate with `--print`, eyeball with `--dump`) | -| `oracle_main.cpp` | Phase-3 driver: lap-timing oracle. Default = synthetic constant-speed OKC circle (period exact by construction) through the whole real pipeline (boot page → race entry → proximity detect → CourseDetector "Normal" → laps ±40 ms); `--dovex ` replays a hardware log against its own header laps; diagnostic modes: `--dovex-noheader ` replays a header-less (crashed-session) log and prints live detection/lap state instead of asserting, `--two-session [break-min]` reproduces a full track day (synthetic session 1 → auto-idle end → parked break with GPS drift → real-log session 2) to test CourseManager state carryover | -| `fixtures/okc_tillotson_1.dovex` | Hardware-recorded OKC session (13 laps) — the `--dovex` oracle's CI fixture; the sim reproduces its header lap list to the exact millisecond (also the `--two-session` carryover test's session 2) | -| `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/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) | - -### Non-Source - -| Path | Contents | -|---|---| -| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.2.0` and build the all-flags-off defaults | -| `tests/` | Host doctest harness (CMake) for the pure-logic units | -| `CHANGELOG.md` | Keep-a-Changelog history; release workflow ties to version tags | -| `ARCHITECTURE.md` | Human-facing architecture narrative (subsystems, design decisions) | -| `CONTRIBUTING.md` | Build/test/PR workflow and code conventions | -| `SECURITY.md` | Private vulnerability reporting + known posture | -| `.github/ISSUE_TEMPLATE/` | Bug report + feature request templates | -| `.github/PULL_REQUEST_TEMPLATE.md` | PR checklist | -| `SDCARD/TRACKS/` | Example track JSON files | -| `CASE/` | 3D-printable enclosure STLs | -| `TACHOMETER/` | Tachometer circuit documentation | -| `README.md` | User-facing project documentation | -| `LICENSE` | GPL v3 | - ---- - -## Hardware & Pin Map - -| Pin | Function | Detail | -|---|---|---| -| Serial1 RX/TX | GPS UART | u-blox SAM-M10Q, 57600 baud | -| I2C SDA/SCL | OLED display | 128x64, address 0x3C, 400 kHz | -| I2C SDA/SCL | LSM6DS3 IMU | Onboard accelerometer/gyro (Sense variant), address 0x6A | -| SPI MOSI/SCK/MISO | SD card | 2 MHz SPI clock (EMI hardened), CS grounded on PCB | -| D1 | Button 1 (Left) | INPUT_PULLUP, RC filter recommended | -| D2 | Button 2 (Select) | INPUT_PULLUP, RC filter recommended | -| 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 | - ---- - -## Subsystem Architecture - -### Main Loop Flow (`loop()`) - -``` -loop() ~250 Hz - ├─ GPS_LOOP() checkUblox, feed CourseManager, log DOVEX - ├─ TACH_LOOP() re-enable ISR after debounce, apply EMA filter - ├─ ACCEL_LOOP() read LSM6DS3 accelerometer X/Y/Z (g-force) - ├─ BLUETOOTH_LOOP() stream file chunks if transfer active - ├─ SENSOREGG_LOOP() drain SensorEgg scan buffer → Temp1/Junction1 - ├─ trackDetectionLoop() haversine scan → create CourseManager on match - ├─ checkForNewLapData() reads from active timer (CourseManager or lapTimer) - ├─ checkAutoIdle() 60s at <2mph → end session (yields while camera recording) - ├─ updateGpsLockHold() pin user to tach page until GPS time lock - ├─ CAMERA_LOOP() step Insta360 auto-record FSM (GPS/tach fresh) - ├─ cameraConsumeAutoStop() camera 30s-engine-off stop → endRaceSession + menu - ├─ calculateGPSFrameRate() 1-second PVT counter - ├─ readButtons() multi-sample debounce + edge detection - ├─ gpsStatusPageLoop() boot GPS status page: GPS re-detect + hold/auto-close/exit - ├─ sdFormatPageLoop() boot SD format page: hold-Select confirm → format + reboot - ├─ displayLoop() pages read from active timer helpers - ├─ autoRaceModeCheck() RPM>500 or speed>=10 → enter race from menu - └─ resetButtons() clear pressed flags -``` - -### 1. GPS & Lap Timing (`gps_functions.ino`, `gps_config.h`) - -- Uses SparkFun u-blox GNSS v3 library with UBX binary protocol. -- `myGNSS` (SFE_UBLOX_GNSS_SERIAL) is stack-allocated in `BirdsEye.ino`. -- **Boot probe ladder** (`GPS_SETUP()` → `gpsSetupProbe()`): the SAM-M10Q - has no flash and every boot is a cold start (sleep = System OFF), so the - module can be in backup mode, configured-57600, or factory-9600. Setup - sends the `0xFF` backup-wake byte FIRST (harmless if awake), probes 57600 - (warm case ≈ instant), falls back to 9600 + `setSerialRate`, and only - pays a 1.5 s cold-boot delay + one retry when nothing answers. Config is - applied via the VALSET API (GPS-only constellation, automotive dynamic - model) at the current rate target, PVT (+ NAV-SAT when wanted) callbacks - registered, and the **5 s PVT-arrival watchdog is armed at boot too** — - a module that answers the ping but streams nothing gets - `GPS_BAUD_RECOVERY()`. A GPS missing entirely is re-probed by - `GPS_STATUS_RETRY_LOOP()` (3×, 10 s apart) while the status page shows - `NOT DETECTED` / `CHECK WIRING`. -- **Two rate modes** (`gpsEnterStatusMode()` / `gpsEnterRaceMode()`): boot - starts in status mode — `GPS_NAV_RATE_STATUS_HZ` (5 Hz) with UBX-NAV-SAT - at ~1 Hz feeding per-satellite CNO into `gpsSatCnos[]` for the status - page's bars. Leaving the page switches to race mode: `GPS_NAV_RATE_HZ` - (25 Hz) PVT-only. `GPS_RECONFIGURE()` and every wake/recovery path - re-assert the *current* targets (`gpsNavRateTarget` / `gpsNavSatWanted`) - — never hardcode a rate. -- **GPS serial buffer — two buffers, two failure modes**: A 4 KB RAM ring - buffer (`gpsRxBuf`) sits between Serial1 and the SparkFun library. A - TIMER3 ISR drains Serial1 into this buffer every 5 ms - (`GPS_DRAIN_INTERVAL_US`), independent of the main loop — this covers - *downstream* stalls (SD GC pauses can block the loop 100 ms–2 s; the - ring holds ~1.6 s). *Upstream*, only the core's Serial1 RX ring absorbs - bytes while SoftDevice radio ISRs (prio 0–2, unmaskable) defer the - prio-3 TIMER3 handler — that ring is grown 64→256 B via the - **required `-DSERIAL_BUFFER_SIZE=256` build flag** (~44 ms of slack at - 57600 baud; `project.h` static_asserts it, CI passes it in all three - workflows, local setup in CONTRIBUTING.md "Local build flags"). Both - overflow points and the worst TIMER3 deferral are counted — see the - `gpsStats*()` accessors, the `gps_stats` pure unit, and the GPS debug - page (first page of the race rotation). The SparkFun library reads from - the buffer via `GpsBufferedStream` (a `Stream` wrapper). During - `GPS_SETUP()` (before timer starts), reads pass through to Serial1 - directly. Timer stopped on shutdown/charging entry, restarted on the - charging-loop resume (`GPS_WAKE()`). -- `GPS_LOOP()` calls `checkUblox()` + `checkCallbacks()`. The registered - `onPVTReceived()` callback fires with the full `UBX_NAV_PVT_data_t` struct, - populates `gpsData`, and sets `gpsDataFresh` flag for downstream processing. -- PVT data is cached in `gpsData` struct (GpsData) for access by display - pages and other subsystems. -- Feeds lat/lng/alt/speed into `CourseManager.loop()` which handles - course detection, Lap Anything fallback, and sector timing internally. -- Logs validated data rows to SD as DOVEX: reserved 1 KB header, - CSV data after byte 1024 (9-check validation pipeline). -- **Log file creation requires a valid time lock**: `onPVTReceived()` sets - `gpsData.timeValid` only when the module asserts `validDate + validTime + - fullyResolved` (and folds `gnssFixOK` into `gpsData.fix`). The log file is - not created from the module's placeholder date — this prevents garbage-named - files (e.g. `20210307_0000.dovex`) that collided every boot and corrupted on - reboot. Until the lock arrives, `updateGpsLockHold()` pins the user to the - tachometer page (engine running; the page shows `WAITING GPS LOCK..` so the - pin never reads as a crash) and logging waits; a failed open is retried - at 1 Hz and a write failure stops logging — **none fault out of race mode**. - The pin **releases after 10 s of engine-off** (session stays active; a - restart re-latches), and while it is active `checkAutoIdle()` may end the - fileless session even if the camera is recording — the recording yield - otherwise left no session-ender and the device looked bricked until a - power cycle (2026-07-19 field incident). -- Time helpers: `getGpsTimeInMilliseconds()`, `getGpsUnixTimestampMillis()`. -- 64-bit timestamps are manually converted to strings (Arduino lacks `%llu`). -- **Wake hardening**: `GPS_WAKE()` (charging-loop resume) clears stale - `gpsDataFresh`/`gpsData.fix`, re-applies VALSET config via - `GPS_RECONFIGURE()`, and arms the same 5 s PVT watchdog as boot. - If no PVT arrives, `GPS_BAUD_RECOVERY()` re-negotiates baud (9600→57600) and - reconfigures. The SAM-M10Q has no flash; all config is volatile RAM only. - -### 2. Tachometer (`tachometer.ino`) - -- ISR `TACH_COUNT_PULSE()` fires on falling edge of D0. -- 3 ms minimum pulse gap (supports up to ~20 000 RPM). -- **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 - `tachRingOverflow` instead of lapping the consumer during SD GC stalls; - `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). -- Time-based debounce only (3 ms). Old volatile flag gate removed — ISR - body is trivially fast (<1 µs) and cannot cause interrupt storms. -- `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. -- The engine-start wake from shutdown is NOT this module's job: System OFF - wakes on the tach pin's GPIO SENSE and the boot decodes the LATCH bit via - `wake_cause` (the old `tachHavePeriod` latch + `TACH_SLEEP()` are gone). - -### 3. Accelerometer (`accelerometer.ino`) - -- Onboard LSM6DS3 6-axis IMU on XIAO nRF52840 Sense (I2C address 0x6A). -- Shares I2C bus with OLED display (0x3C) — different addresses, no conflict. -- `ACCEL_SETUP()` initializes IMU; sets `accelAvailable` flag. Graceful - degradation if IMU not present (non-Sense board). -- `ACCEL_LOOP()` reads `readFloatAccelX/Y/Z()` into global floats every - main loop iteration (~250 Hz). Values in g-force (1g = 9.81 m/s²). -- No filtering — raw g-force is the standard unit for motorsports data. - -### 4. SD Card & Logging (`sd_functions.ino`) - -- SdFat library, FAT16/32, 2 MHz SPI (reduced from default for EMI hardening). - Raised to 8 MHz (`SD_SPI_SPEED_FAST`) for the duration of a BLE or USB - transfer via `sdSetTransferSpeed(true)` and reverted afterward — transfers - happen parked (motor off), so the EMI rationale doesn't apply. Re-`SD.begin()` - is the runtime clock switch; it falls back to 2 MHz if the fast re-init fails. -- Track files live under `/TRACKS/*.json` (ArduinoJson 6 parsing). -- **Blank-card self-provision**: `buildTrackList()` creates `/TRACKS` - when missing (SdFat's `open()` never creates parent dirs), and - `processTrackUpload()` mkdirs it again before every upload — so a - factory-blank soldered-in module can sync tracks over BLE on first boot. -- **On-device format** (`sdPerformFormat()` + the host-tested - `sd_format_page` unit): when `SD_SETUP()` finds the card answers at the - SPI level (`SD.cardBegin` + `sectorCount()`) but `volumeBegin()` fails - — the volume re-check matters: a transient card-level failure with a - healthy FAT must remount, not be offered an erase — boot lands on - `PAGE_SD_FORMAT` (buttons live, unlike FAULT). Hold Select ALONE 3 s - to format FAT16/32 via SdFat's `SD.format()` (zero new RAM; WDT fed - through the formatter's Print callbacks; 8 MHz clock only when the - engine isn't turning — EMI corrupts writes silently — else 2 MHz), - then `/TRACKS` is provisioned (`sdEnsureTracksFolder()`) and the - device reboots clean. The confirm can never fire from the wake press - (Select must be seen released once) nor beat the Select+side reboot - combo (a held side button disarms it). A paired camera is stopped and - powered off (`CAMERA_SLEEP()`) before the format blocks the loop, since - the ending hard reset runs no shutdown teardown. Format failure returns - to the confirm page (marked `FAILED - retry`; fresh full hold to retry — - keeps the idle-timeout battery protection the FAULT dead-end lacks); - a dead/absent card never offers the format. 5 min idle → shutdown - (deferred while the engine runs), and a charging-loop resume with the - card still unformatted returns to the format page, not the menu. -- **Dual JSON format**: `parseTrackFile()` auto-detects root type: - - **Object** (LapWingData format): `longName`, `shortName`, - `defaultCourse`, `courses[]` with `lengthFt`. - - **Array** (older format, still accepted): bare array of course - objects, metadata blank, `lengthFt = 0`. CourseDetector can't - rank by distance without `lengthFt`, so these tracks fall back - to Lap Anything mode. -- **Track manifest**: `buildTrackList()` also builds an in-RAM - `trackManifest[]` (up to 200 entries) with first lat/lon per track - for haversine proximity matching. ~10 KB RAM. -- **SD access arbitration** prevents concurrent access: - - `acquireSDAccess(mode)` / `releaseSDAccess(mode)` - - Modes: `SD_ACCESS_NONE` (0), `LOGGING` (1), `REPLAY` (2), - `BLE_TRANSFER` (3), `TRACK_PARSE` (4), `USB_MSC` (5), `FORMAT` (6) — - values and grant/deny rules live in the host-tested `sd_access_policy` - pure unit. - - `TRACK_PARSE` **nests under `LOGGING`** without taking ownership - (`ownerAfterAcquire`): track detection and settings reads are brief, - same-task, and use their own `File` objects, so they are safe alongside - the session-long logging hold. Before this rule, any boot where the log - file was created before the 1 Hz track-detect parse had the parse - denied and silently fell back to Lap Anything for the whole session. - `USB_MSC` is a normal exclusive holder (held for the whole USB - mass-storage session; see subsystem 12). - - Transitions are **atomic**: the check-then-set runs inside a FreeRTOS - critical section (`taskENTER_CRITICAL`, BASEPRI-masked so SoftDevice - radio interrupts are unaffected) because the Bluefruit callback task - and the main loop share the owner flag. - - Belt-and-suspenders only: all SD-touching BLE work is deferred to the - main loop (see subsystem 6), so SdFat itself is single-task. -- Data flushes every 10 seconds during logging. - -### 5. Display & UI (`display_ui.ino`, `display_pages.ino`, `display_config.h`) - -- Driver selected at compile time (`USE_1306_DISPLAY` define). -- Button debounce: 3 samples at 500 us intervals, 200 ms refire lockout. -- All lap times render via the host-tested `lap_format::formatLapTime()` - (ms → `M:SS.mmm`, always 3-digit ms; zero-minutes styles: `kOmit` for - replay results, `kShow` for the lap list, `kSpace` column-stable for the - big-font live pages). Never hand-roll the `60000`/`%1000` math inline. -- Pages are integer constants; key pages: - - 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 — first in - the rotation) through `LOGGING_STOP`; `SENSOR_TEMP` (7, SensorEgg - Temp1) sits after `TACHOMETER` (6) — non-endurance only, and only - when `BIRDSEYE_ENABLE_SENSOREGG` is set (beta). With the POC off (the - master/release default) the block closes up behind the tach page and - `LOGGING_STOP` is 12 instead of 13, 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, - `PAGE_USB_STORAGE` (-5) USB drive active. - - BLE: `PAGE_BLUETOOTH` (-2). - - Camera: `PAGE_PAIR_CAMERA` (-6) pairing / paired-status management, - `PAGE_CAMERA_SERIAL_ENTRY` (-7) manual 6-char serial entry fallback, - `PAGE_CAMERA_TEST` (-10) bench test menu (paired-only manual controls). - - Errors: `PAGE_INTERNAL_WARNING` (100), `PAGE_INTERNAL_FAULT` (105), - `PAGE_SD_FORMAT` (106, card responds but FAT won't mount — driven by - `sdFormatPageLoop()`, buttons live unlike FAULT). - -### 6. Bluetooth (`bluetooth.ino`) - -- **Shared BLE core, used by transfer and the camera** (see subsystem 13): - the one-time `bleCoreEnsureInit()` runs `Bluefruit.begin(1, 0)` — one - peripheral connection, no central (both the transfer service and the - camera remote are peripheral roles) — configures Just-Works bonding, and - registers *every* GATT service (DFU, DIS, file service, camera remote via - `cameraBleRegisterServices()`) before any advertising starts. - `BLE_SETUP()` / `BLE_STOP()` are now just the transfer-mode owner - transitions on top of that core. -- **Radio ownership (`BleOwner`)**: the single advert set + peripheral - connection slot are shared between the transfer service and the camera - remote. `bleOwner` (`NONE` / `TRANSFER` / `CAMERA`, enum in `project.h`, - variable in `BirdsEye.ino`) records the current owner; the shared - connect/disconnect callbacks route on it, so a camera link can never - trigger the transfer auto-reboot and file commands are ignored unless - the transfer service owns the radio. `bleActive` / `bleConnected` keep - their transfer-only meanings — camera mode never sets them. Owner - transitions happen only on the main loop, never in a Bluefruit callback. - - **Reboot gate (`bleTransferEngaged`)**: the radio has one BD_ADDR, so a - bonded camera can connect to the *transfer* advert and be routed as "the - phone". The auto-reboot-on-disconnect is therefore gated on the peer - having actually written the file/settings/OTA service — a camera that - only vets our GATT and drops never reboots the logger out of a transfer - session (and held no SD, so there's nothing to tear down). - - **Advert teardown**: `BLE_STOP()` disarms `restartOnDisconnect(false)` - *before* its async disconnect, so Bluefruit's core handler can't restart - a stale ownerless transfer advert after the stop (which would let a phone - reconnect into a mute session and occupy the slot camera auto-record - needs). -- BLE service UUID `0x1820`. -- Characteristics: file list (0x2A3D), file request (0x2A3E), - file data (0x2A3F), file status (0x2A40). -- **OTA + version services** (registered in `bleCoreEnsureInit()`): - - `BLEDfu bledfu` — buttonless Nordic Secure DFU. A companion - (DovesDataViewer over Web Bluetooth) writes the "enter bootloader" - command; the board reboots into the bootloader's Secure DFU mode and - receives a new firmware image over the air — no reset double-tap. The - bootloader validates the signed/CRC'd DFU `.zip` before writing, so a - bad/mismatched image is rejected rather than bricking the device. The - board has no internet radio (BLE only): the companion downloads the - GitHub release `.zip` and force-feeds it — the bootloader never - "chooses" a file. - - `BLEDis bledis` — Device Information Service (0x180A). Publishes - `FIRMWARE_VERSION` (from `project.h`) via the Firmware Revision - characteristic (0x2A26) so the companion can compare against the latest - GitHub release and decide whether to offer an update. The Model string - is `"BirdsEye-" FIRMWARE_VARIANT` (`BirdsEye-sense` / `BirdsEye-nonsense`) - — equal to the release asset prefix, so the companion maps model → - 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). -- **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 - deferred state) is only parsed/validated in the BLE callback and is - executed by `BLUETOOTH_LOOP()` on the main loop. Listings hold the SD - lock for the whole directory walk; `DELETE` takes the lock and refuses - (`BUSY`) while a transfer is streaming. One file command may be queued - at a time — a second gets the protocol's busy reply (`BUSY` / - `TERR:BUSY`). -- **Filename validation**: every BLE command carrying a filename - (`GET:`, `DELETE:`, `TGET:`, `TPUT:`, `TDEL:`) runs the name through - `filename_validator::isValidFilename()` BEFORE any `SD.open()` / - `SD.remove()` / `"/TRACKS/%s"` path build. Rejects path traversal - (`..`, leading `.`), separators (`/`, `\`), and FAT-unsafe bytes. - `GET`/`DELETE` reject with `ERROR` / `NOT_FOUND`; track commands - reject with `TERR:BAD_NAME`. -- **Settings commands** (via `fileRequestChar` / `fileStatusChar`): - - `SLIST` → `SVAL:key=value` per entry, then `SEND` - - `SGET:key` → `SVAL:key=value` or `SERR:NOT_FOUND` - - `SSET:key=value` → `SOK:key` or `SERR:reason` - - `SBUSY` returned if a command is already pending. - - Uses deferred execution: BLE callback copies command into buffer, - `BLUETOOTH_LOOP()` processes it in main loop for thread-safe SD access. -- **Track management commands** (via `fileRequestChar` / `fileStatusChar`): - - `TLIST` → `TFILE:name.json` per file, then `TEND` - - `TGET:name.json` → reuses existing file transfer (`SIZE:N` → data chunks → `DONE`) - - `TPUT:name.json` → `TREADY` → app sends data chunks → `TDONE` → `TOK` - - `TDEL:name.json` → `TOK` or `TERR:NO_FILE` - - Upload uses a 4096-byte static RAM buffer; `TERR:TOO_LARGE` if exceeded. - - Error responses: `TERR:SD_BUSY`, `TERR:BUSY`, `TERR:WRITE_FAIL`, `TERR:NO_FILE`, `TERR:BAD_NAME`. - - Upload/delete state machines: BLE callback sets flags, `BLUETOOTH_LOOP()` - calls `processTrackUpload()` / `processTrackDelete()` for thread-safe SD - access. Both call `buildTrackList()` after success. -- **Firmware OTA commands** (`FW*`, handled by `firmware_ota.ino` — see - subsystem 11): `FWBEGIN`/`FWPUT`/`FWDONE`/`FWAPPLY`/`FWABORT`. The BLE - callback dispatches them via `fwIsCommand()`/`fwHandleCommand()` and routes - 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 - 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 - and boot the old firmware, so the apply is left to `FW_OTA_LOOP()` (called - later in the same `BLUETOOTH_LOOP()`), which owns its own reset. - -### 7. Replay (`replay.ino`) - -- Instant DOVEX header-only replay. `parseDovexHeader()` reads the - metadata line and the lap-times line from the first 1 KB of the - file, populates `dovexReplay*` globals + `lapHistory[]`, and the - results page renders directly from those — no file streaming, no - re-running the lap timer. Only `.dovex` files shown in the browser. -- `haversineDistanceMiles()` lives here too; the track-detection loop - in `BirdsEye.ino` uses it for proximity matching. - -### 8. Settings (`settings.ino`) - -- Persistent JSON key-value store at `/SETTINGS.json` on SD card. -- `SETTINGS_SETUP()` called once from `setup()` after SD init; creates - default file on first boot (random BLE name + PIN + racing-word device name). -- **Auto-populate**: `ensureDefaultSettings()` checks for missing keys on - boot and adds them with defaults. Existing values are never overwritten. -- `getSetting(key, buf, bufSize)` reads a value into a caller-provided - buffer. Returns `true` if found, `false` on any failure (buf set empty). - 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 - 4096-byte buffer. -- Total RAM cost: ~1 KB (512-byte file buffer + 512-byte JSON document). - -### 9. CourseManager Integration - -- **CourseManager** (`courseManager` global pointer): created when a track - is detected via haversine proximity match, or with `courseCount=0` for - immediate Lap Anything activation. -- **Track detection flow** (`trackDetectionLoop()`): - 1. Valid GPS time lock acquired → DOVEX log file created (see GPS section). - 2. Scans `trackManifest[]` via haversine, throttled to 1 Hz (the scan - is O(N) software-double math; `gpsData.fix` stays true between PVT - updates, so an unthrottled scan ran every ~250 Hz loop iteration). - 3. Closest match within 5 miles → parse full JSON, build `TrackConfig`. - 4. Create `CourseManager` with settings-configurable thresholds. - 5. CourseManager handles course detection + Lap Anything fallback. - 6. No tracks / no match → `CourseManager(courseCount=0)` → Lap Anything. -- **Active timer abstraction**: helper functions (`activeTimerLaps()`, - `activeTimerBestLapTime()`, etc.) provide a unified interface for display - pages. They check CourseManager's active timer (DovesLapTimer or - WaypointLapTimer) and return appropriate values. -- **Auto-race** (`autoRaceModeCheck()`): from main menu, if RPM > 500 or - speed >= 10 mph, jumps directly to race mode. -- **Auto-idle** (`checkAutoIdle()`): if speed < 2 mph for 60 seconds - continuously, writes DOVEX header, closes file, cleans up CourseManager, - and returns to main menu. - -### 10. Shutdown (System OFF) - -"Sleep" is a full power-down: nRF52 **System OFF** (~µA), designed so the -hardware needs no power switch. Wake = chip reset = fresh `setup()`. - -- **Entry** (`enterShutdown()`): long-press left+right (5 s) on main menu, - 5-min menu idle, the GPS status page's idle timeout, the SD format - page's idle timeout (deferred while the engine runs, so a tach-wake - with a bad card doesn't power-cycle all session), or — only with - `BIRDSEYE_ENABLE_ONBOARD_CHARGING` — USB present on the main menu after - 60 s of button inactivity (`USB_MENU_CHARGE_IDLE_MS` — not immediate, so - a charging-loop button wake doesn't bounce and the device stays usable - for replay/transfer while plugged in). With onboard charging off (the - default) that USB trigger is compiled out entirely: the firmware isn't - managing the charge current, so a cable is no reason to cut the menu - short — the plain 5-min idle still fires and still parks on VBUS. -- **Teardown order** (wdtPet-bracketed — `CAMERA_SLEEP()`'s 3 s ce82 - power-off hold is the longest step under the armed ~4 s WDT): end race - session → `CAMERA_SLEEP()` → `BLE_STOP()` if active → `DISPLAY_SLEEP()` - → `GPS_SLEEP()` (u-blox software backup, µA, config retained while - powered; TIMER3 stopped) → IMU power rail off. -- **System OFF entry** (`shutdownSystemOff()`, no return): wait for the - entry combo's buttons to release (a held button = SENSE satisfied = - instant wake-reset), configure `nrf_gpio_cfg_sense_input(pull-up, - SENSE-LOW)` on the tach pin + all 3 buttons (P-numbers via - `g_ADigitalPinMap`, never hardcoded), clear the GPIO LATCH registers - (a set latch = pending DETECT = instant re-wake), clear pending FPU - exceptions, then `sd_power_system_off()` when the SoftDevice is enabled - (BLE is lazy — check `sd_softdevice_is_enabled()`) else raw - `NRF_POWER->SYSTEMOFF`. **GPREGRET is untouched** — register 0 belongs - to the OTA/bootloader handoff (subsystem 11). The WDT halts in System - OFF (all clocks stop); `wdtSetup()` re-arms on the fresh boot. -- **Wake sources**: tach pulse (D0 falling, engine start), any button, - or VBUS (USB plug-in, always armed on nRF52840). -- **Wake-cause decode** (`captureBootWakeCause()`, FIRST thing in - `setup()`): reads then clears `RESETREAS` + `NRF_P0/P1->LATCH` (sticky, - cumulative) and decodes via the host-tested `wake_cause` unit. A tach - wake makes the GPS status page exit into race mode with logging; a USB - wake skips the status page straight into the charging loop — that - shortcut too is `BIRDSEYE_ENABLE_ONBOARD_CHARGING`-only, since with - charging off a cable means "host connected" and the device just boots - normally (its idle timeout parks it on VBUS soon enough). -- **Onboard charging is a build flag** (`BIRDSEYE_ENABLE_ONBOARD_CHARGING` - in `project.h`, **0 in every shipped build** since 3.0.1): the hardware - now carries an external charging circuit, so the firmware leaves HICHG - alone (BQ25100 stays at its ~50 mA default) and drops the charging UX. - Set it to 1 to restore the pre-3.0.1 behavior. The VBUS park below is - NOT gated on it. -- **Charging loop — the one soft-sleep survivor** (`runChargingShutdownLoop()`): - System OFF is never entered while VBUS is present. Two reasons: the - HICHG fast-charge pin (`PIN_CHARGING_CURRENT`) is software-held when - onboard charging is compiled in, and — regardless of the flag — VBUS is - an always-armed System OFF wake source, so entering OFF with the cable - in risks an immediate wake-reset loop. After the - same full teardown, the loop shows the charging screen for 10 s then - turns the display off; **any button is a full wake to the main menu** - (`softResumeFromCharging()`: IMU re-init, race-mode GPS targets + - `GPS_WAKE()`, display on); unplugging drops to System OFF. CPU idles - via `shutdownIdleWait()` — `sd_app_evt_wait()` only when the SoftDevice - is actually enabled, `__WFE()` otherwise. - -### 11. Firmware OTA (`firmware_ota.ino`, `crc32.{h,cpp}`) - -- **Why self-flash**: Chrome's Web Bluetooth blocklist bans the Nordic - *legacy* DFU service `BLEDfu` exposes, and the sealed units have no - button/SWD pins to install a web-allowed Secure-DFU bootloader. So the app - updates itself: the web app streams the image to SD over the existing - `0x1820` service, the firmware CRC-verifies it, stages it to a free flash - region, and a RAM flasher swaps it into the app region and resets. The - bootloader is **not** changed for the field flow. -- **Wire protocol** (text on `0x2A3E` in / `0x2A40` out; image bytes are raw - binary writes to `0x2A3E`): - - `FWBEGIN:,,` → `FWCRC:` (echo handshake - to verify the control channel before any upload). `` (`sense` / - `nonsense`) is the target board variant the web app derives from the - device's DIS Model Number; the firmware compares it (case-insensitive) to - `FIRMWARE_VARIANT` and replies `FWERR:VARIANT` here — the single variant - gate, before any upload. - - `FWPUT:` → `FWREADY`, then raw ≤240-byte chunks streamed to SD - (`/fw/pending.bin`), then `FWDONE` → `FWOK:` (CRC of the stored - file) or `FWERR:CRC|SIZE|WRITE`. - - `FWAPPLY` → `FWSTAGE:` (0–100, repeatable) → `FWAPPLIED` then reset, - or `FWERR:`. `FWABORT` cancels at any point. - - Error tokens: `CRC`, `SIZE`, `WRITE`, `BATTERY`, `VARIANT`, `STATE`, - `FLASH`. -- **CRC**: CRC-32/IEEE-802.3 (zlib), reflected poly `0xEDB88320`, init/xor - `0xFFFFFFFF`, lowercase 8-char hex, compared case-insensitively. Shared - with the web client via the host-tested `crc32` pure unit. Sanity vector - `crc32("123456789") == 0xcbf43926`. -- **Threading**: like track upload, the BLE callback only parses commands and - copies chunk bytes into a RAM double-buffer; `FW_OTA_LOOP()` (main loop) - does all SD writes, CRC verify, and the apply sequence. SD held via - `SD_ACCESS_BLE_TRANSFER` for the receive. -- **Apply** (`fwDoApply()`): guards first — refuse below `FW_MIN_APPLY_VOLTAGE` - (3.6 V, uses cached `lastBatteryVoltage`) → `FWERR:BATTERY`. (Variant is - validated earlier, at `FWBEGIN`; no image-byte scan here. The image still - embeds `kFwImageDescriptor` for forensics.) Then `fwStageToFlash()` copies - SD → upper flash - (`FW_STAGE_BASE`, via the `flash_nrf5x` HAL) and **re-verifies the CRC in - flash before the app region is ever erased** (`FWERR:FLASH` on mismatch). - Only then: emit `FWAPPLIED`, arm the GPREGRET recovery flag - (`FW_GPREGRET_OTA_DFU`), disable the SoftDevice, and call the RAM-resident - `fwRamFlasher()` to erase the app region, copy the staged image down, and - reset. -- **Recovery net**: an interrupted swap leaves an invalid app, so the - bootloader comes up in BLE DFU and the unit is re-flashable over the air via - the nRF Connect mobile app — no pins. **The apply path needs the Phase 0 - hardware spikes signed off before field release** — see - `docs/firmware-ota-phase0.md`. -- **Fleet migration**: the first firmware carrying `FW*` is pushed to sealed - units once via nRF Connect (native app, buttonless trigger works on the - existing single-bank bootloader); all later updates go through the web app. - -### 12. USB Mass Storage (`usb_msc.ino`) - -- **Why**: a wired, app-free way to move files. The SD is FAT16/32, so a - host PC can mount it as a drive and drag-and-drop track JSON / DOVEX logs. - Complements (does not replace) the BLE transfer service. -- **Stack**: TinyUSB `Adafruit_USBD_MSC` (bundled in the Seeed/Adafruit - nRF52 core; the core's default USB stack is TinyUSB). Three block - callbacks wrap SdFat's block device: `msc_read_cb` → - `SD.card()->readSectors()`, `msc_write_cb` → `writeSectors()`, - `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()`. -- **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. - `USB_MSC_ENABLE()` first requires **VBUS present** (`isUsbConnected()`) — - without a cable there is nothing to mount and the parked loop would read - absent VBUS as a cable-pull and instantly reset, so it bails before taking - the lock or enumerating (the menu shows "Plug in USB cable first"). It then - acquires `SD_ACCESS_USB_MSC`, sets the capacity from - `sectorCount()`, marks the unit ready, `begin()`s the interface (bailing - out — restoring the clock and releasing the lock — if `begin()` fails), - and forces a `TinyUSBDevice.detach()` / 50 ms / `attach()` re-enumeration - so the host mounts the drive. If the SD mutex is busy it bails to a - warning page and changes nothing. -- **Loop parking**: while `usbMscActive`, `loop()` takes an early-return - branch (mirroring the `bleActive` branch) that skips all GPS/tach/lap/SD - processing — the host PC owns the FAT, so the firmware must not touch it - concurrently. The branch services only the Exit button and the status - 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 - 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, - replay, and BLE transfer are locked out (and vice-versa) — though loop - parking, not the mutex, is the primary guarantee the firmware stays off - the card. The transfer/USB pages are not the main menu, so the - USB-on-main-menu charge-mode entry never fires while transferring. -- **No pure unit test**: the block-callback glue is TinyUSB/Arduino-bound - (hardware), so there is no host-testable logic here — only the - `sd_access_policy` mode addition is unit-tested. - -### 13. Camera Auto-Record (`camera_ble.ino`, `camera_fsm.{h,cpp}`, `insta360_protocol.{h,cpp}`) - -- **What**: hands-free Insta360 X4 control. The device *is* the Insta360 - "GPS Remote": it emulates the physical remote so a paired X4 is woken - when the engine starts, records the session, and powers itself off - afterward — the driver never touches the camera. -- **Single BLE role: peripheral remote** on the one SoftDevice - (`Bluefruit.begin(1, 0)`). We host the remote's GATT — service `0xCE80` - (ce81 WRITE camera→us carrying its serial + status frames, ce82 NOTIFY - us→camera button frames, ce83 READ static) — advertise the wake/identity - payload, and **the camera connects to us as central** and subscribes to - ce82. **All control is a ce82 button notification**, byte-for-byte the - physical remote's frames: recording toggles via the shutter button, and - power-off streams the 3-second power-hold. We never act as central: no - scanning, no `be80` client, no `be81` writes. (The old central role held - a `be80` link to the camera for start/stop-video — removed. It made - power-off impossible: power-off only exists as a remote `ce82` hold, - which cannot coexist with being the camera's `be80` client.) -- **GPS overlay** (`cameraServiceGpsStream()`): a Wireshark capture of the - genuine remote↔camera link showed the remote streams GPS on **`ce82` at - 10 Hz** as a non-standard NMEA-RMC frame (`FC EF FE 83 00 - ,26.7,\x07,$GNRMC,...` — signed longitude with a constant `E`, an extra - `V` field; built + golden-tested in `insta360_protocol::buildGpsRmcFrame`). - The firmware streams it continuously while the camera is connected + - subscribed — this doubles as the remote's **liveness heartbeat** (never - go silent or the camera drops us; status `V` with last-known coords when - there's no fix), paused only during a power-off hold. GPS still logs to - SD independently. -- **Lifecycle FSM** (`camera_fsm` pure unit, host-tested): the race-mode - lifecycle is deliberately **RPM-driven and simple**. 7 states — UNPAIRED / - IDLE / WAKING / AWAIT_READY / RECORDING / **WATCHING** / PAIRING (the old - COOLDOWN/POWERING_OFF tail is gone — power-off is now sleep-only). - RPM > 500 held 2 s enters WAKING, which broadcasts the 31-byte CONNECTABLE - wake advert — the sniffed GPS-Action-Remote manufacturer payload (serial at - mfg[14..19], per the primary `pchwalek/insta360_ble_esp32` source) in the - primary PDU with the "Insta360 GPS Remote" name in the scan response, both - set as raw bytes so the stack can't reshape them (retry ×3). The woken - camera connects back and the FSM moves to AWAIT_READY (wait for the ce82 - subscription; bounded re-wake if it never subscribes) → **WATCHING**. - (Wake only reaches an ARMED camera: on the X4, Bluetooth Wakeup is armed - when **QuickCapture is OFF** — an armed camera keeps its radio scanning even - fully powered off. Every advert goes through `bleAdvFinalizePadded()` — see - bluetooth.ino — to defeat the Bluefruit 0.21.0 frozen-packet-length core - bug.) **Recording starts** from WATCHING once RPM has held at/above - `kRecordRpmThreshold` (1500 — deliberately far above the 500 wake - threshold: pull-start cranking blips clear 500 and once started a - recording during a failed first start; any dip below 1500 restarts the - clock) for `kRecordStartDelayMs` (5 s) — **no GPS-lock gate** (GPS still - streams the whole time) — by sending one shutter-toggle ce82 frame. The shutter is a - stateful TOGGLE, so the FSM never blind-fires it: it **confirms** record - state from the camera's own `0x10` ce81 display-string frame (a live - `.HH:MM:SS` timer while recording — the `0x02` status word is not reliable; - `insta360_protocol::parseRecordingState`) and reconciles `recordingActive` - against that observation (`RecordObs` Input). On reconnect it adopts the - camera's real state instead of toggling; if the camera reports idle while we - believe we're recording it re-asserts the shutter once; and the belief is - preserved on any path where the camera is unreachable, so a dropped link - can't invert on reconnect. **Recording stops** after `kStopRecordDelayMs` - (30 s) of engine-off (RPM < 300) — **RPM only, no speed**, so a - stationary-but-running grid idle keeps recording — sending one shutter - toggle and returning to WATCHING. The 30 s-engine-off auto-stop also **ends - the race log session** (see the read-only note below). A manual session end - (`CAMERA_NOTIFY_SESSION_END()` from the logging-stop confirm) stops the - camera immediately, also to WATCHING. **WATCHING** keeps the camera ON and - connected: if RPM returns it re-records (stall recovery), and it powers the - camera off **only when the device shuts down** (`CAMERA_SLEEP()` streams the - ce82 power-hold synchronously) — there is no post-record cooldown/power-off - timeout. All timing lives in the FSM so the temporal behavior is - host-testable; every tunable is a single-point `constexpr` in - `camera_fsm.h`. The unit is the board-portable core shared with the nRF54 - ("Falcon") target — nothing in it may `#ifdef` on the platform. -- **Telemetry consumer, with one deliberate write-back**: the FSM consumes an - `Inputs` snapshot (RPM, link state, observed record state, one-shot events) - built fresh each `CAMERA_LOOP()` and returns at most one `Action`. Camera - mode never parks the main loop (unlike `bleActive` / `usbMscActive`). The - ONE exception to the old read-only guarantee: when the camera auto-stops - (30 s engine-off), the glue latches `cameraConsumeAutoStop()` and the main - sketch calls `endRaceSession()` + returns to the menu — so with a camera - paired+recording the log ends on 30 s-no-RPM instead of the speed-based - auto-idle (which `checkAutoIdle()` suppresses while `cameraActivelyRecording()`). - Without a camera, logging is unchanged. -- **Pairing / bonding**: entering pairing from `PAGE_PAIR_CAMERA` - advertises connectably as "Insta360 GPS Remote"; after connecting, the - camera writes its 6-char ASCII serial to ce81, which is captured and - persisted in the `camera_serial` setting (empty = unpaired). The manual - 6-char entry page (`PAGE_CAMERA_SERIAL_ENTRY`) is the fallback. Pairing - times out after 2 min. The genuine remote link is encrypted + bonded, so - we support Just-Works (NoInputNoOutput) pairing as peripheral — the - camera may withhold its ce82 subscription until the link is encrypted. -- **Coexistence** (`bleOwner`, see subsystem 6): the camera shares the - single advert set + peripheral slot with the transfer service. Opening - the Bluetooth transfer page calls `CAMERA_FORCE_RELEASE()` before - `BLE_SETUP()` — best-effort stop recording, drop the camera link, stop - camera-owned advertising, force the FSM to IDLE, release the radio. - Shutdown entry runs `CAMERA_SLEEP()` (same, plus a power-off that is - **streamed synchronously** before the disconnect — the chip powers off - right after, so the non-blocking ce82 hold would otherwise never transmit - a frame and the camera would run all night). BLE - comes up **lazily** on the first camera action (first advertising - `Action`), so unpaired users pay zero RAM/power cost. -- **Threading**: same deferred pattern as `firmware_ota` — Bluefruit - callbacks (connect/disconnect/ce81 writes/ce82 CCCD writes) only copy - into RAM and set volatile flags; `CAMERA_LOOP()` on the main loop - consumes them, steps the FSM, and does all real work (including the one - `setSetting()` that persists a captured serial and streaming the - power-off hold). The ce82 CCCD callback latches a cached `ce82NotifyOn` - flag so the hot paths (per-loop Inputs snapshot + 10 Hz GPS tick) never - SVC into the SoftDevice for the subscription state; `CAMERA_LOOP()` - re-reads `notifyEnabled()` only at a low rate while linked-but-not-yet-known- - subscribed, to catch a bonded peer's silent sys-attr CCCD restore. -- **X4-VERIFY posture**: all frame bytes live in the host-tested - `insta360_protocol` pure unit with golden-byte tests. The **wake - advert + scan response are X4-CONFIRMED ground truth** — captured from - a genuine GPS Remote with nRF Connect (2026-07-10 bench session) and - the replayed packet woke the sleeping X4; flags are `0x05`, and the - paired camera-mode advert presents this same remote-identity packet. - Remaining `// X4-VERIFY(sniff)`: the ce82 button frames (proven capture - bytes; the record/power-off *effect* still to be confirmed end-to-end on - an X4) and the ce81/ce83 parsers. -- **Bench test menu** (`PAGE_CAMERA_TEST`): the paired Camera page has a - **Test** entry opening a manual-control menu (Wake / Record / Power Off / - Back) plus live remote (**R**) link status (`R:UP+` when the camera is - connected AND subscribed to ce82, `R:UP` when connected but ignoring our - buttons), advert (**Adv:**) status, a **G:** GPS-feed indicator (`SYNC` - with a fix / `V` voided-but-streaming / `--` not streaming) and a - **rec:yes/no** state driven by the camera's own `0x10` record timer - (`rec:--` when there's no fresh observation — no link, or the camera hasn't - pushed a `0x10` yet — so a missing signal isn't misread as "not recording") - — so both the GPS link and that a Record press actually started the camera - can be verified without staging RPM/GPS to drive the FSM. **Wake** presents the - wake / remote-identity advert so a standby camera wakes and an on camera - connects back to us; **Record** sends the ce82 shutter toggle; **Power - Off** streams the ce82 hold — both need the camera connected + subscribed. - `cameraTestEnterMode()` forces the FSM to IDLE and sets `cameraTestActive`, - which makes `CAMERA_LOOP()` suppress the FSM step (so auto-record can't - fight the manual actions) while the Bluefruit callbacks keep the link - serviced; `cameraTestExitMode()` stops any recording (tracked in a - bench-local belief so the camera is **guaranteed** left stopped on exit) - and tears the session down. The `cameraTest*()` action helpers reuse the exact - `cameraExecuteAction()` code paths the FSM would run — no new FSM states, - so the board-portable pure unit is untouched. -- **X4 field notes** (from live bench testing, informing the above): the - camera connects to our ce80 remote (R link) only *after* it has been - paired from the camera's own **Settings → Bluetooth remote** menu — - capturing the serial (subsystem UI pairing) is not sufficient. The - wake-burst advert only wakes an *armed* camera (X4: QuickCapture OFF = - "Bluetooth Wakeup Enabled"; armed cameras keep the radio scanning even - powered off, un-armed ones are BLE-dead). There is **no** `be80` - power-off command in any known reference implementation — power-off is - the `ce82` 3-second-hold button frame over the R link, so both Record and - Power Off depend on the R link being up and subscribed. - -### 14. SensorEgg Wireless EGT (`sensoregg.ino`, `sensoregg_protocol.{h,cpp}`) - -- **BUILD FLAG — `BIRDSEYE_ENABLE_SENSOREGG` (`project.h`)**: this whole - subsystem is a beta-channel feature. `0` (master/release default) - compiles `sensoregg.ino` down to no-op `SENSOREGG_SETUP/LOOP` and NaN - accessors, drops the Temp1 race page from the rotation - (`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. - 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 - **advertising packets** — protocol `PW-ADV-1`: 14-byte Manufacturer - Specific Data (`FF FF` company ID + `50 57` magic *inside* the array, - version, flags, int16 LE deci-°C ×2 with `0x8000` = invalid sentinel, - raw MCP9600 STATUS, battery stub, uint16 sequence), ~10 Hz. -- **Radio role — do not "improve" this**: the logger is a pure passive - OBSERVER (`Bluefruit.Scanner`, `useActiveScan(false)`, 90 ms interval / - 40 ms window ≈ 44% duty, RSSI ≥ −90). No SCAN_REQ, no connection, no - GATT — so it cannot contend with the camera peripheral link for TX - airtime; S140 time-slices scan windows around connection events. The - egg accepts no connections. **The camera link wins every tradeoff** — - and scan duty is capped (test-enforced ≤45%) because SoftDevice - scan-window ISRs defer the TIMER3 GPS drain (see subsystem 1). -- **Scanner robustness (bench-proven, do not remove)**: (1) - `Scanner.filterMSD(0xFFFF)` rejects ambient packets INLINE — Bluefruit - self-resumes filtered reports, while an accepted report pauses scanning - until the deferred rx callback runs, so without this filter desk BLE - traffic collapses the scan duty in bursts. (2) Anti-phase-lock lives in - the **interval**: equal 100 ms adv/scan periods phase-lock and parked - the egg in the deaf zone for seconds; the 90 ms scan interval (plus the - egg advertising off-100 ms) sweeps relative phase ~10 ms/cycle so a - deaf-zone park escapes in ≤~450 ms. (The original fix was a 60 ms - window — 60% radio duty, which deferred the GPS drain enough to drop - PVT frames; the interval retune replaced it and returned the window to - the spec's 40 ms.) (3) `SENSOREGG_LOOP()` kicks stop+start after 30 s - with no accepted packet — a lost deferred callback otherwise halts the - scanner silently forever. -- **Pairing (POC)**: `SENSOREGG_MAC` #define in `sensoregg.h`, human byte - order; all-zeros (default) = accept any advertiser matching the payload - magic. The scan callback filters length + magic + MAC, copies the raw 14 - bytes into a double buffer (camera ce81 idiom), stamps `millis()`, and - calls `Scanner.resume()` — **mandatory**, or the scanner halts after one - report. No Serial/SD/display in the callback (BLE task context). -- **Consumption**: `SENSOREGG_LOOP()` (main loop) drains + parses via the - host-tested `sensoregg_protocol` unit. Accessors: `sensoreggEgtC()` / - `sensoreggJunctionC()` (NaN when stale, egg-invalid, or app-hung), - `sensoreggLinkUp()`, `sensoreggTcFault()`, `sensoreggAppHung()`. -- **Zombie-egg detection**: BLE radios rebroadcast the last-set advert - buffer autonomously, so an egg whose *application* hangs (suspected - blocking MCP9600 I2C read under ignition EMI; 2026-07-19 field incident, - ~3–4 h in) keeps beaconing a frozen payload at 10 Hz — arrival-time - freshness alone reports a live link with a flat-lined value. The payload's - uint16 sequence counter is the sign of life: `sensoregg_protocol::SeqMonitor` - (host-tested; wrap-safe) marks the reading dead when the sequence hasn't - changed within `kStalenessMs` even though packets arrive. Readings go NaN - (log `nan`), and the Temp1 page shows `rf:HUNG` (egg needs a power cycle) - instead of `rf:OK`. **Staleness (1 s) is absolute** — a reading is never - held across a dropout (a held value draws a flat line indistinguishable - from real data). Logging writes `Temp1`/`Junction1` (or `nan`) **in - Celsius**; the `SENSOR_TEMP` race page (after the tach page) shows big - EGT + junction + `rf:` link subtext **in Fahrenheit** — converted at - render time only via `sensoregg_protocol::celsiusToFahrenheit()` (a C/F - display setting comes later). -- **BLE lifetime change**: `SENSOREGG_SETUP()` (called from `setup()` after - `CAMERA_SETUP()`) runs `bleCoreEnsureInit()` at boot — BLE is no longer - lazy. Scanner start failure logs the documented `Bluefruit.begin(1, 1)` - fallback note (spec §7.2.3) rather than touching the shared `begin(1, 0)`. -- **Sim**: `sensoregg.ino` is excluded from the sim TU like the other BLE - modules; `module_stubs.cpp` returns NaN/false so the page renders `---` - and rows log `nan`. - ---- - -## Data Formats - -### DOVEX Log (`.dovex` files) — New UI default - -``` -datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_ms,device_name -lap1_ms,lap2_ms,lap3_ms,... -\n padding to byte 1024 -timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z,Temp1,Junction1 -1710512400123,12,0.8,35.12345678,-97.12345678,65.32,234.56,... -``` - -- **Reserved header** (bytes 0–1023): Line 1 = session metadata, Line 2 = - all lap times (comma-separated ms values), padded with `\n` to 1024 bytes. -- **`device_name`** is the trailing metadata column (after `optimal_lap_ms`). - Appending it keeps old logs readable (parsed as empty) and lets older - readers ignore the extra column — backwards compatible by design. -- **GPS data** (byte 1024+): CSV column header then streaming GPS rows. -- **`Temp1` / `Junction1`** (trailing columns): SensorEgg EGT + cold - junction in °C. Literal `nan` when the egg link is stale (>1 s) or the - egg reports an invalid probe — a dropout must be a visible gap, never a - held value. These fields never cause a GPS row to be skipped. -- **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` -- 1 KB handles ~100 laps (8 chars per lap time). Extremely unlikely to exceed. - -### Track JSON (`/TRACKS/*.json`) - -**New format** (LapWingData / web simulator): -```json -{ - "longName": "Orlando Kart Center", - "shortName": "OKC", - "defaultCourse": "Normal", - "courses": [ - { - "name": "Normal", - "lengthFt": 3383, - "start_a_lat": 28.4127081705638, - ... - } - ] -} -``` - -**Older format** (bare array, still parsed): -```json -[ - { - "name": "Full Course", - "start_a_lat": 28.41270817, - ... - } -] -``` - -Auto-detected by JSON root type (object vs array). The older bare-array -form sets `lengthFt = 0` for all courses, which means CourseDetector -cannot rank by distance — CourseManager falls back to Lap Anything -immediately. - -Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). - -### Settings JSON (`/SETTINGS.json`) - -```json -{ - "bluetooth_name": "DovesDataLogger-042", - "bluetooth_pin": "7391", - "camera_serial": "", - "device_name": "ApexTurbo", - "driver_name": "Driver", - "lap_detection_distance": "7", - "waypoint_detection_distance": "30", - "waypoint_speed": "30" -} -``` - -| Key | Type | Default | Purpose | -|-----|------|---------|---------| -| `bluetooth_name` | string | Random | BLE device name | -| `bluetooth_pin` | string | Random 4-digit | BLE pairing PIN | -| `camera_serial` | string | `""` (empty = unpaired) | Paired Insta360 X4's 6-char serial (auto-captured on pairing, or entered manually) | -| `device_name` | string | Random racing words | Identifies the logging device (DOVEX header) | -| `driver_name` | string | `"Driver"` | Logged in DOVEX header | -| `lap_detection_distance` | int | `7` | DovesLapTimer crossing threshold (meters) | -| `waypoint_detection_distance` | int | `30` | WaypointLapTimer proximity zone (meters) | -| `waypoint_speed` | int | `30` | Speed threshold (mph) for waypoint/detection | - -- Created automatically on first boot with random BLE values. -- Missing keys auto-populated on boot via `ensureDefaultSettings()`. -- Editable on a computer or via BLE `SSET` command — changes take effect - on next reboot (BLE disconnect triggers auto-reboot). -- Read on-demand via `getSetting()`, written via `setSetting()`. - ---- - -## Key Constants - -| Constant | Value | Location | -|---|---|---| -| GPS baud | 57 600 | `gps_config.h` | -| GPS nav rate (race) | 25 Hz | `gps_config.h` | -| GPS nav rate (boot/status page) | 5 Hz + NAV-SAT ~1 Hz | `gps_config.h` | -| Status page auto-close | 3 s after fix+timeValid | `gps_status_page.h` | -| Status page idle shutdown | 5 min (no lock, no engine) | `gps_status_page.h` | -| SD format confirm hold | 3 s continuous Select | `sd_format_page.h` | -| SD format page idle shutdown | 5 min | `sd_format_page.h` | -| GPS boot re-detect | 3 tries, 10 s apart | `gps_functions.ino` | -| Menu idle shutdown | 5 min (`SLEEP_IDLE_TIMEOUT_MS`) | `project.h` | -| USB-on-menu charge idle | 60 s (`USB_MENU_CHARGE_IDLE_MS`) — compiled out unless `BIRDSEYE_ENABLE_ONBOARD_CHARGING` | `project.h` | -| Onboard charging (HICHG hold + USB charge UX) | `BIRDSEYE_ENABLE_ONBOARD_CHARGING`, default 0 (all channels) | `project.h` | -| SensorEgg wireless EGT POC | `BIRDSEYE_ENABLE_SENSOREGG`, default 0; 1 on the beta channel | `project.h` | -| Charging screen timeout | 10 s (`CHARGE_DISPLAY_TIMEOUT_MS`) | `project.h` | -| Sat bars display cap / CNO ceiling | 16 bars / 50 dB-Hz | `sat_bars.h` | -| Crossing threshold | 7.0 m | `BirdsEye.ino` | -| Max laps/session | 1 000 | `BirdsEye.ino` | -| Max locations | 200 | `project.h` | -| Max layouts/track | 10 | `project.h` | -| Max replay files | 20 | `replay.ino` | -| DOVEX header size | 1 024 bytes | `project.h` | -| Auto-idle timeout | 60 s at <2 mph | `BirdsEye.ino` | -| Track detect radius | 5 miles | `BirdsEye.ino` | -| Tach min pulse gap | 3 ms | `BirdsEye.ino` | -| Tach ring buffer | 16 entries | `BirdsEye.ino` | -| Tach Kalman Q | 800 RPM² | `tach_filter.h` | -| Tach Kalman R_BASE | 2500 RPM² | `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` | -| Button debounce | 200 ms | `display_ui.ino` | -| SD SPI clock (normal) | 2 MHz | `BirdsEye.ino` | -| 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` | -| JSON buffer | 4096 (SIM builds too) | `sd_functions.ino` | -| Settings JSON buffer | 512 | `settings.ino` | -| Settings file path | `/SETTINGS.json` | `settings.ino` | -| Track upload buffer | 4096 | `bluetooth.ino` | -| GPS serial buffer | 4096 | `gps_functions.ino` | -| GPS serial timer | TIMER3, 5 ms (`GPS_DRAIN_INTERVAL_US`) | `gps_config.h` | -| Core Serial1 RX/TX rings | 256 B via required `-DSERIAL_BUFFER_SIZE=256` (asserted) | `project.h` + workflows | -| GPS drop-count slack / credit cap | 1 frame / 2 frames | `gps_stats.h` | -| OTA staging path | `/fw/pending.bin` | `firmware_ota.ino` | -| OTA receive buffer | 2 × 4096 (double-buffer) | `firmware_ota.ino` | -| OTA app base | `0x27000` | `firmware_ota.ino` | -| OTA staging flash base | `0xA4000` | `firmware_ota.ino` | -| OTA max image size | 320 KB | `firmware_ota.ino` | -| OTA min apply voltage | 3.6 V | `firmware_ota.ino` | -| Camera record-start gate | RPM ≥ 1500 (`kRecordRpmThreshold`) held 5 s, strict — dips restart the clock (no GPS gate) | `camera_fsm.h` | -| Camera stop-record delay | 30 s engine-off (RPM only) → also ends log session | `camera_fsm.h` | -| Camera power-off | shutdown only (no post-record cooldown/timeout) | `camera_ble.ino` (`CAMERA_SLEEP`) | -| Camera RPM on/off thresholds | 500 / 300 (2 s on-debounce) | `camera_fsm.h` | -| Camera wake attempt window | 20 s ×3 (beacon) | `camera_fsm.h` | -| Camera connect / subscribe timeouts | 20 s connect / 10 s ce82 subscribe, 3 retries each | `camera_fsm.h` | -| Camera record-confirm re-assert | 2.5 s camera-idle before re-shutter | `camera_fsm.h` | -| Camera record-obs freshness | 3 s (stale 0x10 → kUnknown) | `camera_ble.ino` | -| Camera pairing timeout | 120 s | `camera_fsm.h` | -| SensorEgg staleness | 1000 ms (older → NaN/`---`) | `sensoregg_protocol.h` | -| SensorEgg scan interval / window | 90 ms / 40 ms (≈44% duty, test-capped ≤45%), passive | `sensoregg_protocol.h` | -| 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` | - ---- - -## Required Libraries - -| Library | Purpose | -|---|---| -| Adafruit GFX | Graphics primitives | -| Adafruit SSD1306 | SSD1306 OLED driver | -| Adafruit SH110X | SH110X OLED driver | -| SparkFun u-blox GNSS v3 | UBX binary PVT GPS interface | -| ArduinoJson 6.x | Track file JSON parsing | -| 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.2.0` (bump deliberately) | -| Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant, ±16g) | -| Bluefruit nRF52 | BLE (built into board package) | -| Adafruit TinyUSB | USB Mass Storage (`Adafruit_USBD_MSC`); built into board package | - ---- - -## EMI Mitigation - -This device operates in ignition-noise environments. Three layers of defense: - -1. **Hardware**: RC low-pass filters on buttons (10 K + 100 nF) and tach - (1 K + 100 nF + optional TVS diode). -2. **ISR design**: Volatile flag gating (never `noInterrupts()` in ISR); - 3 ms minimum pulse gap in tachometer. -3. **Software**: Multi-sample button reads (3x at 500 us), 200 ms refire - lockout, Kalman-filtered RPM (absorbs ISR jitter), 2 MHz SPI clock for - SD stability (raised to 8 MHz only during parked BLE/USB transfers, where - the motor is off and ignition EMI is absent — see subsystem 4). -4. **GPS serial buffer**: TIMER3 ISR drains Serial1 into a 4 KB RAM ring - buffer every 5 ms, preventing GPS data loss during SD card GC pauses - that can block writes for 100 ms–2 s; the core Serial1 ring (256 B via - the required `-DSERIAL_BUFFER_SIZE=256` flag) covers SoftDevice - radio-ISR deferral of the drain itself. - ---- - -## Build Notes - -- **Board**: Seeed XIAO nRF52840 Sense (Arduino IDE). The firmware also - builds and runs on the plain (non-Sense) Seeed XIAO nRF52840 — same MCU, - BLE, bootloader and pin map; it just lacks the onboard LSM6DS3 IMU, so - accelerometer logging degrades gracefully (`accelAvailable = false`). CI - (`compile-sketch`) and the `release` workflow build a matrix of both - variants (FQBNs `xiaonRF52840Sense` and `xiaonRF52840`), publishing - per-board `BirdsEye-sense.*` / `BirdsEye-nonsense.*` assets. The `.zip` - in each is the Secure DFU package used for OTA. Each build passes - `-DBIRDSEYE_BOARD_SENSE` / `-DBIRDSEYE_BOARD_NONSENSE` (via - `compiler.cpp.extra_flags`) so the image self-reports its variant over - BLE; a plain IDE build with no flag defaults to `sense`. -- **Firmware version** is a single `#define FIRMWARE_VERSION` in `project.h`. - Keep it in sync with the release git tag (`v2.0.0` -> `"2.0.0"`); it is - reported over BLE (DIS) for the OTA update check. `FIRMWARE_VARIANT` - (also in `project.h`) feeds the DIS model string. The version literal can be - overridden at build time with `-DFIRMWARE_VERSION_OVERRIDE=` (a bare - token; `project.h` stringizes it) — the `beta` workflow uses this to stamp - nightly builds as `-beta.`. Normal builds leave it undefined. -- **Required build flag `-DSERIAL_BUFFER_SIZE=256`** — grows the core's - Serial1 rings so radio-ISR deferral of the GPS drain can't drop bytes - (see subsystem 1). `project.h` static_asserts it on non-SIM builds; CI - passes it in all three workflows (merged into the SAME - `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): - - `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. - - `BIRDSEYE_ENABLE_SENSOREGG` — off in master/release, **on in beta** - (`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. - 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. -- The sketch lives in `BirdsEye/` so the folder name matches the - `.ino` file — required by Arduino IDE / arduino-cli. -- `project.h` is included before other `.ino` modules so Arduino's - auto-prototype generator sees custom types first. -- PROGMEM is used for bitmap images to save RAM. -- Avoid Arduino `String` in hot paths (heap fragmentation risk on 256 KB). -- SD chip-select is hardwired to GND; pass `-1` to SdFat. -- `#define SIM` enables simulator-specific tweaks (no WDT, fixed battery - voltage, placeholder GPS setup, sim button pins). Never defined in CI - firmware builds — it's the compile flag for the browser/WASM simulator - build (sources under `BirdsEye/sim/`; replaces the old Wokwi target). -- `#define ENDURANCE_MODE` hides the tachometer page and reshuffles - page numbers — for endurance racing where RPM isn't relevant. -- **TIMER3 is reserved** for the GPS serial buffer ISR. Use TIMER4 if another - hardware timer is needed. TIMER0 is reserved by SoftDevice; TIMER1/2 may - be used by PWM/tone. -- **CRITICAL: NEVER use `analogRead()` on any GPIO pin.** On the nRF52840, - `analogRead()` permanently disables the digital input buffer on the target - pin for the remainder of the session. Every analog-capable pin on the XIAO - is also a critical digital function: A0=tach ISR, A1-A3=buttons, A4=SDA, - A5=SCL. Use `micros()` or the hardware RNG for entropy instead. - ---- - -## Development Conventions - -- `.ino` files act as modules; Arduino IDE concatenates them alphabetically - after the main sketch file. -- Each module has a matching `.h` declaring its public surface. The `.ino` - includes its own header as the first include so any drift between - declaration and definition is caught at compile time. -- Each subsystem exposes `*_SETUP()` and `*_LOOP()` entry points called - from `BirdsEye.ino`. -- SD access must go through `acquireSDAccess()` / `releaseSDAccess()`. -- GPS data validation (9 checks) must pass before any CSV row is written. -- Display pages are rendered by `displayPage_*()` functions routed via - `currentPage` in `displayLoop()`. -- Cross-module globals (e.g. `dovexReplay*`, `trackManifest[]`, `courseManager`) - are declared and defined in `BirdsEye.ino`. Module headers may `extern`-declare - them where the module's own API touches that state. -- Library includes that define return types used in auto-prototyped functions - (`DovesLapTimer.h`, `CourseManager.h`, `SparkFun_u-blox_GNSS_v3.h`) must be - in the top include block of `BirdsEye.ino` (before Arduino generates - prototypes). +# BirdsEye - Project Guide + +> **MAINTAINERS: Keep this file updated when adding/removing files, changing pin +> assignments, modifying subsystem interfaces, or altering the build configuration. +> This file is loaded into Claude's context window on every session and must +> accurately reflect the current state of the project.** + +## Maintaining the Quality Bar + +This project went through a deliberate hardening pass (tests, CI, static +analysis, security, release pipeline, docs). **Keep it there.** When making +any change — whether you're Claude or a human contributor — hold the line: + +- **Add tests when possible.** New pure logic (math, parsing, validation, + formatting, anything Arduino-free) belongs in a `BirdsEye/*.{h,cpp}` unit + with a matching `tests/_test.cpp`. If you're touching existing logic + that *could* be a pure unit but isn't yet, prefer extracting it so it can + be tested rather than leaving it tangled in an `.ino`. Don't add untested + pure logic when a test is feasible. +- **Keep the CHANGELOG updated.** Any user-visible change gets an entry under + `[Unreleased]` in `CHANGELOG.md` (Added / Changed / Removed / Fixed / + Security). Flag breaking changes explicitly — they drive the next version + number per the semver policy in that file. +- **Keep CI green and meaningful.** The checks (compile-sketch + flash-size + gate, arduino-lint, unit-tests, clang-tidy, coverage) must pass. Fix the + root cause rather than loosening a check; if a clang-tidy finding is a + genuine false positive, suppress that one line with `// NOLINT(check)` and + a reason, never by disabling the check globally. The coverage floor + (`COVERAGE_MIN` in `coverage.yml`) is intentionally low — raise it as + coverage grows; don't lower it to pass. +- **Keep the docs in sync.** Update this file's File Map and the relevant + subsystem section, plus `ARCHITECTURE.md`, when you add/remove a module or + change a subsystem interface. Stale docs are worse than none. +- **Hold the conventions.** No Arduino `String` in hot paths, all SD access + through the mutex, never `analogRead()`, `TIMER3` reserved, ISRs trivially + short. See *Development Conventions* at the bottom for the full list. +- **One concern per PR.** Keep refactors, behavior changes, and new tests in + separate PRs so each is reviewable and revertable on its own. +- **Once CI is green on a PR, STOP.** Report the green status once and end. + Do NOT schedule recurring re-checks, polling wake-ups, or "babysit" + timers on a passing PR — they burn the owner's session usage confirming + nothing changed. Watch a PR only when explicitly asked, and even then a + green CI run ends the loop. + +The goal: every change should leave the codebase at least as professional as +it found it. If a shortcut would lower the bar, flag it instead of taking it. + +## What Is BirdsEye? + +A high-precision GPS lap timer and data logger for motorsports / track days. +Built on the **Seeed XIAO nRF52840 Sense** (ARM Cortex-M4, 256 KB RAM, BLE 5.0, onboard LSM6DS3 IMU). + +Core capabilities: +- 25 Hz GPS lap timing with sector support (DovesLapTimer library) +- **"Just Drive" auto-detection** via CourseManager: automatic track + proximity matching, course detection, and Lap Anything fallback +- RPM monitoring via inductive tachometer pickup +- Accelerometer logging (g-force X/Y/Z) via onboard LSM6DS3 IMU +- DOVEX data logging with reserved 1 KB header (crash-safe GPS data) +- 8+ display pages on a 128x64 OLED (3 Hz refresh) +- Bluetooth LE file download to companion apps / LapWingData.com +- On-device session replay: instant DOVEX header replay +- **Insta360 X4 camera auto-record**: emulates the Insta360 GPS Remote as a + pure BLE peripheral — wakes the camera on engine start, records via a ce82 + shutter toggle, stops and powers off automatically (see subsystem 13) +- **SensorEgg wireless EGT (POC)**: passive BLE observer receives the + DovesSensorEgg thermocouple pod's advertising broadcasts (`PW-ADV` v1 + and v2), logs `Temp1`/`Junction1`/`Temp2` DOVEX columns + Temp1/Temp2 + race pages (subsystem 14) + +--- + +## File Map + +All sketch sources live in `BirdsEye/` so the folder name matches the +`.ino` filename Arduino IDE expects. Each module has both a `.ino` +(implementation) and a `.h` (public interface, documentation). + +### Sketch Sources (`BirdsEye/`) + +| File | Purpose | +|---|---| +| `BirdsEye.ino` | Entry point: globals, `setup()`, `loop()`, state machine, course/timer helpers | +| `project.h` | Shared types (`ButtonState`, `TrackLayout`, `TrackManifestEntry`, `TrackMetadata`), debug macros, `MAX_*` constants | +| `display_config.h` | Display driver abstraction (SH110X vs SSD1306 toggle) | +| `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 | +| `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 | +| `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) | +| `settings.{h,ino}` | Persistent JSON settings on SD (`/SETTINGS.json`), `getSetting()`/`setSetting()` | +| `tachometer.{h,ino}` | Falling-edge ISR on D0, Kalman-filtered RPM calculation | +| `usb_msc.{h,ino}` | USB Mass Storage (TinyUSB MSC): SD card as a drag-and-drop drive (see subsystem 12) | + +### Pure-Logic Units (`BirdsEye/*.{h,cpp}`) + +Arduino-free `.cpp` files — compiled into the firmware AND into the +host test harness (`tests/`). No Arduino headers, so they build on a +desktop toolchain. This is where logic worth unit-testing lives. + +| File | Purpose | +|---|---| +| `haversine.{h,cpp}` | Great-circle distance in miles (track proximity) | +| `gps_stats.{h,cpp}` | GPS pipeline drop accounting: expected-vs-received PVT window math (exact fractional carry, 1-frame jitter slack, capped credit, rate-switch suppression) feeding the debug-page `Drops` counter | +| `gps_time.{h,cpp}` | Leap-year/Unix-epoch math, `u64ToDecimalString` | +| `gps_validation.{h,cpp}` | PVT sample sanity gate + dtostrf-output check | +| `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 | +| `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` | +| `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 | +| `crossing_pattern.{h,cpp}` | The two-frame crossing animation as geometry (eight 16x16 cells, odd row bands, alternating phase) instead of 2 KB of stored bitmap; golden-tested byte-identical to the images it replaced | +| `sprint_select.{h,cpp}` | Sprint mode selection: newest-course-by-`date_created` ordering (sortable ISO strings) + the circuit-vs-sprint tiebreak decision table (`race_mode` pref; circuit yields to a sprint course created today) | +| `course_creator.{h,cpp}` | On-device course creator model (subsystem 15): screen/row table, required-vs-optional lines, the two webapp-compat save rules, the point-averaging hold (3 s, ≥8 fixes, ≤10 m h_acc), and `N{YYMMDD}_{HHMM}` name generation | +| `track_json.{h,cpp}` | The firmware's only track-JSON **writer** — course/track object emitters + a fixed-point coordinate formatter (integer math: no working `%f` on this core, and `dtostrf` doesn't exist on the host) | +| `wake_cause.{h,cpp}` | Boot wake-cause decode: RESETREAS + GPIO LATCH register snapshots → tach / button / USB / watchdog / soft-reset / cold boot (System OFF shutdown, subsystem 10) | +| `gps_status_page.{h,cpp}` | GPS status boot page state machine: hold, 3 s auto-close after fix+timeValid, button skip, exit destination (menu vs race), idle → shutdown; `timeSyncState()` names which time milestone is outstanding (date/time vs the slow `fullyResolved`) | +| `sd_format_page.{h,cpp}` | SD format-confirm boot page state machine: Select held 3 s continuously → format (release restarts the full window; other buttons never confirm), 5 min idle → shutdown | +| `sat_bars.{h,cpp}` | Status-page satellite signal bars: NAV-SAT CNO selection (used-in-nav first, strongest first) + bar x/w/h layout math for the 128×~30 px bottom half | + +### Simulator (`BirdsEye/sim/`) + +Host build of the REAL firmware TU under the `SIM` flag (browser/WASM +target in a later phase; native + CI today). The `.ino` sources compile +unmodified — all sim behavior lives in `BirdsEye/sim/` or behind `SIM`. +See `ARCHITECTURE.md` → *Simulator* and the phased plan in the simulator +handoff spec. + +| Path | Purpose | +|---|---| +| `sim_main.cpp` | Single TU replicating Arduino's .ino concatenation (bluetooth/camera_ble/usb_msc/firmware_ota deliberately absent) + host glue (`sim_init`/`sim_step_millis`/buttons/state peeks) | +| `sim_prototypes.h` | Hand-written stand-in for Arduino's auto-generated prototypes | +| `virtual_clock.{h,cpp}` | Host-advanced virtual time; `delay()` consumes it; no wall clock (determinism) | +| `arduino_shim/` | Arduino core + nRF52 registers/SoftDevice/FreeRTOS surface, Wire/SPI, LSM6DS3 (settable), Bluefruit types | +| `busio_shim/` | The display stack's entire hardware boundary: `Adafruit_I2CDevice` whose `begin()` is true and whose writes discard — everything above it (GFX/GrayOLED/SH110X) is the REAL pinned library, so the framebuffer is pixel-perfect | +| `sdfat_shim/` | In-memory VFS implementing the exact SdFat subset the firmware calls; preloads `assets/` (fixed SETTINGS.json + OKC track) via cmake-embedded byte arrays | +| `stubs/` | No-op surfaces of the excluded modules + SparkFun GNSS driver (real header, stubbed methods — PVT is injected directly into `onPVTReceived()`) | +| `frame_hash.{h,cpp}` | FNV-1a 32 over the 1024-byte framebuffer (golden fixtures, viewer dirty-check, future HIL tap) | +| `png_dump.{h,cpp}` | Dependency-free PNG writer (stored-deflate + repo crc32) for eyeballing frames | +| `native_main.cpp` | Phase-1 driver: boot → skip GPS status page → 60 s soak, state prints | +| `golden_main.cpp` | Phase-2 driver: scripted real-menu walk capturing 8 golden page hashes (`golden/golden_hashes.txt`; regenerate with `--print`, eyeball with `--dump`) | +| `oracle_main.cpp` | Phase-3 driver: lap-timing oracle. Default = synthetic constant-speed OKC circle (period exact by construction) through the whole real pipeline (boot page → race entry → proximity detect → CourseDetector "Normal" → laps ±40 ms); `--dovex ` replays a hardware log against its own header laps; diagnostic modes: `--dovex-noheader ` replays a header-less (crashed-session) log and prints live detection/lap state instead of asserting, `--two-session [break-min]` reproduces a full track day (synthetic session 1 → auto-idle end → parked break with GPS drift → real-log session 2) to test CourseManager state carryover | +| `fixtures/okc_tillotson_1.dovex` | Hardware-recorded OKC session (13 laps) — the `--dovex` oracle's CI fixture; the sim reproduces its header lap list to the exact millisecond (also the `--two-session` carryover test's session 2) | +| `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/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) | + +### Non-Source + +| Path | Contents | +|---|---| +| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.2.0` and build the all-flags-off defaults | +| `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 | +| `ARCHITECTURE.md` | Human-facing architecture narrative (subsystems, design decisions) | +| `CONTRIBUTING.md` | Build/test/PR workflow and code conventions | +| `SECURITY.md` | Private vulnerability reporting + known posture | +| `.github/ISSUE_TEMPLATE/` | Bug report + feature request templates | +| `.github/PULL_REQUEST_TEMPLATE.md` | PR checklist | +| `SDCARD/TRACKS/` | Example track JSON files | +| `CASE/` | 3D-printable enclosure STLs | +| `TACHOMETER/` | Tachometer circuit documentation | +| `README.md` | User-facing project documentation | +| `LICENSE` | GPL v3 | + +--- + +## Hardware & Pin Map + +| Pin | Function | Detail | +|---|---|---| +| Serial1 RX/TX | GPS UART | u-blox SAM-M10Q, 57600 baud | +| I2C SDA/SCL | OLED display | 128x64, address 0x3C, 400 kHz | +| I2C SDA/SCL | LSM6DS3 IMU | Onboard accelerometer/gyro (Sense variant), address 0x6A | +| SPI MOSI/SCK/MISO | SD card | 2 MHz SPI clock (EMI hardened), CS grounded on PCB | +| D1 | Button 1 (Left) | INPUT_PULLUP, RC filter recommended | +| D2 | Button 2 (Select) | INPUT_PULLUP, RC filter recommended | +| 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 | + +--- + +## Subsystem Architecture + +### Main Loop Flow (`loop()`) + +``` +loop() ~250 Hz + ├─ GPS_LOOP() checkUblox, feed CourseManager, log DOVEX + ├─ TACH_LOOP() re-enable ISR after debounce, apply EMA filter + ├─ ACCEL_LOOP() read LSM6DS3 accelerometer X/Y/Z (g-force) + ├─ BLUETOOTH_LOOP() stream file chunks if transfer active + ├─ SENSOREGG_LOOP() drain SensorEgg scan buffer → Temp1/Junction1 + ├─ trackDetectionLoop() haversine scan → create CourseManager on match + ├─ checkForNewLapData() reads from active timer (CourseManager or lapTimer) + ├─ checkAutoIdle() 60s at <2mph → end session (yields while camera recording) + ├─ updateGpsLockHold() pin user to tach page until GPS time lock + ├─ CAMERA_LOOP() step Insta360 auto-record FSM (GPS/tach fresh) + ├─ cameraConsumeAutoStop() camera 30s-engine-off stop → endRaceSession + menu + ├─ calculateGPSFrameRate() 1-second PVT counter + ├─ readButtons() multi-sample debounce + edge detection + ├─ gpsStatusPageLoop() boot GPS status page: GPS re-detect + hold/auto-close/exit + ├─ sdFormatPageLoop() boot SD format page: hold-Select confirm → format + reboot + ├─ displayLoop() pages read from active timer helpers + ├─ autoRaceModeCheck() RPM>500 or speed>=10 → enter race from menu + └─ resetButtons() clear pressed flags +``` + +### 1. GPS & Lap Timing (`gps_functions.ino`, `gps_config.h`) + +- Uses SparkFun u-blox GNSS v3 library with UBX binary protocol. +- `myGNSS` (SFE_UBLOX_GNSS_SERIAL) is stack-allocated in `BirdsEye.ino`. +- **Boot probe ladder** (`GPS_SETUP()` → `gpsSetupProbe()`): the SAM-M10Q + has no flash and every boot is a cold start (sleep = System OFF), so the + module can be in backup mode, configured-57600, or factory-9600. Setup + sends the `0xFF` backup-wake byte FIRST (harmless if awake), probes 57600 + (warm case ≈ instant), falls back to 9600 + `setSerialRate`, and only + pays a 1.5 s cold-boot delay + one retry when nothing answers. Config is + applied via the VALSET API (GPS-only constellation, automotive dynamic + model) at the current rate target, PVT (+ NAV-SAT when wanted) callbacks + registered, and the **5 s PVT-arrival watchdog is armed at boot too** — + a module that answers the ping but streams nothing gets + `GPS_BAUD_RECOVERY()`. A GPS missing entirely is re-probed by + `GPS_STATUS_RETRY_LOOP()` (3×, 10 s apart) while the status page shows + `NOT DETECTED` / `CHECK WIRING`. +- **Two rate modes** (`gpsEnterStatusMode()` / `gpsEnterRaceMode()`): boot + starts in status mode — `GPS_NAV_RATE_STATUS_HZ` (5 Hz) with UBX-NAV-SAT + at ~1 Hz feeding per-satellite CNO into `gpsSatCnos[]` for the status + page's bars. Leaving the page switches to race mode: `GPS_NAV_RATE_HZ` + (25 Hz) PVT-only. `GPS_RECONFIGURE()` and every wake/recovery path + re-assert the *current* targets (`gpsNavRateTarget` / `gpsNavSatWanted`) + — never hardcode a rate. +- **GPS serial buffer — two buffers, two failure modes**: A 4 KB RAM ring + buffer (`gpsRxBuf`) sits between Serial1 and the SparkFun library. A + TIMER3 ISR drains Serial1 into this buffer every 5 ms + (`GPS_DRAIN_INTERVAL_US`), independent of the main loop — this covers + *downstream* stalls (SD GC pauses can block the loop 100 ms–2 s; the + ring holds ~1.6 s). *Upstream*, only the core's Serial1 RX ring absorbs + bytes while SoftDevice radio ISRs (prio 0–2, unmaskable) defer the + prio-3 TIMER3 handler — that ring is grown 64→256 B via the + **required `-DSERIAL_BUFFER_SIZE=256` build flag** (~44 ms of slack at + 57600 baud; `project.h` static_asserts it, CI passes it in all three + workflows, local setup in CONTRIBUTING.md "Local build flags"). Both + overflow points and the worst TIMER3 deferral are counted — see the + `gpsStats*()` accessors, the `gps_stats` pure unit, and the GPS debug + page (first page of the race rotation). The SparkFun library reads from + the buffer via `GpsBufferedStream` (a `Stream` wrapper). During + `GPS_SETUP()` (before timer starts), reads pass through to Serial1 + directly. Timer stopped on shutdown/charging entry, restarted on the + charging-loop resume (`GPS_WAKE()`). +- `GPS_LOOP()` calls `checkUblox()` + `checkCallbacks()`. The registered + `onPVTReceived()` callback fires with the full `UBX_NAV_PVT_data_t` struct, + populates `gpsData`, and sets `gpsDataFresh` flag for downstream processing. +- PVT data is cached in `gpsData` struct (GpsData) for access by display + pages and other subsystems. +- Feeds lat/lng/alt/speed into `CourseManager.loop()` which handles + course detection, Lap Anything fallback, and sector timing internally. +- Logs validated data rows to SD as DOVEX: reserved 1 KB header, + CSV data after byte 1024 (9-check validation pipeline). +- **Log file creation requires a valid time lock**: `onPVTReceived()` sets + `gpsData.timeValid` only when the module asserts `validDate + validTime + + fullyResolved` (and folds `gnssFixOK` into `gpsData.fix`). The log file is + not created from the module's placeholder date — this prevents garbage-named + files (e.g. `20210307_0000.dovex`) that collided every boot and corrupted on + reboot. Until the lock arrives, `updateGpsLockHold()` pins the user to the + tachometer page (engine running; the page shows `WAITING GPS LOCK..` so the + pin never reads as a crash) and logging waits; a failed open is retried + at 1 Hz and a write failure stops logging — **none fault out of race mode**. + The pin **releases after 10 s of engine-off** (session stays active; a + restart re-latches), and while it is active `checkAutoIdle()` may end the + fileless session even if the camera is recording — the recording yield + otherwise left no session-ender and the device looked bricked until a + power cycle (2026-07-19 field incident). +- Time helpers: `getGpsTimeInMilliseconds()`, `getGpsUnixTimestampMillis()`. +- 64-bit timestamps are manually converted to strings (Arduino lacks `%llu`). +- **Wake hardening**: `GPS_WAKE()` (charging-loop resume) clears stale + `gpsDataFresh`/`gpsData.fix`, re-applies VALSET config via + `GPS_RECONFIGURE()`, and arms the same 5 s PVT watchdog as boot. + If no PVT arrives, `GPS_BAUD_RECOVERY()` re-negotiates baud (9600→57600) and + reconfigures. The SAM-M10Q has no flash; all config is volatile RAM only. + +### 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. +- **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 + `tachRingOverflow` instead of lapping the consumer during SD GC stalls; + `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). +- 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. + **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. +- `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. +- The engine-start wake from shutdown is NOT this module's job: System OFF + wakes on the tach pin's GPIO SENSE and the boot decodes the LATCH bit via + `wake_cause` (the old `tachHavePeriod` latch + `TACH_SLEEP()` are gone). + +### 3. Accelerometer (`accelerometer.ino`) + +- Onboard LSM6DS3 6-axis IMU on XIAO nRF52840 Sense (I2C address 0x6A). +- Shares I2C bus with OLED display (0x3C) — different addresses, no conflict. +- `ACCEL_SETUP()` initializes IMU; sets `accelAvailable` flag. Graceful + degradation if IMU not present (non-Sense board). +- `ACCEL_LOOP()` reads `readFloatAccelX/Y/Z()` into global floats every + main loop iteration (~250 Hz). Values in g-force (1g = 9.81 m/s²). +- No filtering — raw g-force is the standard unit for motorsports data. + +### 4. SD Card & Logging (`sd_functions.ino`) + +- SdFat library, FAT16/32, 2 MHz SPI (reduced from default for EMI hardening). + Raised to 8 MHz (`SD_SPI_SPEED_FAST`) for the duration of a BLE or USB + transfer via `sdSetTransferSpeed(true)` and reverted afterward — transfers + happen parked (motor off), so the EMI rationale doesn't apply. Re-`SD.begin()` + is the runtime clock switch; it falls back to 2 MHz if the fast re-init fails. +- Track files live under `/TRACKS/*.json` (ArduinoJson 6 parsing). +- **Blank-card self-provision**: `buildTrackList()` creates `/TRACKS` + when missing (SdFat's `open()` never creates parent dirs), and + `processTrackUpload()` mkdirs it again before every upload — so a + factory-blank soldered-in module can sync tracks over BLE on first boot. +- **On-device format** (`sdPerformFormat()` + the host-tested + `sd_format_page` unit): when `SD_SETUP()` finds the card answers at the + SPI level (`SD.cardBegin` + `sectorCount()`) but `volumeBegin()` fails + — the volume re-check matters: a transient card-level failure with a + healthy FAT must remount, not be offered an erase — boot lands on + `PAGE_SD_FORMAT` (buttons live, unlike FAULT). Hold Select ALONE 3 s + to format FAT16/32 via SdFat's `SD.format()` (zero new RAM; WDT fed + through the formatter's Print callbacks; 8 MHz clock only when the + engine isn't turning — EMI corrupts writes silently — else 2 MHz), + then `/TRACKS` is provisioned (`sdEnsureTracksFolder()`) and the + device reboots clean. The confirm can never fire from the wake press + (Select must be seen released once) nor beat the Select+side reboot + combo (a held side button disarms it). A paired camera is stopped and + powered off (`CAMERA_SLEEP()`) before the format blocks the loop, since + the ending hard reset runs no shutdown teardown. Format failure returns + to the confirm page (marked `FAILED - retry`; fresh full hold to retry — + keeps the idle-timeout battery protection the FAULT dead-end lacks); + a dead/absent card never offers the format. 5 min idle → shutdown + (deferred while the engine runs), and a charging-loop resume with the + card still unformatted returns to the format page, not the menu. +- **Dual JSON format**: `parseTrackFile()` auto-detects root type: + - **Object** (LapWingData format): `longName`, `shortName`, + `defaultCourse`, `courses[]` with `lengthFt`. + - **Array** (older format, still accepted): bare array of course + objects, metadata blank, `lengthFt = 0`. CourseDetector can't + rank by distance without `lengthFt`, so these tracks fall back + to Lap Anything mode. +- **Track manifest**: `buildTrackList()` also builds an in-RAM + `trackManifest[]` (up to 200 entries) with first lat/lon per track + for haversine proximity matching. ~10 KB RAM. +- **SD access arbitration** prevents concurrent access: + - `acquireSDAccess(mode)` / `releaseSDAccess(mode)` + - Modes: `SD_ACCESS_NONE` (0), `LOGGING` (1), `REPLAY` (2), + `BLE_TRANSFER` (3), `TRACK_PARSE` (4), `USB_MSC` (5), `FORMAT` (6) — + values and grant/deny rules live in the host-tested `sd_access_policy` + pure unit. + - `TRACK_PARSE` **nests under `LOGGING`** without taking ownership + (`ownerAfterAcquire`): track detection and settings reads are brief, + same-task, and use their own `File` objects, so they are safe alongside + the session-long logging hold. Before this rule, any boot where the log + file was created before the 1 Hz track-detect parse had the parse + denied and silently fell back to Lap Anything for the whole session. + `USB_MSC` is a normal exclusive holder (held for the whole USB + mass-storage session; see subsystem 12). + - Transitions are **atomic**: the check-then-set runs inside a FreeRTOS + critical section (`taskENTER_CRITICAL`, BASEPRI-masked so SoftDevice + radio interrupts are unaffected) because the Bluefruit callback task + and the main loop share the owner flag. + - Belt-and-suspenders only: all SD-touching BLE work is deferred to the + main loop (see subsystem 6), so SdFat itself is single-task. +- Data flushes every 10 seconds during logging. + +### 5. Display & UI (`display_ui.ino`, `display_pages.ino`, `display_config.h`) + +- Driver selected at compile time (`USE_1306_DISPLAY` define). +- Button debounce: 3 samples at 500 us intervals, 200 ms refire lockout. +- All lap times render via the host-tested `lap_format::formatLapTime()` + (ms → `M:SS.mmm`, always 3-digit ms; zero-minutes styles: `kOmit` for + replay results, `kShow` for the lap list, `kSpace` column-stable for the + big-font live pages). Never hand-roll the `60000`/`%1000` math inline. +- Pages are integer constants; key pages: + - 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 — first in + the rotation) through `LOGGING_STOP`; `SENSOR_TEMP` (7, SensorEgg + Temp1) sits after `TACHOMETER` (6) — non-endurance only, and only + when `BIRDSEYE_ENABLE_SENSOREGG` is set (beta). With the POC off (the + master/release default) the block closes up behind the tach page and + `LOGGING_STOP` is 12 instead of 13, 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, + `PAGE_USB_STORAGE` (-5) USB drive active. + - BLE: `PAGE_BLUETOOTH` (-2). + - Camera: `PAGE_PAIR_CAMERA` (-6) pairing / paired-status management, + `PAGE_CAMERA_SERIAL_ENTRY` (-7) manual 6-char serial entry fallback, + `PAGE_CAMERA_TEST` (-10) bench test menu (paired-only manual controls). + - Course creator (subsystem 15): `PAGE_COURSE_TRACK` (-11) track prompt, + `PAGE_COURSE_TYPE` (-12) circuit/sprint, `PAGE_COURSE_LINES` (-13) line + menu, `PAGE_COURSE_LINE` (-14) per-line points, `PAGE_COURSE_POINT` + (-15) averaging hold. All five are contiguous so `courseCreatorActive()` + is a range test. + - Errors: `PAGE_INTERNAL_WARNING` (100), `PAGE_INTERNAL_FAULT` (105), + `PAGE_SD_FORMAT` (106, card responds but FAT won't mount — driven by + `sdFormatPageLoop()`, buttons live unlike FAULT). + +### 6. Bluetooth (`bluetooth.ino`) + +- **Shared BLE core, used by transfer and the camera** (see subsystem 13): + the one-time `bleCoreEnsureInit()` runs `Bluefruit.begin(1, 0)` — one + peripheral connection, no central (both the transfer service and the + camera remote are peripheral roles) — configures Just-Works bonding, and + registers *every* GATT service (DFU, DIS, file service, camera remote via + `cameraBleRegisterServices()`) before any advertising starts. + `BLE_SETUP()` / `BLE_STOP()` are now just the transfer-mode owner + transitions on top of that core. +- **Radio ownership (`BleOwner`)**: the single advert set + peripheral + connection slot are shared between the transfer service and the camera + remote. `bleOwner` (`NONE` / `TRANSFER` / `CAMERA`, enum in `project.h`, + variable in `BirdsEye.ino`) records the current owner; the shared + connect/disconnect callbacks route on it, so a camera link can never + trigger the transfer auto-reboot and file commands are ignored unless + the transfer service owns the radio. `bleActive` / `bleConnected` keep + their transfer-only meanings — camera mode never sets them. Owner + transitions happen only on the main loop, never in a Bluefruit callback. + - **Reboot gate (`bleTransferEngaged`)**: the radio has one BD_ADDR, so a + bonded camera can connect to the *transfer* advert and be routed as "the + phone". The auto-reboot-on-disconnect is therefore gated on the peer + having actually written the file/settings/OTA service — a camera that + only vets our GATT and drops never reboots the logger out of a transfer + session (and held no SD, so there's nothing to tear down). + - **Advert teardown**: `BLE_STOP()` disarms `restartOnDisconnect(false)` + *before* its async disconnect, so Bluefruit's core handler can't restart + a stale ownerless transfer advert after the stop (which would let a phone + reconnect into a mute session and occupy the slot camera auto-record + needs). +- BLE service UUID `0x1820`. +- Characteristics: file list (0x2A3D), file request (0x2A3E), + file data (0x2A3F), file status (0x2A40). +- **OTA + version services** (registered in `bleCoreEnsureInit()`): + - `BLEDfu bledfu` — buttonless Nordic Secure DFU. A companion + (DovesDataViewer over Web Bluetooth) writes the "enter bootloader" + command; the board reboots into the bootloader's Secure DFU mode and + receives a new firmware image over the air — no reset double-tap. The + bootloader validates the signed/CRC'd DFU `.zip` before writing, so a + bad/mismatched image is rejected rather than bricking the device. The + board has no internet radio (BLE only): the companion downloads the + GitHub release `.zip` and force-feeds it — the bootloader never + "chooses" a file. + - `BLEDis bledis` — Device Information Service (0x180A). Publishes + `FIRMWARE_VERSION` (from `project.h`) via the Firmware Revision + characteristic (0x2A26) so the companion can compare against the latest + GitHub release and decide whether to offer an update. The Model string + is `"BirdsEye-" FIRMWARE_VARIANT` (`BirdsEye-sense` / `BirdsEye-nonsense`) + — equal to the release asset prefix, so the companion maps model → + 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). +- **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 + deferred state) is only parsed/validated in the BLE callback and is + executed by `BLUETOOTH_LOOP()` on the main loop. Listings hold the SD + lock for the whole directory walk; `DELETE` takes the lock and refuses + (`BUSY`) while a transfer is streaming. One file command may be queued + at a time — a second gets the protocol's busy reply (`BUSY` / + `TERR:BUSY`). +- **Filename validation**: every BLE command carrying a filename + (`GET:`, `DELETE:`, `TGET:`, `TPUT:`, `TDEL:`) runs the name through + `filename_validator::isValidFilename()` BEFORE any `SD.open()` / + `SD.remove()` / `"/TRACKS/%s"` path build. Rejects path traversal + (`..`, leading `.`), separators (`/`, `\`), and FAT-unsafe bytes. + `GET`/`DELETE` reject with `ERROR` / `NOT_FOUND`; track commands + reject with `TERR:BAD_NAME`. +- **Settings commands** (via `fileRequestChar` / `fileStatusChar`): + - `SLIST` → `SVAL:key=value` per entry, then `SEND` + - `SGET:key` → `SVAL:key=value` or `SERR:NOT_FOUND` + - `SSET:key=value` → `SOK:key` or `SERR:reason` + - `SBUSY` returned if a command is already pending. + - Uses deferred execution: BLE callback copies command into buffer, + `BLUETOOTH_LOOP()` processes it in main loop for thread-safe SD access. +- **Track management commands** (via `fileRequestChar` / `fileStatusChar`): + - `TLIST` → `TFILE:name.json` per file, then `TEND` + - `TGET:name.json` → reuses existing file transfer (`SIZE:N` → data chunks → `DONE`) + - `TPUT:name.json` → `TREADY` → app sends data chunks → `TDONE` → `TOK` + - `TDEL:name.json` → `TOK` or `TERR:NO_FILE` + - **Sprint variants** (`/TRACKS/SPRINT`, plan 0002) — same four verbs with a + `TS` prefix, sharing the circuit code paths with a `kind` parameter: + `TSLIST` → `TSFILE:name.json` per file, then `TSEND` (distinct tokens so a + client can't confuse the two enumerations); `TSGET:` / `TSPUT:` / `TSDEL:` + behave exactly like their circuit twins and reuse their replies + (`SIZE:`/`DONE`, `TREADY`/`TDONE`/`TOK`, `TERR:*`). + - **The folder is never taken from the wire.** `filename_validator` still + rejects `/`, `..` and FAT-unsafe bytes on every track command; which of the + two folders a command targets is decided by the *opcode* alone + (`trackFolderFor()`), so a client cannot path its way between them. + - Upload uses a 4096-byte static RAM buffer; `TERR:TOO_LARGE` if exceeded. + - Error responses: `TERR:SD_BUSY`, `TERR:BUSY`, `TERR:WRITE_FAIL`, `TERR:NO_FILE`, `TERR:BAD_NAME`. + - Upload/delete state machines: BLE callback sets flags, `BLUETOOTH_LOOP()` + calls `processTrackUpload()` / `processTrackDelete()` for thread-safe SD + access. Both call `buildTrackList()` after success. +- **Firmware OTA commands** (`FW*`, handled by `firmware_ota.ino` — see + subsystem 11): `FWBEGIN`/`FWPUT`/`FWDONE`/`FWAPPLY`/`FWABORT`/`FWDFU`. The BLE + callback dispatches them via `fwIsCommand()`/`fwHandleCommand()` and routes + 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 + 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 + and boot the old firmware, so the apply is left to `FW_OTA_LOOP()` (called + later in the same `BLUETOOTH_LOOP()`), which owns its own reset. + +### 7. Replay (`replay.ino`) + +- Instant DOVEX header-only replay. `parseDovexHeader()` reads the + metadata line and the lap-times line from the first 1 KB of the + file, populates `dovexReplay*` globals + `lapHistory[]`, and the + results page renders directly from those — no file streaming, no + re-running the lap timer. Only `.dovex` files shown in the browser. +- `haversineDistanceMiles()` lives here too; the track-detection loop + in `BirdsEye.ino` uses it for proximity matching. + +### 8. Settings (`settings.ino`) + +- Persistent JSON key-value store at `/SETTINGS.json` on SD card. +- `SETTINGS_SETUP()` called once from `setup()` after SD init; creates + default file on first boot (random BLE name + PIN + racing-word device name). +- **Auto-populate**: `ensureDefaultSettings()` checks for missing keys on + boot and adds them with defaults. Existing values are never overwritten. +- `getSetting(key, buf, bufSize)` reads a value into a caller-provided + buffer. Returns `true` if found, `false` on any failure (buf set empty). + 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 + 4096-byte buffer. +- Total RAM cost: ~1 KB (512-byte file buffer + 512-byte JSON document). + +### 9. CourseManager Integration + +- **CourseManager** (`courseManager` global pointer): created when a track + is detected via haversine proximity match, or with `courseCount=0` for + immediate Lap Anything activation. +- **Track detection flow** (`trackDetectionLoop()`): + 1. Valid GPS time lock acquired → DOVEX log file created (see GPS section). + 2. Scans `trackManifest[]` via haversine, throttled to 1 Hz (the scan + is O(N) software-double math; `gpsData.fix` stays true between PVT + updates, so an unthrottled scan ran every ~250 Hz loop iteration). + 3. Closest match within 5 miles → parse full JSON, build `TrackConfig`. + 4. Create `CourseManager` with settings-configurable thresholds. + 5. CourseManager handles course detection + Lap Anything fallback. + 6. No tracks / no match → `CourseManager(courseCount=0)` → Lap Anything. +- **Active timer abstraction**: helper functions (`activeTimerLaps()`, + `activeTimerBestLapTime()`, etc.) provide a unified interface for display + pages. They check CourseManager's active timer (DovesLapTimer or + WaypointLapTimer) and return appropriate values. +- **Auto-race** (`autoRaceModeCheck()`): from main menu, if RPM > 500 or + speed >= 10 mph, jumps directly to race mode — but only once the menu has + been **settled** for `AUTO_RACE_MENU_GRACE_MS` (3 s), anchored on the newest + of the menu-arrival stamp (`mainMenuEnteredAtMs`, set by + `switchToDisplayPage()`) and the three button `lastPressed` values. Without + it, exiting any page while moving landed on the menu and entered race mode on + the very next loop iteration (~4 ms), so the menu was never drawn and the + device looked like it acted on its own. +- **Auto-idle** (`checkAutoIdle()`): if speed < 2 mph for 60 seconds + continuously, writes DOVEX header, closes file, cleans up CourseManager, + and returns to main menu. **Sprint mode is engine-aware**: idle counts + only while the tach reads 0 too (between-run queue waits keep the engine + running), and every completed run re-arms the 3-minute grace period. +- **Sprint mode (plan 0002)**: tracks under `/TRACKS/SPRINT/` make the + session point-to-point. `trackDetectionLoop()` finds the nearest + manifest entry PER KIND; with both kinds in range the `race_mode` + setting breaks the tie via the host-tested `sprint_select` unit (the + event-day heuristic parses the sprint file once for its newest + `date_created`). The sprint path skips CourseManager/CourseDetector + entirely — `createSprintSession()` picks the newest course + (`sprint_select::newestCourseIndex`) and stands up the library's + `SprintTimer` (start + separate finish + optional S2/S3 lines; ~11.6 KB + heap, one instance). `sprintTimer != nullptr` IS sprint mode — + `courseManager` stays null for the session and every `activeTimer*()` + helper checks the sprint branch first (runs duck-type as laps; "laps" + verbiage kept everywhere by design). Run completion is captured on the + RUN-COUNT EDGE in `checkForNewLapData()` (identical consecutive run + times are normal; value-change dedupe would drop them). Between runs + the Current Lap / Pace pages show `*waiting*` + (`sprintModeIsActive() && !activeTimerRunActive()`); everything else + stays live. The DOVEX header gets `race_mode=SPRINT` and the sprint + course name. + +### 10. Shutdown (System OFF) + +"Sleep" is a full power-down: nRF52 **System OFF** (~µA), designed so the +hardware needs no power switch. Wake = chip reset = fresh `setup()`. + +- **Entry** (`enterShutdown()`): long-press left+right (5 s) on main menu, + 5-min menu idle, the GPS status page's idle timeout, the SD format + page's idle timeout (deferred while the engine runs, so a tach-wake + with a bad card doesn't power-cycle all session), or — only with + `BIRDSEYE_ENABLE_ONBOARD_CHARGING` — USB present on the main menu after + 60 s of button inactivity (`USB_MENU_CHARGE_IDLE_MS` — not immediate, so + a charging-loop button wake doesn't bounce and the device stays usable + for replay/transfer while plugged in). With onboard charging off (the + default) that USB trigger is compiled out entirely: the firmware isn't + managing the charge current, so a cable is no reason to cut the menu + short — the plain 5-min idle still fires and still parks on VBUS. +- **Teardown order** (wdtPet-bracketed — `CAMERA_SLEEP()`'s 3 s ce82 + power-off hold is the longest step under the armed ~4 s WDT): end race + session → `CAMERA_SLEEP()` → `BLE_STOP()` if active → `DISPLAY_SLEEP()` + → `GPS_SLEEP()` (u-blox software backup, µA, config retained while + powered; TIMER3 stopped) → IMU power rail off. +- **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** + (15 reads over ~30 ms, majority vote in the host-tested + `wake_cause::tachIdleIsHigh()`) and configure + `nrf_gpio_cfg_sense_input(pull-up, SENSE opposite the idle level)` on + the tach pin — the pickup's Schmitt-inverter + optocoupler output + stage idles high or low depending on the circuit build, and arming + toward the idle level was an instant wake-reset loop on battery + (runtime RPM counting can't tell the polarities apart: one falling + edge per pulse either way). Buttons are fixed active-low → + `SENSE-LOW` on all 3 (P-numbers via `g_ADigitalPinMap`, never + hardcoded). Then clear the GPIO LATCH registers + (a set latch = pending DETECT = instant re-wake), clear pending FPU + exceptions, then `sd_power_system_off()` when the SoftDevice is enabled + (BLE is lazy — check `sd_softdevice_is_enabled()`) else raw + `NRF_POWER->SYSTEMOFF`. **GPREGRET is untouched** — register 0 belongs + to the OTA/bootloader handoff (subsystem 11). The WDT halts in System + OFF (all clocks stop); `wdtSetup()` re-arms on the fresh boot. +- **Wake sources**: tach pulse (D0, engine start — any transition away + from the sampled idle level), any button, or VBUS (USB plug-in, + always armed on nRF52840). +- **Wake-cause decode** (`captureBootWakeCause()`, FIRST thing in + `setup()`): reads then clears `RESETREAS` + `NRF_P0/P1->LATCH` (sticky, + cumulative) and decodes via the host-tested `wake_cause` unit. A tach + wake makes the GPS status page exit into race mode with logging; a USB + wake skips the status page straight into the charging loop — that + shortcut too is `BIRDSEYE_ENABLE_ONBOARD_CHARGING`-only, since with + charging off a cable means "host connected" and the device just boots + normally (its idle timeout parks it on VBUS soon enough). +- **Onboard charging is a build flag** (`BIRDSEYE_ENABLE_ONBOARD_CHARGING` + in `project.h`, **0 in every shipped build** since 3.0.1): the hardware + now carries an external charging circuit, so the firmware leaves HICHG + alone (BQ25100 stays at its ~50 mA default) and drops the charging UX. + Set it to 1 to restore the pre-3.0.1 behavior. The VBUS park below is + NOT gated on it. +- **Charging loop — the one soft-sleep survivor** (`runChargingShutdownLoop()`): + System OFF is never entered while VBUS is present. Two reasons: the + HICHG fast-charge pin (`PIN_CHARGING_CURRENT`) is software-held when + onboard charging is compiled in, and — regardless of the flag — VBUS is + an always-armed System OFF wake source, so entering OFF with the cable + in risks an immediate wake-reset loop. After the + same full teardown, the loop shows the charging screen for 10 s then + turns the display off; **any button is a full wake to the main menu** + (`softResumeFromCharging()`: IMU re-init, race-mode GPS targets + + `GPS_WAKE()`, display on); unplugging drops to System OFF. CPU idles + via `shutdownIdleWait()` — `sd_app_evt_wait()` only when the SoftDevice + is actually enabled, `__WFE()` otherwise. + +### 11. Firmware OTA (`firmware_ota.ino`, `crc32.{h,cpp}`) + +- **Why self-flash**: Chrome's Web Bluetooth blocklist bans the Nordic + *legacy* DFU service `BLEDfu` exposes, and the sealed units have no + button/SWD pins to install a web-allowed Secure-DFU bootloader. So the app + updates itself: the web app streams the image to SD over the existing + `0x1820` service, the firmware CRC-verifies it, stages it to a free flash + region, and a RAM flasher swaps it into the app region and resets. The + bootloader is **not** changed for the field flow. +- **Wire protocol** (text on `0x2A3E` in / `0x2A40` out; image bytes are raw + binary writes to `0x2A3E`): + - `FWBEGIN:,,` → `FWCRC:` (echo handshake + to verify the control channel before any upload). `` (`sense` / + `nonsense`) is the target board variant the web app derives from the + device's DIS Model Number; the firmware compares it (case-insensitive) to + `FIRMWARE_VARIANT` and replies `FWERR:VARIANT` here — the single variant + gate, before any upload. + - `FWPUT:` → `FWREADY`, then raw ≤240-byte chunks streamed to SD + (`/fw/pending.bin`), then `FWDONE` → `FWOK:` (CRC of the stored + file) or `FWERR:CRC|SIZE|WRITE`. + - `FWAPPLY` → `FWSTAGE:` (0–100, repeatable) → `FWAPPLIED` then reset, + or `FWERR:`. `FWABORT` cancels at any point. + - `FWDFU` → `FWDFU:OK`, then the device reboots into the Adafruit + bootloader's **UF2 mass-storage DFU** (GPREGRET `0x57` = + `DFU_MAGIC_UF2_RESET` — a stock bootloader feature, same handoff + register the OTA recovery flag uses). The device enumerates as a USB + drive; copying a `.uf2` onto it flashes the app region directly — **no + image-size cap, no staging region**. This is the "pre-update" escape + hatch (plan 0004): any unit carrying this command can always be + updated with just a USB cable regardless of future image growth. + Exiting UF2 mode without flashing boots the existing app unchanged. + Accepted from any FW state (an in-flight OTA is cleanly aborted + first); executed on the main loop like all FW commands. + - Error tokens: `CRC`, `SIZE`, `WRITE`, `BATTERY`, `VARIANT`, `STATE`, + `FLASH`. +- **CRC**: CRC-32/IEEE-802.3 (zlib), reflected poly `0xEDB88320`, init/xor + `0xFFFFFFFF`, lowercase 8-char hex, compared case-insensitively. Shared + with the web client via the host-tested `crc32` pure unit. Sanity vector + `crc32("123456789") == 0xcbf43926`. +- **Threading**: like track upload, the BLE callback only parses commands and + copies chunk bytes into a RAM double-buffer; `FW_OTA_LOOP()` (main loop) + does all SD writes, CRC verify, and the apply sequence. SD held via + `SD_ACCESS_BLE_TRANSFER` for the receive. +- **Apply** (`fwDoApply()`): guards first — refuse below `FW_MIN_APPLY_VOLTAGE` + (3.6 V, uses cached `lastBatteryVoltage`) → `FWERR:BATTERY`. (Variant is + validated earlier, at `FWBEGIN`; no image-byte scan here. The image still + embeds `kFwImageDescriptor` for forensics.) Then `fwStageToFlash()` copies + SD → upper flash + (`FW_STAGE_BASE`, via the `flash_nrf5x` HAL) and **re-verifies the CRC in + flash before the app region is ever erased** (`FWERR:FLASH` on mismatch). + Only then: emit `FWAPPLIED`, arm the GPREGRET recovery flag + (`FW_GPREGRET_OTA_DFU`), disable the SoftDevice, and call the RAM-resident + `fwRamFlasher()` to erase the app region, copy the staged image down, and + reset. +- **Recovery net**: an interrupted swap leaves an invalid app, so the + bootloader comes up in BLE DFU and the unit is re-flashable over the air via + the nRF Connect mobile app — no pins. **This is the one Phase 0 spike still + unproven on hardware.** The apply path itself has shipped and is flashing + units in the field, which closes the other spikes by demonstration; but a + *successful* update never walks the recovery path, so it stays untested + until someone deliberately corrupts an app region and confirms the unit + comes back. See `docs/plans/0000-firmware-ota-phase0.md` → *The one test + still outstanding*. +- **Fleet migration**: the first firmware carrying `FW*` is pushed to sealed + units once via nRF Connect (native app, buttonless trigger works on the + existing single-bank bootloader); all later updates go through the web app. + +### 12. USB Mass Storage (`usb_msc.ino`) + +- **Why**: a wired, app-free way to move files. The SD is FAT16/32, so a + host PC can mount it as a drive and drag-and-drop track JSON / DOVEX logs. + Complements (does not replace) the BLE transfer service. +- **Stack**: TinyUSB `Adafruit_USBD_MSC` (bundled in the Seeed/Adafruit + nRF52 core; the core's default USB stack is TinyUSB). Three block + callbacks wrap SdFat's block device: `msc_read_cb` → + `SD.card()->readSectors()`, `msc_write_cb` → `writeSectors()`, + `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()`. +- **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. + `USB_MSC_ENABLE()` first requires **VBUS present** (`isUsbConnected()`) — + without a cable there is nothing to mount and the parked loop would read + absent VBUS as a cable-pull and instantly reset, so it bails before taking + the lock or enumerating (the menu shows "Plug in USB cable first"). It then + acquires `SD_ACCESS_USB_MSC`, sets the capacity from + `sectorCount()`, marks the unit ready, `begin()`s the interface (bailing + out — restoring the clock and releasing the lock — if `begin()` fails), + and forces a `TinyUSBDevice.detach()` / 50 ms / `attach()` re-enumeration + so the host mounts the drive. If the SD mutex is busy it bails to a + warning page and changes nothing. +- **Loop parking**: while `usbMscActive`, `loop()` takes an early-return + branch (mirroring the `bleActive` branch) that skips all GPS/tach/lap/SD + processing — the host PC owns the FAT, so the firmware must not touch it + concurrently. The branch services only the Exit button and the status + 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 + 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, + replay, and BLE transfer are locked out (and vice-versa) — though loop + parking, not the mutex, is the primary guarantee the firmware stays off + the card. The transfer/USB pages are not the main menu, so the + USB-on-main-menu charge-mode entry never fires while transferring. +- **No pure unit test**: the block-callback glue is TinyUSB/Arduino-bound + (hardware), so there is no host-testable logic here — only the + `sd_access_policy` mode addition is unit-tested. + +### 13. Camera Auto-Record (`camera_ble.ino`, `camera_fsm.{h,cpp}`, `insta360_protocol.{h,cpp}`) + +- **What**: hands-free Insta360 X4 control. The device *is* the Insta360 + "GPS Remote": it emulates the physical remote so a paired X4 is woken + when the engine starts, records the session, and powers itself off + afterward — the driver never touches the camera. +- **Single BLE role: peripheral remote** on the one SoftDevice + (`Bluefruit.begin(1, 0)`). We host the remote's GATT — service `0xCE80` + (ce81 WRITE camera→us carrying its serial + status frames, ce82 NOTIFY + us→camera button frames, ce83 READ static) — advertise the wake/identity + payload, and **the camera connects to us as central** and subscribes to + ce82. **All control is a ce82 button notification**, byte-for-byte the + physical remote's frames: recording toggles via the shutter button, and + power-off streams the 3-second power-hold. We never act as central: no + scanning, no `be80` client, no `be81` writes. (The old central role held + a `be80` link to the camera for start/stop-video — removed. It made + power-off impossible: power-off only exists as a remote `ce82` hold, + which cannot coexist with being the camera's `be80` client.) +- **GPS overlay** (`cameraServiceGpsStream()`): a Wireshark capture of the + genuine remote↔camera link showed the remote streams GPS on **`ce82` at + 10 Hz** as a non-standard NMEA-RMC frame (`FC EF FE 83 00 + ,26.7,\x07,$GNRMC,...` — signed longitude with a constant `E`, an extra + `V` field; built + golden-tested in `insta360_protocol::buildGpsRmcFrame`). + The firmware streams it continuously while the camera is connected + + subscribed — this doubles as the remote's **liveness heartbeat** (never + go silent or the camera drops us; status `V` with last-known coords when + there's no fix), paused only during a power-off hold. GPS still logs to + SD independently. +- **Lifecycle FSM** (`camera_fsm` pure unit, host-tested): the race-mode + lifecycle is deliberately **RPM-driven and simple**. 7 states — UNPAIRED / + IDLE / WAKING / AWAIT_READY / RECORDING / **WATCHING** / PAIRING (the old + COOLDOWN/POWERING_OFF tail is gone — power-off is now sleep-only). + RPM > 500 held 2 s enters WAKING, which broadcasts the 31-byte CONNECTABLE + wake advert — the sniffed GPS-Action-Remote manufacturer payload (serial at + mfg[14..19], per the primary `pchwalek/insta360_ble_esp32` source) in the + primary PDU with the "Insta360 GPS Remote" name in the scan response, both + set as raw bytes so the stack can't reshape them (retry ×3). The woken + camera connects back and the FSM moves to AWAIT_READY (wait for the ce82 + subscription; bounded re-wake if it never subscribes) → **WATCHING**. + (Wake only reaches an ARMED camera: on the X4, Bluetooth Wakeup is armed + when **QuickCapture is OFF** — an armed camera keeps its radio scanning even + fully powered off. Every advert goes through `bleAdvFinalizePadded()` — see + bluetooth.ino — to defeat the Bluefruit 0.21.0 frozen-packet-length core + bug.) **Recording starts** from WATCHING once RPM has held at/above + `kRecordRpmThreshold` (1500 — deliberately far above the 500 wake + threshold: pull-start cranking blips clear 500 and once started a + recording during a failed first start; any dip below 1500 restarts the + clock) for `kRecordStartDelayMs` (5 s) — **no GPS-lock gate** (GPS still + streams the whole time) — by sending one shutter-toggle ce82 frame. The shutter is a + stateful TOGGLE, so the FSM never blind-fires it: it **confirms** record + state from the camera's own `0x10` ce81 display-string frame (a live + `.HH:MM:SS` timer while recording — the `0x02` status word is not reliable; + `insta360_protocol::parseRecordingState`) and reconciles `recordingActive` + against that observation (`RecordObs` Input). On reconnect it adopts the + camera's real state instead of toggling; if the camera reports idle while we + believe we're recording it re-asserts the shutter once; and the belief is + preserved on any path where the camera is unreachable, so a dropped link + can't invert on reconnect. **Recording stops** after `kStopRecordDelayMs` + (30 s) of engine-off (RPM < 300) — **RPM only, no speed**, so a + stationary-but-running grid idle keeps recording — sending one shutter + toggle and returning to WATCHING. The 30 s-engine-off auto-stop also **ends + the race log session** (see the read-only note below). A manual session end + (`CAMERA_NOTIFY_SESSION_END()` from the logging-stop confirm) stops the + camera immediately, also to WATCHING. **WATCHING** keeps the camera ON and + connected: if RPM returns it re-records (stall recovery), and it powers the + camera off **only when the device shuts down** (`CAMERA_SLEEP()` streams the + ce82 power-hold synchronously) — there is no post-record cooldown/power-off + timeout. All timing lives in the FSM so the temporal behavior is + host-testable; every tunable is a single-point `constexpr` in + `camera_fsm.h`. The unit is the board-portable core shared with the nRF54 + ("Falcon") target — nothing in it may `#ifdef` on the platform. +- **Telemetry consumer, with one deliberate write-back**: the FSM consumes an + `Inputs` snapshot (RPM, link state, observed record state, one-shot events) + built fresh each `CAMERA_LOOP()` and returns at most one `Action`. Camera + mode never parks the main loop (unlike `bleActive` / `usbMscActive`). The + ONE exception to the old read-only guarantee: when the camera auto-stops + (30 s engine-off), the glue latches `cameraConsumeAutoStop()` and the main + sketch calls `endRaceSession()` + returns to the menu — so with a camera + paired+recording the log ends on 30 s-no-RPM instead of the speed-based + auto-idle (which `checkAutoIdle()` suppresses while `cameraActivelyRecording()`). + Without a camera, logging is unchanged. +- **Pairing / bonding**: entering pairing from `PAGE_PAIR_CAMERA` + advertises connectably as "Insta360 GPS Remote"; after connecting, the + camera writes its 6-char ASCII serial to ce81, which is captured and + persisted in the `camera_serial` setting (empty = unpaired). The manual + 6-char entry page (`PAGE_CAMERA_SERIAL_ENTRY`) is the fallback. Pairing + times out after 2 min. The genuine remote link is encrypted + bonded, so + we support Just-Works (NoInputNoOutput) pairing as peripheral — the + camera may withhold its ce82 subscription until the link is encrypted. +- **Coexistence** (`bleOwner`, see subsystem 6): the camera shares the + single advert set + peripheral slot with the transfer service. Opening + the Bluetooth transfer page calls `CAMERA_FORCE_RELEASE()` before + `BLE_SETUP()` — best-effort stop recording, drop the camera link, stop + camera-owned advertising, force the FSM to IDLE, release the radio. + Shutdown entry runs `CAMERA_SLEEP()` (same, plus a power-off that is + **streamed synchronously** before the disconnect — the chip powers off + right after, so the non-blocking ce82 hold would otherwise never transmit + a frame and the camera would run all night). BLE + comes up **lazily** on the first camera action (first advertising + `Action`), so unpaired users pay zero RAM/power cost. +- **Threading**: same deferred pattern as `firmware_ota` — Bluefruit + callbacks (connect/disconnect/ce81 writes/ce82 CCCD writes) only copy + into RAM and set volatile flags; `CAMERA_LOOP()` on the main loop + consumes them, steps the FSM, and does all real work (including the one + `setSetting()` that persists a captured serial and streaming the + power-off hold). The ce82 CCCD callback latches a cached `ce82NotifyOn` + flag so the hot paths (per-loop Inputs snapshot + 10 Hz GPS tick) never + SVC into the SoftDevice for the subscription state; `CAMERA_LOOP()` + re-reads `notifyEnabled()` only at a low rate while linked-but-not-yet-known- + subscribed, to catch a bonded peer's silent sys-attr CCCD restore. +- **X4-VERIFY posture**: all frame bytes live in the host-tested + `insta360_protocol` pure unit with golden-byte tests. The **wake + advert + scan response are X4-CONFIRMED ground truth** — captured from + a genuine GPS Remote with nRF Connect (2026-07-10 bench session) and + the replayed packet woke the sleeping X4; flags are `0x05`, and the + paired camera-mode advert presents this same remote-identity packet. + Remaining `// X4-VERIFY(sniff)`: the ce82 button frames (proven capture + bytes; the record/power-off *effect* still to be confirmed end-to-end on + an X4) and the ce81/ce83 parsers. +- **Bench test menu** (`PAGE_CAMERA_TEST`): the paired Camera page has a + **Test** entry opening a manual-control menu (Wake / Record / Power Off / + Back) plus live remote (**R**) link status (`R:UP+` when the camera is + connected AND subscribed to ce82, `R:UP` when connected but ignoring our + buttons), advert (**Adv:**) status, a **G:** GPS-feed indicator (`SYNC` + with a fix / `V` voided-but-streaming / `--` not streaming) and a + **rec:yes/no** state driven by the camera's own `0x10` record timer + (`rec:--` when there's no fresh observation — no link, or the camera hasn't + pushed a `0x10` yet — so a missing signal isn't misread as "not recording") + — so both the GPS link and that a Record press actually started the camera + can be verified without staging RPM/GPS to drive the FSM. **Wake** presents the + wake / remote-identity advert so a standby camera wakes and an on camera + connects back to us; **Record** sends the ce82 shutter toggle; **Power + Off** streams the ce82 hold — both need the camera connected + subscribed. + `cameraTestEnterMode()` forces the FSM to IDLE and sets `cameraTestActive`, + which makes `CAMERA_LOOP()` suppress the FSM step (so auto-record can't + fight the manual actions) while the Bluefruit callbacks keep the link + serviced; `cameraTestExitMode()` stops any recording (tracked in a + bench-local belief so the camera is **guaranteed** left stopped on exit) + and tears the session down. The `cameraTest*()` action helpers reuse the exact + `cameraExecuteAction()` code paths the FSM would run — no new FSM states, + so the board-portable pure unit is untouched. +- **X4 field notes** (from live bench testing, informing the above): the + camera connects to our ce80 remote (R link) only *after* it has been + paired from the camera's own **Settings → Bluetooth remote** menu — + capturing the serial (subsystem UI pairing) is not sufficient. The + wake-burst advert only wakes an *armed* camera (X4: QuickCapture OFF = + "Bluetooth Wakeup Enabled"; armed cameras keep the radio scanning even + powered off, un-armed ones are BLE-dead). There is **no** `be80` + power-off command in any known reference implementation — power-off is + the `ce82` 3-second-hold button frame over the R link, so both Record and + Power Off depend on the R link being up and subscribed. + +### 14. SensorEgg Wireless EGT (`sensoregg.ino`, `sensoregg_protocol.{h,cpp}`) + +- **BUILD FLAG — `BIRDSEYE_ENABLE_SENSOREGG` (`project.h`)**: this whole + subsystem is a beta-channel feature. `0` (master/release default) + compiles `sensoregg.ino` down to no-op `SENSOREGG_SETUP/LOOP` and NaN + accessors, drops the Temp1 race page from the rotation + (`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. + 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 + **advertising packets** — protocol `PW-ADV-1`: 14-byte Manufacturer + Specific Data (`FF FF` company ID + `50 57` magic *inside* the array, + version, flags, int16 LE deci-°C ×2 with `0x8000` = invalid sentinel, + raw MCP9600 STATUS, battery stub, uint16 sequence), ~10 Hz. +- **Radio role — do not "improve" this**: the logger is a pure passive + OBSERVER (`Bluefruit.Scanner`, `useActiveScan(false)`, 90 ms interval / + 40 ms window ≈ 44% duty, RSSI ≥ −90). No SCAN_REQ, no connection, no + GATT — so it cannot contend with the camera peripheral link for TX + airtime; S140 time-slices scan windows around connection events. The + egg accepts no connections. **The camera link wins every tradeoff** — + and scan duty is capped (test-enforced ≤45%) because SoftDevice + scan-window ISRs defer the TIMER3 GPS drain (see subsystem 1). +- **Scanner robustness (bench-proven, do not remove)**: (1) + `Scanner.filterMSD(0xFFFF)` rejects ambient packets INLINE — Bluefruit + self-resumes filtered reports, while an accepted report pauses scanning + until the deferred rx callback runs, so without this filter desk BLE + traffic collapses the scan duty in bursts. (2) Anti-phase-lock lives in + the **interval**: equal 100 ms adv/scan periods phase-lock and parked + the egg in the deaf zone for seconds; the 90 ms scan interval (plus the + egg advertising off-100 ms) sweeps relative phase ~10 ms/cycle so a + deaf-zone park escapes in ≤~450 ms. (The original fix was a 60 ms + window — 60% radio duty, which deferred the GPS drain enough to drop + PVT frames; the interval retune replaced it and returned the window to + the spec's 40 ms.) (3) `SENSOREGG_LOOP()` kicks stop+start after 30 s + with no accepted packet — a lost deferred callback otherwise halts the + scanner silently forever. +- **Pairing (POC)**: `SENSOREGG_MAC` #define in `sensoregg.h`, human byte + order; all-zeros (default) = accept any advertiser matching the payload + magic. The scan callback filters length + magic + MAC, copies the raw 14 + bytes into a double buffer (camera ce81 idiom), stamps `millis()`, and + calls `Scanner.resume()` — **mandatory**, or the scanner halts after one + report. No Serial/SD/display in the callback (BLE task context). +- **Consumption**: `SENSOREGG_LOOP()` (main loop) drains + parses via the + host-tested `sensoregg_protocol` unit. Accessors: `sensoreggEgtC()` / + `sensoreggJunctionC()` (NaN when stale, egg-invalid, or app-hung), + `sensoreggLinkUp()`, `sensoreggTcFault()`, `sensoreggAppHung()`. +- **Zombie-egg detection**: BLE radios rebroadcast the last-set advert + buffer autonomously, so an egg whose *application* hangs (suspected + blocking MCP9600 I2C read under ignition EMI; 2026-07-19 field incident, + ~3–4 h in) keeps beaconing a frozen payload at 10 Hz — arrival-time + freshness alone reports a live link with a flat-lined value. The payload's + uint16 sequence counter is the sign of life: `sensoregg_protocol::SeqMonitor` + (host-tested; wrap-safe) marks the reading dead when the sequence hasn't + changed within `kStalenessMs` even though packets arrive. Readings go NaN + (log `nan`), and the Temp1 page shows `rf:HUNG` (egg needs a power cycle) + instead of `rf:OK`. **Staleness (1 s) is absolute** — a reading is never + held across a dropout (a held value draws a flat line indistinguishable + from real data). Logging writes `Temp1`/`Junction1` (or `nan`) **in + Celsius**; the `SENSOR_TEMP` race page (after the tach page) shows big + EGT + junction + `rf:` link subtext **in Fahrenheit** — converted at + render time only via `sensoregg_protocol::celsiusToFahrenheit()` (a C/F + display setting comes later). +- **BLE lifetime change**: `SENSOREGG_SETUP()` (called from `setup()` after + `CAMERA_SETUP()`) runs `bleCoreEnsureInit()` at boot — BLE is no longer + lazy. Scanner start failure logs the documented `Bluefruit.begin(1, 1)` + fallback note (spec §7.2.3) rather than touching the shared `begin(1, 0)`. +- **Sim**: `sensoregg.ino` is excluded from the sim TU like the other BLE + modules; `module_stubs.cpp` returns NaN/false so the page renders `---` + and rows log `nan`. + +### 15. On-device Course Creator (`course_creator.{h,cpp}`, `track_json.{h,cpp}`) + +- **What**: main menu → **Create** → walk the cones and capture the timing + lines. Sprint venues re-lay their course every event, so the device has + to be able to author one without a laptop (plan 0002 §5). Serves circuit + courses too — same lines, one fewer of them. +- **HARD RULE — no text entry on-device, ever.** Names are generated from + the GPS clock and renamed later in the webapp. A track file and its first + course are both `N{YYMMDD}_{HHMM}` (12 chars, so the 13-char track + browser shows it whole); a new track's `shortName` is `MMDDHHMM` — + exactly the webapp's 8-char budget and half of the `(kind, shortName)` + key its sync merge uses. Sprint courses also get the sortable + `date_created` stamp `sprint_select` compares. +- **Five screens**, all driven by `course_creator`'s row table (nothing + renders a local list, so a row can't display in one order and act in + another): track prompt (`Here` / `New Track`, skipped when nothing is in + 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). +- **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 + than 10 m h_acc are dropped; fixes after the window are ignored so a mean + already shown to the user can't shift. Feeding it needed a new monotonic + **`gpsPvtSequence`** — `gpsDataFresh` is consumed by `GPS_LOOP()` earlier + in the same iteration, and `gpsData` holds its last value between + updates, so an un-gated feed averaged one fix 250 times a second. +- **Line edits are scratch-then-commit**: opening a line copies it, `Save + line` commits, `Back` discards. Back is a real undo. +- **Two save rules exist to keep courses editable in the webapp**, and Save + is refused (with the reason on the row) until they hold: circuit sectors + are **all-or-nothing** (the app accepts zero or exactly three majors), and + sprint splits **fill in order** (the app re-exports them positionally, so + a lone sector 3 returns as a sector 2). A course the device writes and the + app then can't save is worse than one never written. +- **Writing** (`sdSaveCreatedCourse`, in `sd_functions.ino` with the other + track I/O): a new track is one emitted object; an append is a + read-modify-write through the existing 4 KB `trackJson` document, capped + at `MAX_LAYOUTS` and rejected on overflow. Appends serialize to + `.tmp` and **rename over the original only once closed** — in-place + rewriting would leave a truncated track file after a power loss in a + field, on a battery, at an event. `buildTrackList()` re-runs on success so + the new course is in the manifest for the next session. +- **Entry needs a fix and a time lock** (capture + filename), refused at the + menu rather than at Save. +- **Sim**: fully exercised — five golden fixtures walk the real menus, + inject real PVT, run a real averaging hold, and lock the rendered pixels. + +--- + +## Data Formats + +### DOVEX Log (`.dovex` files) — New UI default + +``` +datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode +lap1_ms,lap2_ms,lap3_ms,... +\n padding to byte 1024 +timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z,Temp1,Junction1,Temp2 +1710512400123,12,0.8,35.12345678,-97.12345678,65.32,234.56,... +``` + +- **Reserved header** (bytes 0–1023): Line 1 = session metadata, Line 2 = + all lap times (comma-separated ms values), padded with `\n` to 1024 bytes. +- **`device_name`** and **`race_mode`** are trailing metadata columns + (after `optimal_ms`, in that order). Appending keeps old logs + readable (parsed as empty) and lets older readers ignore the extra + columns — backwards compatible by design. `race_mode` is `CIRCUIT` / + `SPRINT` (empty = circuit): a webapp loading helper — with `SPRINT`, + the laps line is a runs line. Nothing on-device reads it back. +- **GPS data** (byte 1024+): CSV column header then streaming GPS rows. +- **`Temp1` / `Junction1` / `Temp2`** (trailing columns): SensorEgg EGT + + cold junction + v2 aux intake-air temp, all °C. Literal `nan` when the + 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. +- **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` +- 1 KB handles ~100 laps (8 chars per lap time). Extremely unlikely to exceed. + +### Track JSON (`/TRACKS/*.json`) + +**New format** (LapWingData / web simulator): +```json +{ + "longName": "Orlando Kart Center", + "shortName": "OKC", + "defaultCourse": "Normal", + "courses": [ + { + "name": "Normal", + "lengthFt": 3383, + "start_a_lat": 28.4127081705638, + ... + } + ] +} +``` + +**Older format** (bare array, still parsed): +```json +[ + { + "name": "Full Course", + "start_a_lat": 28.41270817, + ... + } +] +``` + +Auto-detected by JSON root type (object vs array). The older bare-array +form sets `lengthFt = 0` for all courses, which means CourseDetector +cannot rank by distance — CourseManager falls back to Lap Anything +immediately. + +Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). + +**Sprint track JSON** (`/TRACKS/SPRINT/*.json`) uses the same object +format plus `"type": "sprint"` (track level — redundant with the folder, +which is authoritative) and per-course `finish_a/b_lat/lng` (required for +timing; a course without a finish line can't run) and `date_created` (a +sortable ISO-8601 stamp, `YYYY-MM-DDTHH:MM`; the newest course is always +the one loaded). Sector lines stay optional — zero, one, or two. + +### Settings JSON (`/SETTINGS.json`) + +```json +{ + "bluetooth_name": "DovesDataLogger-042", + "bluetooth_pin": "7391", + "camera_serial": "", + "device_name": "ApexTurbo", + "race_mode": "circuit", + "spark_mode": "wasted", + "cylinder_count": "1", + "driver_name": "Driver", + "lap_detection_distance": "7", + "waypoint_detection_distance": "30", + "waypoint_speed": "30" +} +``` + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `bluetooth_name` | string | Random | BLE device name | +| `bluetooth_pin` | string | Random 4-digit | BLE pairing PIN | +| `camera_serial` | string | `""` (empty = unpaired) | Paired Insta360 X4's 6-char serial (auto-captured on pairing, or entered manually) | +| `device_name` | string | Random racing words | Identifies the logging device (DOVEX header) | +| `driver_name` | string | `"Driver"` | Logged in DOVEX header | +| `race_mode` | string | `"circuit"` | Tiebreak pref when BOTH a circuit and a sprint track are in range: `circuit` yields only to a sprint course created today; `sprint` always prefers the sprint track. Never overrides single-kind detection | +| `lap_detection_distance` | int | `7` | DovesLapTimer crossing threshold (meters) | +| `waypoint_detection_distance` | int | `30` | WaypointLapTimer proximity zone (meters) | +| `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` | +| `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 | + +- Created automatically on first boot with random BLE values. +- Missing keys auto-populated on boot via `ensureDefaultSettings()`. +- Editable on a computer or via BLE `SSET` command — changes take effect + on next reboot (BLE disconnect triggers auto-reboot). +- Read on-demand via `getSetting()`, written via `setSetting()`. + +--- + +## Key Constants + +| Constant | Value | Location | +|---|---|---| +| GPS baud | 57 600 | `gps_config.h` | +| GPS nav rate (race) | 25 Hz | `gps_config.h` | +| GPS nav rate (boot/status page) | 5 Hz + NAV-SAT ~1 Hz | `gps_config.h` | +| Status page auto-close | 3 s after fix+timeValid | `gps_status_page.h` | +| UTC resolve worst case | ~12.5 min cold start (nav-msg subframe 4 page 18) | `gps_status_page.h` | +| Status page idle shutdown | 5 min (no lock, no engine) | `gps_status_page.h` | +| SD format confirm hold | 3 s continuous Select | `sd_format_page.h` | +| SD format page idle shutdown | 5 min | `sd_format_page.h` | +| GPS boot re-detect | 3 tries, 10 s apart | `gps_functions.ino` | +| Menu idle shutdown | 5 min (`SLEEP_IDLE_TIMEOUT_MS`) | `project.h` | +| USB-on-menu charge idle | 60 s (`USB_MENU_CHARGE_IDLE_MS`) — compiled out unless `BIRDSEYE_ENABLE_ONBOARD_CHARGING` | `project.h` | +| Onboard charging (HICHG hold + USB charge UX) | `BIRDSEYE_ENABLE_ONBOARD_CHARGING`, default 0 (all channels) | `project.h` | +| SensorEgg wireless EGT POC | `BIRDSEYE_ENABLE_SENSOREGG`, default 0; 1 on the beta channel | `project.h` | +| Charging screen timeout | 10 s (`CHARGE_DISPLAY_TIMEOUT_MS`) | `project.h` | +| Sat bars display cap / CNO ceiling | 16 bars / 50 dB-Hz | `sat_bars.h` | +| Crossing threshold | 7.0 m | `BirdsEye.ino` | +| Max laps/session | 1 000 | `BirdsEye.ino` | +| Max locations | 200 | `project.h` | +| Max layouts/track | 10 | `project.h` | +| Max replay files | 20 | `replay.ino` | +| DOVEX header size | 1 024 bytes | `project.h` | +| Auto-idle timeout | 60 s at <2 mph | `BirdsEye.ino` | +| Auto-race menu grace | 3 s settled (arrival + buttons) before auto-race can fire | `project.h` | +| Track detect radius | 5 miles | `BirdsEye.ino` | +| Course creator point hold | 3 s, ≥8 usable fixes else FAILED | `course_creator.h` | +| Course creator h_acc gate | drop >10 m, warn >5 m | `course_creator.h` | +| Course creator name format | `N{YYMMDD}_{HHMM}` (+ `MMDDHHMM` short name) | `course_creator.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 ring buffer | 16 entries | `BirdsEye.ino` | +| Tach Kalman Q | 800 RPM² | `tach_filter.h` | +| Tach Kalman R_BASE | 2500 RPM² | `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` | +| Button debounce | 200 ms | `display_ui.ino` | +| SD SPI clock (normal) | 2 MHz | `BirdsEye.ino` | +| 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` | +| JSON buffer | 4096 (SIM builds too) | `sd_functions.ino` | +| Settings JSON buffer | 512 | `settings.ino` | +| Settings file path | `/SETTINGS.json` | `settings.ino` | +| Track upload buffer | 4096 | `bluetooth.ino` | +| GPS serial buffer | 4096 | `gps_functions.ino` | +| GPS serial timer | TIMER3, 5 ms (`GPS_DRAIN_INTERVAL_US`) | `gps_config.h` | +| Core Serial1 RX/TX rings | 256 B via required `-DSERIAL_BUFFER_SIZE=256` (asserted) | `project.h` + workflows | +| GPS drop-count slack / credit cap | 1 frame / 2 frames | `gps_stats.h` | +| OTA staging path | `/fw/pending.bin` | `firmware_ota.ino` | +| OTA receive buffer | 2 × 4096 (double-buffer) | `firmware_ota.ino` | +| OTA app base | `0x27000` | `firmware_ota.ino` | +| OTA staging flash base | `0x8E000` | `firmware_ota.ino` | +| OTA max image size | 408 KiB (half the 820 KiB app+staging span, page-aligned; `static_assert`ed) | `firmware_ota.ino` | +| OTA min apply voltage | 3.6 V | `firmware_ota.ino` | +| Camera record-start gate | RPM ≥ 1500 (`kRecordRpmThreshold`) held 5 s, strict — dips restart the clock (no GPS gate) | `camera_fsm.h` | +| Camera stop-record delay | 30 s engine-off (RPM only) → also ends log session | `camera_fsm.h` | +| Camera power-off | shutdown only (no post-record cooldown/timeout) | `camera_ble.ino` (`CAMERA_SLEEP`) | +| Camera RPM on/off thresholds | 500 / 300 (2 s on-debounce) | `camera_fsm.h` | +| Camera wake attempt window | 20 s ×3 (beacon) | `camera_fsm.h` | +| Camera connect / subscribe timeouts | 20 s connect / 10 s ce82 subscribe, 3 retries each | `camera_fsm.h` | +| Camera record-confirm re-assert | 2.5 s camera-idle before re-shutter | `camera_fsm.h` | +| Camera record-obs freshness | 3 s (stale 0x10 → kUnknown) | `camera_ble.ino` | +| Camera pairing timeout | 120 s | `camera_fsm.h` | +| SensorEgg staleness | 1000 ms (older → NaN/`---`) | `sensoregg_protocol.h` | +| SensorEgg scan interval / window | 90 ms / 40 ms (≈44% duty, test-capped ≤45%), passive | `sensoregg_protocol.h` | +| 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` | + +--- + +## Required Libraries + +| Library | Purpose | +|---|---| +| Adafruit GFX | Graphics primitives | +| Adafruit SSD1306 | SSD1306 OLED driver | +| Adafruit SH110X | SH110X OLED driver | +| SparkFun u-blox GNSS v3 | UBX binary PVT GPS interface | +| ArduinoJson 6.x | Track file JSON parsing | +| 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.2.0` (bump deliberately) | +| Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant, ±16g) | +| Bluefruit nRF52 | BLE (built into board package) | +| Adafruit TinyUSB | USB Mass Storage (`Adafruit_USBD_MSC`); built into board package | + +--- + +## EMI Mitigation + +This device operates in ignition-noise environments. Three layers of defense: + +1. **Hardware**: RC low-pass filters on buttons (10 K + 100 nF) and tach + (1 K + 100 nF + optional TVS diode). +2. **ISR design**: Volatile flag gating (never `noInterrupts()` in ISR); + 3 ms minimum pulse gap in tachometer. +3. **Software**: Multi-sample button reads (3x at 500 us), 200 ms refire + lockout, Kalman-filtered RPM (absorbs ISR jitter), 2 MHz SPI clock for + SD stability (raised to 8 MHz only during parked BLE/USB transfers, where + the motor is off and ignition EMI is absent — see subsystem 4). +4. **GPS serial buffer**: TIMER3 ISR drains Serial1 into a 4 KB RAM ring + buffer every 5 ms, preventing GPS data loss during SD card GC pauses + that can block writes for 100 ms–2 s; the core Serial1 ring (256 B via + the required `-DSERIAL_BUFFER_SIZE=256` flag) covers SoftDevice + radio-ISR deferral of the drain itself. + +--- + +## Build Notes + +- **Board**: Seeed XIAO nRF52840 Sense (Arduino IDE). The firmware also + builds and runs on the plain (non-Sense) Seeed XIAO nRF52840 — same MCU, + BLE, bootloader and pin map; it just lacks the onboard LSM6DS3 IMU, so + accelerometer logging degrades gracefully (`accelAvailable = false`). CI + (`compile-sketch`) and the `release` workflow build a matrix of both + variants (FQBNs `xiaonRF52840Sense` and `xiaonRF52840`), publishing + per-board `BirdsEye-sense.*` / `BirdsEye-nonsense.*` assets. The `.zip` + in each is the Secure DFU package used for OTA. Each build passes + `-DBIRDSEYE_BOARD_SENSE` / `-DBIRDSEYE_BOARD_NONSENSE` (via + `compiler.cpp.extra_flags`) so the image self-reports its variant over + BLE; a plain IDE build with no flag defaults to `sense`. +- **Firmware version** is a single `#define FIRMWARE_VERSION` in `project.h`. + Keep it in sync with the release git tag (`v2.0.0` -> `"2.0.0"`); it is + reported over BLE (DIS) for the OTA update check. `FIRMWARE_VARIANT` + (also in `project.h`) feeds the DIS model string. The version literal can be + overridden at build time with `-DFIRMWARE_VERSION_OVERRIDE=` (a bare + token; `project.h` stringizes it) — the `beta` workflow uses this to stamp + nightly builds as `-beta.`. Normal builds leave it undefined. +- **Required build flag `-DSERIAL_BUFFER_SIZE=256`** — grows the core's + Serial1 rings so radio-ISR deferral of the GPS drain can't drop bytes + (see subsystem 1). `project.h` static_asserts it on non-SIM builds; CI + passes it in all three workflows (merged into the SAME + `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): + - `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. + - `BIRDSEYE_ENABLE_SENSOREGG` — off in master/release, **on in beta** + (`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. + 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. +- The sketch lives in `BirdsEye/` so the folder name matches the + `.ino` file — required by Arduino IDE / arduino-cli. +- `project.h` is included before other `.ino` modules so Arduino's + auto-prototype generator sees custom types first. +- PROGMEM is used for bitmap images to save RAM. +- Avoid Arduino `String` in hot paths (heap fragmentation risk on 256 KB). +- SD chip-select is hardwired to GND; pass `-1` to SdFat. +- `#define SIM` enables simulator-specific tweaks (no WDT, fixed battery + voltage, placeholder GPS setup, sim button pins). Never defined in CI + firmware builds — it's the compile flag for the browser/WASM simulator + build (sources under `BirdsEye/sim/`; replaces the old Wokwi target). +- `#define ENDURANCE_MODE` hides the tachometer page and reshuffles + page numbers — for endurance racing where RPM isn't relevant. +- **TIMER3 is reserved** for the GPS serial buffer ISR. Use TIMER4 if another + hardware timer is needed. TIMER0 is reserved by SoftDevice; TIMER1/2 may + be used by PWM/tone. +- **CRITICAL: NEVER use `analogRead()` on any GPIO pin.** On the nRF52840, + `analogRead()` permanently disables the digital input buffer on the target + pin for the remainder of the session. Every analog-capable pin on the XIAO + is also a critical digital function: A0=tach ISR, A1-A3=buttons, A4=SDA, + A5=SCL. Use `micros()` or the hardware RNG for entropy instead. + +--- + +## Development Conventions + +- `.ino` files act as modules; Arduino IDE concatenates them alphabetically + after the main sketch file. +- Each module has a matching `.h` declaring its public surface. The `.ino` + includes its own header as the first include so any drift between + declaration and definition is caught at compile time. +- Each subsystem exposes `*_SETUP()` and `*_LOOP()` entry points called + from `BirdsEye.ino`. +- SD access must go through `acquireSDAccess()` / `releaseSDAccess()`. +- GPS data validation (9 checks) must pass before any CSV row is written. +- Display pages are rendered by `displayPage_*()` functions routed via + `currentPage` in `displayLoop()`. +- Cross-module globals (e.g. `dovexReplay*`, `trackManifest[]`, `courseManager`) + are declared and defined in `BirdsEye.ino`. Module headers may `extern`-declare + them where the module's own API touches that state. +- Library includes that define return types used in auto-prototyped functions + (`DovesLapTimer.h`, `CourseManager.h`, `SparkFun_u-blox_GNSS_v3.h`) must be + in the top include block of `BirdsEye.ino` (before Arduino generates + prototypes). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d140c2..452a084 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,13 @@ the flag automatically. update). - **arduino-cli**: add `--build-property "compiler.cpp.extra_flags=-DSERIAL_BUFFER_SIZE=256"` + +**Optional — `-DDOVES_DISABLE_DEBUG`** (CI/production builds pass it): +compiles out the DovesLapTimer library's debug strings and print +call-sites (~6-8 KB of flash that a hardware build never uses — no debug +Stream is attached). Local debug builds should OMIT it if you want the +library's serial debug output; the firmware's own `HAS_DEBUG` is +unaffected either way. The OTA size gate in CI assumes it is set. (merge it into the same property if you're already passing variant defines — a second `--build-property` for the same key replaces the first). diff --git a/README.md b/README.md index a787871..f9fe15d 100644 --- a/README.md +++ b/README.md @@ -1,488 +1,495 @@ -# BirdsEye - GPS Lap Timer & Data Logger - -[![compile-sketch](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/compile-sketch.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/compile-sketch.yml) -[![arduino-lint](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/arduino-lint.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/arduino-lint.yml) -[![unit-tests](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/unit-tests.yml) -[![clang-tidy](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/clang-tidy.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/clang-tidy.yml) -[![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/TheAngryRaven/DovesDataLogger/badges/coverage-badge.json)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/coverage.yml) - -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. - -

- -

-

- -

- -## Features - -### Core Functionality -- **25Hz GPS Logging** - High-frequency data capture straight to SD card -- **Accelerometer** - On-board 6-axis IMU when using Seeed XIAO nRF52840 Sense, +/-16g -- **RPM Monitoring** - Tachometer input with noise filtering for ignition systems -- **"Just Drive" Mode** - Automatic track detection, course detection, and lap timing — no manual selection needed -- **Sector Timing** - Optional 2 and 3-sector support for detailed performance analysis -- **Lap Anything** - Automatic waypoint-based lap timing when no track files match or no sectors configured -- **Lap Timing** - Current lap, best lap, last lap, and optimal lap calculation -- **Pace Comparison** - Real-time pace difference vs. best lap -- **Lap History** - Session-based lap history (up to 1000 laps) -- **Speed Display** - Large, easy-to-read speed display -- **Auto Power-Off** - Full shutdown (nRF52 System OFF, ~µA) instead of sleep — wakes on button press, engine start (tach pulse), or USB plug-in; no power switch needed -- **GPS Status Page** - Every boot shows a MyChron-style satellite view (sat count, HDOP, per-satellite signal bars) until GPS locks; any button skips it -- **DOVEX Format** - Crash-safe logging with reserved header for instant replay -- **Review Data** - Instant replay of DOVEX session headers on-device -- **Insta360 cameras pairing** - Automatically turn on, start recording, and sync GPS data directly to your camera once a session starts - -#### WebApp Features (no login) -- **Bluetooth Downloads** - Can now download files directly to [LapWingData.com](https://LapWingData.com) -- **Bluetooth Firmware updates** - update from the latest stable or the experimental beta branch releases -- **Configure settings** - none of us want to fill in text with three buttons -- **Track Sync** - Update on-device track library via the webapp - -#### To-Do -- **External Sensors** - Add thermocouple sensor / m8 circle connector -- **Pin Lock** - require pin to pull logs from device - -### Display Pages -- GPS Status (boot page: satellites, HDOP, lock state, per-satellite signal bars) -- GPS Statistics (battery, satellites, HDOP, logging status) -- Speed (with current lap number) -- Tachometer (RPM with max RPM tracking) -- Current Lap Time -- Pace Off Best Lap (visual indicators when beating best) -- Best Lap Time -- Optimal Lap (combined best sectors with lap numbers) -- Lap History (paginated list) -- GPS Debug (distance to line, crossing status, etc.) - -### Track Configuration -- Loads track layouts from JSON files on SD card -- Support for forward/reverse direction selection -- Optional sector timing lines (2 and 3 sectors) -- Easy to add new tracks - just add a JSON file - -## Hardware Requirements - -### Core Components -- **MCU**: Seeed XIAO nRF52840 Sense (64MHz ARM Cortex-M4 with FPU + BLE 5.0 + 6-axis IMU) - - ~120 mA active draw with 2.45" screen; System OFF when powered down (µA-class + GPS backup draw) - - Built-in battery charging circuit (BQ25101) -- **GPS**: u-blox SAM-M10Q (Matek SAM-M10Q recommended, found from most RC hobby shops) - - Configured for 25Hz UBX binary (PVT) update rate - - GPS-only constellation for maximum nav rate - - **TODO** Update GPS tray to use bare $15 GPS module, battery backup is optional -- **Display**: 2.45" 128x64 OLED (SH110X or SSD1306 compatible) - - I2C interface (address: 0x3C) - - Software sleep/wake via I2C commands (~10uA when off) -- **SD Card**: Standard SD card module - - FAT16/FAT32 formatted - - 1 MHz SPI for EMI resistance -- **Battery**: 1500mAh 103050 LiPo - - ~12.5 hours active; weeks-to-months powered down (System OFF) -- **Buttons**: 3x momentary pushbuttons for navigation - - Left, Select/Enter, Right - - RC low-pass filters recommended (10K + 100nF) for EMI rejection - -### Optional Components -- **Tachometer Circuit**: Inductive pickup for RPM sensing - - Input on pin D0 - - Noise filtering for ignition systems - - *Circuit diagram: `tachometer-circuit.jpg` (README coming soon)* - - diagram is missing required optocoupler and isolated dc-dc power supply - -### Power Usage -- Active draw: ~120 mA with 2.45" OLED display -- Active battery life: ~12.5 hours continuous on 1500 mAh -- Powered down: nRF52 System OFF (µA-class MCU draw; GPS backup mode + board - quiescent draw dominate) — expected weeks-to-months on 1500 mAh (bench - measurement pending) -- Battery percentage and voltage displayed on stats page and charging screen - -## Track Loading System - -### How It Works - -The system loads track configurations from JSON files stored on the SD card. Each track file can contain multiple layouts (e.g., "Full Course", "Short Course", "Chicane Bypass"). - -### SD Card Structure - -``` -SDCARD/ -└── TRACKS/ - ├── OKC.json - ├── BMP.json - ├── PIQUET.json - └── [your-track].json -``` - -### Track JSON Format - -Two formats are supported. The device auto-detects which format is used. - -**New format** (recommended, can use [LapWingData.com](https://LapWingData.com) to update tracks on the device): - -```json -{ - "longName": "Orlando Kart Center", - "shortName": "OKC", - "defaultCourse": "Normal", - "courses": [ - { - "name": "Normal", - "lengthFt": 3383, - "start_a_lat": 28.4127081705638, - "start_a_lng": -81.3797326641803, - "start_b_lat": 28.4127303867932, - "start_b_lng": -81.3795704875378, - "sector_2_a_lat": 28.4119049886871, - "sector_2_a_lng": -81.3790708193926, - "sector_2_b_lat": 28.4118316342961, - "sector_2_b_lng": -81.3791856652217, - "sector_3_a_lat": 28.4115010664104, - "sector_3_a_lng": -81.3799856475317, - "sector_3_b_lat": 28.4115084390461, - "sector_3_b_lng": -81.3798064021136 - } - ] -} -``` - -**Older format** (still parsed, but the device falls back to Lap Anything because there's no `lengthFt` for course detection to rank by): - -```json -[ - { - "name": "Full Course", - "start_a_lat": 28.41270817, - "start_a_lng": -81.37973266, - "start_b_lat": 28.41273039, - "start_b_lng": -81.37957049, - "sector_2_a_lat": 28.41190499, - "sector_2_a_lng": -81.37907082, - "sector_2_b_lat": 28.41183163, - "sector_2_b_lng": -81.37918567 - } -] -``` - -**New format fields:** -- `longName` / `shortName`: track display names -- `defaultCourse`: which course to prefer (used by CourseDetector) -- `courses[].lengthFt`: track length in feet (enables automatic course detection ranking) - -**Coordinate Requirements:** -- **Start/Finish Line**: `start_a_lat`, `start_a_lng`, `start_b_lat`, `start_b_lng` (required) -- **Sector 2 Line**: `sector_2_a_lat`, `sector_2_a_lng`, `sector_2_b_lat`, `sector_2_b_lng` (optional) -- **Sector 3 Line**: `sector_3_a_lat`, `sector_3_a_lng`, `sector_3_b_lat`, `sector_3_b_lng` (optional) - -Each line is defined by two GPS coordinate points (A and B). The system detects crossing when you pass through the line segment between these points. - -**Getting Coordinates:** -Use [Google Maps](https://maps.google.com) or your preferred mapping tool: -1. Right-click on a point → Copy coordinates -2. Paste into JSON (format: latitude, longitude) -3. Precision: 8 decimal places recommended for racing accuracy - -### Adding a New Track - -1. Create a new `.json` file in `SDCARD/TRACKS/` directory -2. Use the format shown above -3. Filename should be short (max 8 characters for FAT16 compatibility) -4. Example: `LAGUNA.json`, `COTA.json`, `BRANDS.json` - -The track will automatically appear in the track selection menu on next boot. - -## Device Settings - -Settings are stored in `/SETTINGS.json` on the SD card. The file is created automatically on first boot with random default values. Missing keys are auto-populated on boot when firmware is updated. - -```json -{ - "bluetooth_name": "DovesDataLogger-042", - "bluetooth_pin": "7391", - "driver_name": "Driver", - "lap_detection_distance": "7", - "waypoint_detection_distance": "30", - "waypoint_speed": "30" -} -``` - -| Setting | Description | Default | -|---|---|---| -| `bluetooth_name` | BLE device name visible during pairing | Random (e.g. `DovesDataLogger-042`) | -| `bluetooth_pin` | PIN displayed on device for webapp pairing | Random 4-digit | -| `driver_name` | Driver name logged in DOVEX session header | `Driver` | -| `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` | - -## Data Format - -DOVEX files (`.dovex`) use a reserved **1 KB** header for session metadata, with GPS data streaming after byte 1024. This enables crash-safe logging and instant on-device replay. - -**Structure:** -``` -Bytes 0-1023: Session header (written when session ends) - Line 1: datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name (column labels) - Line 2: 2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,ApexTurbo (session metadata) - Line 3: laps_ms (column label) - Line 4: 65432,63210,62345,64567,... (all lap times in ms) - Remaining: \n padding to byte 1024 - -Bytes 1024+: GPS data - Header row: timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z - Data rows: 1741128001234,12,0.8,28.41270817,-81.37973266,87.32,125.45,182.34,1.25,8450,0.123,-0.945,0.032 -``` - -The 1 KB header fits about 100 lap times at ~8 characters per entry. If the device loses power mid-session the header is left blank but all GPS data after byte 1024 is still valid and recoverable — the metadata write is the LAST thing a clean session does, so file integrity is independent of it. - -**File naming:** `20YYMMDD_HHMM.dovex` (e.g. `20240115_1430.dovex`) - -### Column Reference - -- **timestamp**: Unix timestamp in milliseconds (since Jan 1, 1970) -- **sats**: Number of GPS satellites -- **hdop**: Horizontal Dilution of Precision (GPS accuracy indicator) -- **lat/lng**: GPS coordinates (8 decimal places) -- **speed_mph**: Speed in miles per hour (2 decimal places) -- **altitude_m**: Altitude in meters (2 decimal places) -- **heading_deg**: Heading of motion in degrees (0-360, 2 decimal places, from UBX headMot) -- **h_acc_m**: Horizontal accuracy estimate in meters (2 decimal places, from UBX hAcc) -- **rpm**: Engine RPM from tachometer input -- **accel_x/y/z**: Accelerometer g-force from onboard LSM6DS3 IMU (3 decimal places, logs `0.000` if IMU not available) - -## File Structure - -``` -DovesDataLogger/ # repo root -├── BirdsEye/ # sketch (folder name must match BirdsEye.ino) -│ ├── BirdsEye.ino # Entry point: globals, setup(), loop(), state machine -│ ├── project.h # Shared types, debug macros, constants -│ ├── display_config.h # Display driver abstraction (SH110X/SSD1306) -│ ├── gps_config.h # GPS configuration constants (baud, nav rate) -│ ├── images.h # PROGMEM bitmap data (splash, animations) -│ ├── accelerometer.{h,ino} # LSM6DS3 IMU init and g-force reads -│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync) -│ ├── display_pages.{h,ino} # All page rendering functions (displayPage_*()) -│ ├── display_ui.{h,ino} # Display init, button handling, menu navigation -│ ├── gps_functions.{h,ino} # GPS init, PVT callback, time conversion, logging -│ ├── replay.{h,ino} # Instant DOVEX header replay + haversine helper -│ ├── sd_functions.{h,ino} # SD init, track JSON parsing, track manifest -│ ├── settings.{h,ino} # Persistent JSON settings (/SETTINGS.json) -│ └── tachometer.{h,ino} # Falling-edge ISR, Kalman-filtered RPM -│ -├── .github/workflows/ # CI: compile-sketch + arduino-lint -├── CASE/ # 3D printable enclosure files (STL + STEP) -├── SDCARD/TRACKS/ # Example track JSON files -├── TACHOMETER/ # Tachometer circuit documentation -├── README.md # This file -├── CLAUDE.md # AI-assistant project guide -└── LICENSE # GPL v3 -``` - -## Required Libraries - -| Library | Purpose | -|---|---| -| Adafruit GFX | Graphics primitives | -| Adafruit SSD1306 | SSD1306 OLED driver (if using SSD1306) | -| Adafruit SH110X | SH110X OLED driver (if using SH1106) | -| SparkFun u-blox GNSS v3 | UBX binary PVT GPS interface | -| ArduinoJson 6.x | Track file and settings JSON parsing | -| SdFat | SD card (FAT16/FAT32) | -| [DovesLapTimer](https://github.com/TheAngryRaven/DovesLapTimer) | Lap/sector timing | -| [CourseManager](https://github.com/TheAngryRaven/DovesLapTimer) | Auto course detection + Lap Anything (part of DovesLapTimer) | -| Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant) | -| Bluefruit nRF52 | BLE (built into Seeed nRF52 board package) | - -**Board package:** Use "Seeed nRF52 Boards" (non-mbed). The mbed variant uses ArduinoBLE instead of Bluefruit and is incompatible. - -## Setup & Usage - -### Initial Setup - -1. **Format SD Card** - - Format as FAT32 (or FAT16 for maximum compatibility) - - Create folder structure: `TRACKS/` in root - -2. **Add Track Files** - - Copy or create `.json` track files in `SDCARD/TRACKS/` - - See "Track Loading System" section above - -3. **Flash Firmware** - - **Option A — prebuilt release (no toolchain needed):** - - Download the latest `BirdsEye.uf2` from the [Releases page](https://github.com/TheAngryRaven/DovesDataLogger/releases) - - Double-tap the XIAO's reset button to mount the `XIAO-SENSE` bootloader drive - - Drag `BirdsEye.uf2` onto that drive — the board reboots into the new firmware automatically - - **Option B — build from source:** - - Install "Seeed nRF52 Boards" board package (non-mbed) - - Install required libraries (see table above) - - Select board: "Seeed XIAO nRF52840 Sense" - - Compile and upload `BirdsEye/BirdsEye.ino` - -4. **Hardware Assembly** - - *3D printed case assembly instructions coming soon* - - *Tachometer circuit README coming soon* - - STL files available in `CASE/` directory - - Note: Requires some glue, measuring, and jeweler's screws - -### Usage - -**Button Controls:** -- **Left/Right Buttons**: Navigate between pages -- **Middle Button**: Select/Enter (in menus), Quick-jump to Pace/Best Lap (while racing) -- **Hold Left + Right (5s)**: Power off (from main menu) -- **Hold Select + Side (5s)**: Reboot device (from any page) - -**Startup Sequence ("Just Drive" mode):** -1. Wake (button / engine start / USB) → Boot screen → **GPS Status page** - (satellite count, HDOP, signal bars). Any button skips to the main menu; - 3 seconds after a stable GPS time lock it advances automatically — - straight into race mode if the engine woke the device or is running -2. Start driving — device auto-enters race mode at 10+ mph or 500+ RPM -3. GPS fix acquired → DOVEX logging starts immediately -4. Track auto-detected via GPS proximity → course detection begins -5. Lap timing activates automatically (sector timing if configured, "Lap Anything" otherwise) - -**Ending Session:** -- **Auto-idle**: stops automatically after 60 seconds below 2 mph -- **Manual**: navigate to "END RACE" page (only accessible when speed < 2 mph), confirm to stop -- Returns to main menu - -**Power Off (System OFF):** -- Activates via 5-second left+right hold on the main menu, or automatically - after 5 minutes idle on the main menu (or 5 minutes on the GPS status page - with no lock and no engine) -- Everything powers down — display, GPS (µA backup mode, config/ephemeris - retained), IMU, and the MCU itself (nRF52 System OFF) — no power switch needed -- Wakes on any button press, a tachometer pulse (engine start boots straight - toward race mode via the GPS status page), or plugging in USB -- Waking is a fresh boot (~1-2 s); the GPS warm-starts from backup RAM in - seconds -- **Charging**: plugging in USB wakes the device into a charging screen - (10 s, then dark); any button fully wakes it — replay/transfer work while - plugged in, and it returns to charging after 60 s of no buttons. Unplugging - powers it off - -## Technical Details - -### GPS Configuration -- **Protocol**: UBX binary (PVT messages via SparkFun callback) -- **Update Rate**: 25Hz racing (40ms between fixes); 5Hz + NAV-SAT during the boot GPS status page -- **Mode**: Automotive dynamic model -- **Constellations**: GPS-only (max nav rate, no multi-constellation overhead) -- **Baud Rate**: 57600 (auto-configured from 9600 default on first boot; boot - probes 57600 first and sends the backup-mode wake byte, so warm boots - reconnect near-instantly and a misbehaving module is auto-recovered) - -### SD Card Logging -- **Write Frequency**: Every PVT update (~25Hz) -- **Flush Interval**: Every 10 seconds (prevents data loss) -- **SPI Speed**: 1 MHz (reduced for EMI resistance) -- **Access Arbitration**: Mutex prevents concurrent access from logging, replay, BLE, and track parsing - -### Tachometer Input -- **Input Pin**: D0 -- **Detection**: Falling-edge interrupt -- **Filtering**: 3ms minimum pulse gap (supports up to ~20,000 RPM) -- **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) - -### Display Update Rate -- **Refresh**: 3Hz (reduces power consumption) -- **Exception**: Instant refresh on button press - -### Battery Monitoring -- **Update Interval**: 5 seconds -- **Voltage Divider**: 1510Ω / 510Ω ratio -- **Display**: Percentage (3.3V = 0%, 4.2V = 100%) with voltage readout - -## Adding Custom Data Pages - -The page system is straightforward to extend. Each page needs: - -1. **Page Constant** in `BirdsEye.ino` (in the running page section): - ```cpp - const int MY_NEW_PAGE = 13; - ``` - -2. **Display Function** in `display_pages.ino`: - ```cpp - void displayPage_my_new_page() { - resetDisplay(); - display.println(F("My Custom Page")); - // Your display code here - safeDisplayUpdate(); - } - ``` - -3. **Page Routing** in `displayLoop()` in `display_ui.ino`: - ```cpp - else if (currentPage == MY_NEW_PAGE) { - displayPage_my_new_page(); - } - ``` - -4. **Update Page Range** in `BirdsEye.ino`: - ```cpp - int runningPageEnd = MY_NEW_PAGE; // Update to new last page - ``` - -Look at existing pages like `displayPage_gps_speed()` or `displayPage_tachometer()` in `display_pages.ino` as examples. - -## Related Projects - -- **Data Viewer**: [DovesDataViewer](https://github.com/TheAngryRaven/DovesDataViewer) - - Web-based viewer for logged data - - Preview at [LapWingData.com](https://LapWingData.com) - -- **Core GPS Timing Library**: [DovesLapTimer](https://github.com/TheAngryRaven/DovesLapTimer) - - Line-crossing detection - - Sector timing - - Optimal lap calculation - -## Future Enhancements - -- **Browser-based simulator (in progress)**: the old Wokwi simulator files - (`diagram.json`, `libraries.txt`) have been removed — they described a dead - Arduino-Mega/SSD1306 target. They're being replaced by a WASM build of the - real firmware (behind the `SIM` compile flag, sources under `BirdsEye/sim/`) - that runs in the browser and replays real `.dovex` sessions, embedded in - [DovesDataViewer](https://github.com/TheAngryRaven/DovesDataViewer) -- Pin lock - require PIN to pull logs from device -- Additional sensor inputs (exhaust temp, water temp) -- WiFi automatic data transfer -- Real-time telemetry to pit crew - -## Notes - -- Crossing detection threshold: configurable via `lap_detection_distance` setting (default 7m) -- Maximum track locations: 1000 -- Maximum layouts per track: 10 -- Maximum lap history: 1000 laps per session -- GPS coordinates stored with 8 decimal places (~1.1mm precision) -- BLE disconnect triggers automatic device reboot (ensures settings changes take effect) - -## Contributing - -Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how -to build the firmware, run the host tests, and the PR workflow. -[ARCHITECTURE.md](ARCHITECTURE.md) explains how the system fits together. -Found a security issue? Please follow [SECURITY.md](SECURITY.md) and report -it privately. All participants are expected to follow our -[Code of Conduct](CODE_OF_CONDUCT.md). - -## License - -GPL v3 — see [LICENSE](LICENSE) for details. - -## Support - -For issues and questions, please use [GitHub Issues](https://github.com/TheAngryRaven/DovesDataLogger/issues) -(search existing ones first). For security reports, see [SECURITY.md](SECURITY.md). - +# BirdsEye - GPS Lap Timer & Data Logger + +[![compile-sketch](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/compile-sketch.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/compile-sketch.yml) +[![arduino-lint](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/arduino-lint.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/arduino-lint.yml) +[![unit-tests](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/unit-tests.yml) +[![clang-tidy](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/clang-tidy.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/clang-tidy.yml) +[![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/TheAngryRaven/DovesDataLogger/badges/coverage-badge.json)](https://github.com/TheAngryRaven/DovesDataLogger/actions/workflows/coverage.yml) + +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. + +

+ +

+

+ +

+ +## Features + +### Core Functionality +- **25Hz GPS Logging** - High-frequency data capture straight to SD card +- **Accelerometer** - On-board 6-axis IMU when using Seeed XIAO nRF52840 Sense, +/-16g +- **RPM Monitoring** - Tachometer input with noise filtering for ignition systems +- **"Just Drive" Mode** - Automatic track detection, course detection, and lap timing — no manual selection needed +- **Sector Timing** - Optional 2 and 3-sector support for detailed performance analysis +- **Lap Anything** - Automatic waypoint-based lap timing when no track files match or no sectors configured +- **Lap Timing** - Current lap, best lap, last lap, and optimal lap calculation +- **Pace Comparison** - Real-time pace difference vs. best lap +- **Lap History** - Session-based lap history (up to 1000 laps) +- **Speed Display** - Large, easy-to-read speed display +- **Auto Power-Off** - Full shutdown (nRF52 System OFF, ~µA) instead of sleep — wakes on button press, engine start (tach pulse), or USB plug-in; no power switch needed +- **GPS Status Page** - Every boot shows a MyChron-style satellite view (sat count, HDOP, per-satellite signal bars) until GPS locks; any button skips it +- **DOVEX Format** - Crash-safe logging with reserved header for instant replay +- **Review Data** - Instant replay of DOVEX session headers on-device +- **Insta360 cameras pairing** - Automatically turn on, start recording, and sync GPS data directly to your camera once a session starts + +#### WebApp Features (no login) +- **Bluetooth Downloads** - Can now download files directly to [LapWingData.com](https://LapWingData.com) +- **Bluetooth Firmware updates** - update from the latest stable or the experimental beta branch releases +- **Configure settings** - none of us want to fill in text with three buttons +- **Track Sync** - Update on-device track library via the webapp + +#### To-Do +- **External Sensors** - Add thermocouple sensor / m8 circle connector +- **Pin Lock** - require pin to pull logs from device + +### Display Pages +- GPS Status (boot page: satellites, HDOP, lock state, per-satellite signal bars) +- GPS Statistics (battery, satellites, HDOP, logging status) +- Speed (with current lap number) +- Tachometer (RPM with max RPM tracking) +- Current Lap Time +- Pace Off Best Lap (visual indicators when beating best) +- Best Lap Time +- Optimal Lap (combined best sectors with lap numbers) +- Lap History (paginated list) +- GPS Debug (distance to line, crossing status, etc.) + +### Track Configuration +- Loads track layouts from JSON files on SD card +- Support for forward/reverse direction selection +- Optional sector timing lines (2 and 3 sectors) +- Easy to add new tracks - just add a JSON file + +## Hardware Requirements + +### Core Components +- **MCU**: Seeed XIAO nRF52840 Sense (64MHz ARM Cortex-M4 with FPU + BLE 5.0 + 6-axis IMU) + - ~120 mA active draw with 2.45" screen; System OFF when powered down (µA-class + GPS backup draw) + - Built-in battery charging circuit (BQ25101) +- **GPS**: u-blox SAM-M10Q (Matek SAM-M10Q recommended, found from most RC hobby shops) + - Configured for 25Hz UBX binary (PVT) update rate + - GPS-only constellation for maximum nav rate + - **TODO** Update GPS tray to use bare $15 GPS module, battery backup is optional +- **Display**: 2.45" 128x64 OLED (SH110X or SSD1306 compatible) + - I2C interface (address: 0x3C) + - Software sleep/wake via I2C commands (~10uA when off) +- **SD Card**: Standard SD card module + - FAT16/FAT32 formatted + - 1 MHz SPI for EMI resistance +- **Battery**: 1500mAh 103050 LiPo + - ~12.5 hours active; weeks-to-months powered down (System OFF) +- **Buttons**: 3x momentary pushbuttons for navigation + - Left, Select/Enter, Right + - RC low-pass filters recommended (10K + 100nF) for EMI rejection + +### Optional Components +- **Tachometer Circuit**: Inductive pickup for RPM sensing + - Input on pin D0 + - Noise filtering for ignition systems + - *Circuit diagram: `tachometer-circuit.jpg` (README coming soon)* + - diagram is missing required optocoupler and isolated dc-dc power supply + +### Power Usage +- Active draw: ~120 mA with 2.45" OLED display +- Active battery life: ~12.5 hours continuous on 1500 mAh +- Powered down: nRF52 System OFF (µA-class MCU draw; GPS backup mode + board + quiescent draw dominate) — expected weeks-to-months on 1500 mAh (bench + measurement pending) +- Battery percentage and voltage displayed on stats page and charging screen + +## Track Loading System + +### How It Works + +The system loads track configurations from JSON files stored on the SD card. Each track file can contain multiple layouts (e.g., "Full Course", "Short Course", "Chicane Bypass"). + +### SD Card Structure + +``` +SDCARD/ +└── TRACKS/ + ├── OKC.json + ├── BMP.json + ├── PIQUET.json + └── [your-track].json +``` + +### Track JSON Format + +Two formats are supported. The device auto-detects which format is used. + +**New format** (recommended, can use [LapWingData.com](https://LapWingData.com) to update tracks on the device): + +```json +{ + "longName": "Orlando Kart Center", + "shortName": "OKC", + "defaultCourse": "Normal", + "courses": [ + { + "name": "Normal", + "lengthFt": 3383, + "start_a_lat": 28.4127081705638, + "start_a_lng": -81.3797326641803, + "start_b_lat": 28.4127303867932, + "start_b_lng": -81.3795704875378, + "sector_2_a_lat": 28.4119049886871, + "sector_2_a_lng": -81.3790708193926, + "sector_2_b_lat": 28.4118316342961, + "sector_2_b_lng": -81.3791856652217, + "sector_3_a_lat": 28.4115010664104, + "sector_3_a_lng": -81.3799856475317, + "sector_3_b_lat": 28.4115084390461, + "sector_3_b_lng": -81.3798064021136 + } + ] +} +``` + +**Older format** (still parsed, but the device falls back to Lap Anything because there's no `lengthFt` for course detection to rank by): + +```json +[ + { + "name": "Full Course", + "start_a_lat": 28.41270817, + "start_a_lng": -81.37973266, + "start_b_lat": 28.41273039, + "start_b_lng": -81.37957049, + "sector_2_a_lat": 28.41190499, + "sector_2_a_lng": -81.37907082, + "sector_2_b_lat": 28.41183163, + "sector_2_b_lng": -81.37918567 + } +] +``` + +**New format fields:** +- `longName` / `shortName`: track display names +- `defaultCourse`: which course to prefer (used by CourseDetector) +- `courses[].lengthFt`: track length in feet (enables automatic course detection ranking) + +**Coordinate Requirements:** +- **Start/Finish Line**: `start_a_lat`, `start_a_lng`, `start_b_lat`, `start_b_lng` (required) +- **Sector 2 Line**: `sector_2_a_lat`, `sector_2_a_lng`, `sector_2_b_lat`, `sector_2_b_lng` (optional) +- **Sector 3 Line**: `sector_3_a_lat`, `sector_3_a_lng`, `sector_3_b_lat`, `sector_3_b_lng` (optional) + +Each line is defined by two GPS coordinate points (A and B). The system detects crossing when you pass through the line segment between these points. + +**Getting Coordinates:** +Use [Google Maps](https://maps.google.com) or your preferred mapping tool: +1. Right-click on a point → Copy coordinates +2. Paste into JSON (format: latitude, longitude) +3. Precision: 8 decimal places recommended for racing accuracy + +### Adding a New Track + +1. Create a new `.json` file in `SDCARD/TRACKS/` directory +2. Use the format shown above +3. Filename should be short (max 8 characters for FAT16 compatibility) +4. Example: `LAGUNA.json`, `COTA.json`, `BRANDS.json` + +The track will automatically appear in the track selection menu on next boot. + +## Device Settings + +Settings are stored in `/SETTINGS.json` on the SD card. The file is created automatically on first boot with random default values. Missing keys are auto-populated on boot when firmware is updated. + +```json +{ + "bluetooth_name": "DovesDataLogger-042", + "bluetooth_pin": "7391", + "driver_name": "Driver", + "lap_detection_distance": "7", + "waypoint_detection_distance": "30", + "waypoint_speed": "30", + "spark_mode": "wasted", + "cylinder_count": "1" +} +``` + +| Setting | Description | Default | +|---|---|---| +| `bluetooth_name` | BLE device name visible during pairing | Random (e.g. `DovesDataLogger-042`) | +| `bluetooth_pin` | PIN displayed on device for webapp pairing | Random 4-digit | +| `race_mode` | Circuit-vs-sprint tiebreak when both track kinds are within detection range (`sprint` always prefers the sprint track; `circuit` yields only on an event day) | `circuit` | +| `driver_name` | Driver name logged in DOVEX session header | `Driver` | +| `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` | +| `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` | + +## Data Format + +DOVEX files (`.dovex`) use a reserved **1 KB** header for session metadata, with GPS data streaming after byte 1024. This enables crash-safe logging and instant on-device replay. + +**Structure:** +``` +Bytes 0-1023: Session header (written when session ends) + Line 1: datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode + Line 2: 2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,ApexTurbo,CIRCUIT + Line 3: laps_ms (column label) + Line 4: 65432,63210,62345,64567,... (all lap times in ms) + Remaining: \n padding to byte 1024 + +Bytes 1024+: GPS data + Header row: timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x,accel_y,accel_z,Temp1,Junction1,Temp2 + Data rows: 1741128001234,12,0.8,28.41270817,-81.37973266,87.32,125.45,182.34,1.25,8450,0.123,-0.945,0.032,650.0,33.3,23.4 +``` + +The 1 KB header fits about 100 lap times at ~8 characters per entry. If the device loses power mid-session the header is left blank but all GPS data after byte 1024 is still valid and recoverable — the metadata write is the LAST thing a clean session does, so file integrity is independent of it. + +**File naming:** `20YYMMDD_HHMM.dovex` (e.g. `20240115_1430.dovex`) + +### Column Reference + +- **timestamp**: Unix timestamp in milliseconds (since Jan 1, 1970) +- **sats**: Number of GPS satellites +- **hdop**: Horizontal Dilution of Precision (GPS accuracy indicator) +- **lat/lng**: GPS coordinates (8 decimal places) +- **speed_mph**: Speed in miles per hour (2 decimal places) +- **altitude_m**: Altitude in meters (2 decimal places) +- **heading_deg**: Heading of motion in degrees (0-360, 2 decimal places, from UBX headMot) +- **h_acc_m**: Horizontal accuracy estimate in meters (2 decimal places, from UBX hAcc) +- **rpm**: Engine RPM from tachometer input +- **accel_x/y/z**: Accelerometer g-force from onboard LSM6DS3 IMU (3 decimal places, logs `0.000` if IMU not available) +- **Temp1/Junction1**: SensorEgg wireless EGT + cold junction in °C (1 decimal place; literal `nan` when the egg link is stale, invalid, or the POC is compiled out) +- **Temp2**: SensorEgg aux intake-air thermistor in °C (v2 eggs; `nan` on v1 eggs and everything above) + +## File Structure + +``` +DovesDataLogger/ # repo root +├── BirdsEye/ # sketch (folder name must match BirdsEye.ino) +│ ├── BirdsEye.ino # Entry point: globals, setup(), loop(), state machine +│ ├── project.h # Shared types, debug macros, constants +│ ├── display_config.h # Display driver abstraction (SH110X/SSD1306) +│ ├── gps_config.h # GPS configuration constants (baud, nav rate) +│ ├── images.h # PROGMEM bitmap data (splash, animations) +│ ├── accelerometer.{h,ino} # LSM6DS3 IMU init and g-force reads +│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync) +│ ├── display_pages.{h,ino} # All page rendering functions (displayPage_*()) +│ ├── display_ui.{h,ino} # Display init, button handling, menu navigation +│ ├── gps_functions.{h,ino} # GPS init, PVT callback, time conversion, logging +│ ├── replay.{h,ino} # Instant DOVEX header replay + haversine helper +│ ├── sd_functions.{h,ino} # SD init, track JSON parsing, track manifest +│ ├── settings.{h,ino} # Persistent JSON settings (/SETTINGS.json) +│ └── tachometer.{h,ino} # Falling-edge ISR, Kalman-filtered RPM +│ +├── .github/workflows/ # CI: compile-sketch + arduino-lint +├── CASE/ # 3D printable enclosure files (STL + STEP) +├── SDCARD/TRACKS/ # Example track JSON files +├── TACHOMETER/ # Tachometer circuit documentation +├── README.md # This file +├── CLAUDE.md # AI-assistant project guide +└── LICENSE # GPL v3 +``` + +## Required Libraries + +| Library | Purpose | +|---|---| +| Adafruit GFX | Graphics primitives | +| Adafruit SSD1306 | SSD1306 OLED driver (if using SSD1306) | +| Adafruit SH110X | SH110X OLED driver (if using SH1106) | +| SparkFun u-blox GNSS v3 | UBX binary PVT GPS interface | +| ArduinoJson 6.x | Track file and settings JSON parsing | +| SdFat | SD card (FAT16/FAT32) | +| [DovesLapTimer](https://github.com/TheAngryRaven/DovesLapTimer) | Lap/sector timing | +| [CourseManager](https://github.com/TheAngryRaven/DovesLapTimer) | Auto course detection + Lap Anything (part of DovesLapTimer) | +| Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant) | +| Bluefruit nRF52 | BLE (built into Seeed nRF52 board package) | + +**Board package:** Use "Seeed nRF52 Boards" (non-mbed). The mbed variant uses ArduinoBLE instead of Bluefruit and is incompatible. + +## Setup & Usage + +### Initial Setup + +1. **Format SD Card** + - Format as FAT32 (or FAT16 for maximum compatibility) + - Create folder structure: `TRACKS/` in root + +2. **Add Track Files** + - Copy or create `.json` track files in `SDCARD/TRACKS/` + - See "Track Loading System" section above + +3. **Flash Firmware** + + **Option A — prebuilt release (no toolchain needed):** + - Download the latest `BirdsEye.uf2` from the [Releases page](https://github.com/TheAngryRaven/DovesDataLogger/releases) + - Double-tap the XIAO's reset button to mount the `XIAO-SENSE` bootloader drive + - Drag `BirdsEye.uf2` onto that drive — the board reboots into the new firmware automatically + + **Option B — build from source:** + - Install "Seeed nRF52 Boards" board package (non-mbed) + - Install required libraries (see table above) + - Select board: "Seeed XIAO nRF52840 Sense" + - Compile and upload `BirdsEye/BirdsEye.ino` + +4. **Hardware Assembly** + - *3D printed case assembly instructions coming soon* + - *Tachometer circuit README coming soon* + - STL files available in `CASE/` directory + - Note: Requires some glue, measuring, and jeweler's screws + +### Usage + +**Button Controls:** +- **Left/Right Buttons**: Navigate between pages +- **Middle Button**: Select/Enter (in menus), Quick-jump to Pace/Best Lap (while racing) +- **Hold Left + Right (5s)**: Power off (from main menu) +- **Hold Select + Side (5s)**: Reboot device (from any page) + +**Startup Sequence ("Just Drive" mode):** +1. Wake (button / engine start / USB) → Boot screen → **GPS Status page** + (satellite count, HDOP, signal bars). Any button skips to the main menu; + 3 seconds after a stable GPS time lock it advances automatically — + straight into race mode if the engine woke the device or is running +2. Start driving — device auto-enters race mode at 10+ mph or 500+ RPM +3. GPS fix acquired → DOVEX logging starts immediately +4. Track auto-detected via GPS proximity → course detection begins +5. Lap timing activates automatically (sector timing if configured, "Lap Anything" otherwise) + +**Ending Session:** +- **Auto-idle**: stops automatically after 60 seconds below 2 mph +- **Manual**: navigate to "END RACE" page (only accessible when speed < 2 mph), confirm to stop +- Returns to main menu + +**Power Off (System OFF):** +- Activates via 5-second left+right hold on the main menu, or automatically + after 5 minutes idle on the main menu (or 5 minutes on the GPS status page + with no lock and no engine) +- Everything powers down — display, GPS (µA backup mode, config/ephemeris + retained), IMU, and the MCU itself (nRF52 System OFF) — no power switch needed +- Wakes on any button press, a tachometer pulse (engine start boots straight + toward race mode via the GPS status page), or plugging in USB +- Waking is a fresh boot (~1-2 s); the GPS warm-starts from backup RAM in + seconds +- **Charging**: plugging in USB wakes the device into a charging screen + (10 s, then dark); any button fully wakes it — replay/transfer work while + plugged in, and it returns to charging after 60 s of no buttons. Unplugging + powers it off + +## Technical Details + +### GPS Configuration +- **Protocol**: UBX binary (PVT messages via SparkFun callback) +- **Update Rate**: 25Hz racing (40ms between fixes); 5Hz + NAV-SAT during the boot GPS status page +- **Mode**: Automotive dynamic model +- **Constellations**: GPS-only (max nav rate, no multi-constellation overhead) +- **Baud Rate**: 57600 (auto-configured from 9600 default on first boot; boot + probes 57600 first and sends the backup-mode wake byte, so warm boots + reconnect near-instantly and a misbehaving module is auto-recovered) + +### SD Card Logging +- **Write Frequency**: Every PVT update (~25Hz) +- **Flush Interval**: Every 10 seconds (prevents data loss) +- **SPI Speed**: 1 MHz (reduced for EMI resistance) +- **Access Arbitration**: Mutex prevents concurrent access from logging, replay, BLE, and track parsing + +### Tachometer Input +- **Input Pin**: D0 +- **Detection**: Falling-edge interrupt +- **Filtering**: 3ms minimum pulse gap (supports up to ~20,000 RPM) +- **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) + +### Display Update Rate +- **Refresh**: 3Hz (reduces power consumption) +- **Exception**: Instant refresh on button press + +### Battery Monitoring +- **Update Interval**: 5 seconds +- **Voltage Divider**: 1510Ω / 510Ω ratio +- **Display**: Percentage (3.3V = 0%, 4.2V = 100%) with voltage readout + +## Adding Custom Data Pages + +The page system is straightforward to extend. Each page needs: + +1. **Page Constant** in `BirdsEye.ino` (in the running page section): + ```cpp + const int MY_NEW_PAGE = 13; + ``` + +2. **Display Function** in `display_pages.ino`: + ```cpp + void displayPage_my_new_page() { + resetDisplay(); + display.println(F("My Custom Page")); + // Your display code here + safeDisplayUpdate(); + } + ``` + +3. **Page Routing** in `displayLoop()` in `display_ui.ino`: + ```cpp + else if (currentPage == MY_NEW_PAGE) { + displayPage_my_new_page(); + } + ``` + +4. **Update Page Range** in `BirdsEye.ino`: + ```cpp + int runningPageEnd = MY_NEW_PAGE; // Update to new last page + ``` + +Look at existing pages like `displayPage_gps_speed()` or `displayPage_tachometer()` in `display_pages.ino` as examples. + +## Related Projects + +- **Data Viewer**: [DovesDataViewer](https://github.com/TheAngryRaven/DovesDataViewer) + - Web-based viewer for logged data + - Preview at [LapWingData.com](https://LapWingData.com) + +- **Core GPS Timing Library**: [DovesLapTimer](https://github.com/TheAngryRaven/DovesLapTimer) + - Line-crossing detection + - Sector timing + - Optimal lap calculation + +## Future Enhancements + +- **Browser-based simulator (in progress)**: the old Wokwi simulator files + (`diagram.json`, `libraries.txt`) have been removed — they described a dead + Arduino-Mega/SSD1306 target. They're being replaced by a WASM build of the + real firmware (behind the `SIM` compile flag, sources under `BirdsEye/sim/`) + that runs in the browser and replays real `.dovex` sessions, embedded in + [DovesDataViewer](https://github.com/TheAngryRaven/DovesDataViewer) +- Pin lock - require PIN to pull logs from device +- Additional sensor inputs (exhaust temp, water temp) +- WiFi automatic data transfer +- Real-time telemetry to pit crew + +## Notes + +- Crossing detection threshold: configurable via `lap_detection_distance` setting (default 7m) +- Maximum track locations: 1000 +- Maximum layouts per track: 10 +- Maximum lap history: 1000 laps per session +- GPS coordinates stored with 8 decimal places (~1.1mm precision) +- BLE disconnect triggers automatic device reboot (ensures settings changes take effect) + +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how +to build the firmware, run the host tests, and the PR workflow. +[ARCHITECTURE.md](ARCHITECTURE.md) explains how the system fits together. +Found a security issue? Please follow [SECURITY.md](SECURITY.md) and report +it privately. All participants are expected to follow our +[Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +GPL v3 — see [LICENSE](LICENSE) for details. + +## Support + +For issues and questions, please use [GitHub Issues](https://github.com/TheAngryRaven/DovesDataLogger/issues) +(search existing ones first). For security reports, see [SECURITY.md](SECURITY.md). + diff --git a/docs/firmware-ota-phase0.md b/docs/plans/0000-firmware-ota-phase0.md similarity index 56% rename from docs/firmware-ota-phase0.md rename to docs/plans/0000-firmware-ota-phase0.md index 31166a3..32fd0e8 100644 --- a/docs/firmware-ota-phase0.md +++ b/docs/plans/0000-firmware-ota-phase0.md @@ -7,14 +7,27 @@ are implemented and host-tested independently of these findings (see `BirdsEye/firmware_ota.{h,ino}`, `BirdsEye/crc32.{h,cpp}`, and `tests/crc32_test.cpp`). -> **Status of these findings.** The spikes below require the physical sealed -> hardware (XIAO nRF52840, Adafruit bootloader, S140 7.3.0) and a BLE host. -> They could not be run in the CI/build environment. Each item states the -> best-evidence answer from the platform documentation and core source, and -> exactly what must be confirmed on a bench unit before the **apply** path is -> enabled in a field release. The receive/verify pipeline (everything up to, -> but not including, the destructive app-region swap) is safe to ship and -> exercise on its own — nothing it does can brick a unit. +> **Status: mostly settled by field use. ONE spike outstanding — #1.** +> +> This document was written *before* the apply path shipped, when all five +> spikes were open and each item recorded a best-evidence answer plus what to +> confirm on a bench unit. The apply path has since shipped and has been +> flashing real loggers over the air for weeks, including a full end-to-end +> run on a bench vehicle. **Ordinary successful updates exercise spikes #2, #3 +> and the soft-reset half of #5 every single time** — those are no longer +> open questions, and #4 is moot because it was only ever the fallback for a +> #3 that failed. +> +> **Spike #1 — the recovery net — is the one a successful update can never +> prove.** It only fires when an update is *interrupted*: a half-written +> vector table, or power lost mid-swap. Every update so far has succeeded, so +> that path has never been walked. On sealed units it is the difference +> between a recoverable brick and a brick, which is why it is still called out +> here. It needs one deliberately destructive bench test (below). +> +> Per-spike status is recorded inline. Anyone running the remaining test +> should validate against the CURRENT constants in `BirdsEye/firmware_ota.ino` +> — the staging base moved (see the memory-map note). ## Why we self-flash at all (settled, do not re-litigate) @@ -29,6 +42,14 @@ are implemented and host-tested independently of these findings (see ## Memory map assumed by the implementation +> **Superseded — the staging base moved.** Plan 0004 piece 2 split the +> app+staging span evenly: `FW_STAGE_BASE` is now `0x0008E000` and +> `FW_MAX_IMAGE_SIZE` is 408 KiB (app region `[0x27000, 0x8E000)` = 412 KiB). +> The table below records the layout as originally specced. Anyone actually +> running these spikes should validate against the CURRENT constants in +> `BirdsEye/firmware_ota.ino` — spike 3 in particular erases the staging +> region by address. + | Region | Address | Notes | |---|---|---| | MBR + SoftDevice S140 7.3.0 | `0x00000000`–`0x00026FFF` | | @@ -46,6 +67,10 @@ reject any image larger than `FW_MAX_IMAGE_SIZE` (320 KB) with `FWERR:SIZE`. ## Phase 0 spikes ### 1. Recovery net — invalid app ⇒ re-flashable over BLE with no pins +> **STATUS: OPEN — the only one left.** Field use cannot close this. Every +> update so far has *succeeded*, and this path only exists for the ones that +> don't. Closing it needs the destructive test below, deliberately performed. + **Why it matters:** this is what makes self-flashing acceptable on sealed units. If the swap is interrupted, the unit must still be recoverable. @@ -62,6 +87,12 @@ advertises and is re-flashable over BLE via nRF Connect with **no** button / USB / SWD interaction. This is the make-or-break safety gate. ### 2. App can erase+write the upper free-flash region (SoftDevice flash API) +> **STATUS: CLOSED by field use.** Every OTA stages the incoming image into +> the upper region over a live BLE connection; a failure here would surface as +> `FWERR:FLASH` or a stalled transfer, and updates complete normally. The +> timing concern (page erase starving the connection) is answered by the same +> evidence — transfers hold up. + **Best-evidence answer:** with the SoftDevice enabled, page erase / word write go through `sd_flash_page_erase` / `sd_flash_write`, which complete asynchronously via a SoftDevice flash event. The implementation uses the @@ -77,6 +108,13 @@ copy pads the final block to a word with `0xFF`). core version. ### 3. RAM flasher — erase app region + copy staged image, then boot it +> **STATUS: CLOSED by field use.** This was written as "the spike that decides +> whether to ship the RAM flasher". It shipped, and it is what performs every +> update: each successful OTA is a full erase-app-region → copy-staged-image → +> reset → boot-the-new-image cycle. The `.map` inspection and NVMC +> single-stepping below were pre-ship de-risking; the mechanism is now +> demonstrated by the thing itself working repeatedly, on more than one unit. + **Best-evidence answer:** the app-region swap erases the very flash the app runs from, so it must execute from RAM with the SoftDevice disabled and IRQs off, touching only the raw NVMC. `fwRamFlasher()` is marked @@ -91,12 +129,23 @@ This is the spike that decides whether to ship the RAM flasher or fall back to the dual-bank alternative (#4). ### 4. Fallback — dual-bank Adafruit bootloader via nRF Connect (no pins) +> **STATUS: MOOT.** This was only ever contingent on #3 failing validation. +> #3 works in the field, so this migration is not happening. Kept for the +> record — if a future flash-layout change (plan 0004 piece 3) destabilises +> the swap, this is still the documented escape route. + **If the RAM flasher proves shaky:** a one-time, over-the-air move to a dual-bank Adafruit bootloader makes the swap bootloader-managed. The bootloader update itself is pushed via nRF Connect (native app → buttonless trigger works) with no pins. Evaluate only if #3 fails validation. ### 5. GPREGRET behavior across soft-reset vs power-loss +> **STATUS: soft-reset half CLOSED by field use** — the normal flow arms the +> flag and soft-resets, and updates land correctly. **The power-loss half is +> not independently testable and does not need to be:** the whole point is +> that GPREGRET is *lost* on brownout, which is precisely why recovery there +> falls through to spike #1. Confirming #1 confirms this. + **Best-evidence answer:** `GPREGRET` is a RETAINED register in the always-on power domain; it survives a soft reset (`NVIC_SystemReset`) but **not** a full power loss / brownout. So GPREGRET is the recovery path for the *soft-reset* @@ -109,10 +158,14 @@ on the invalid-app fallback). ## Decision -**Leading plan adopted:** SD → upper-flash staging via `flash_nrf5x`, in-flash -CRC re-verify, GPREGRET recovery flag, then a RAM-resident flasher for the -app-region swap (spikes #1–#3, #5). The dual-bank migration (#4) is the -documented fallback if spike #3 fails on hardware. +**Leading plan adopted, and since shipped:** SD → upper-flash staging via +`flash_nrf5x`, in-flash CRC re-verify, GPREGRET recovery flag, then a +RAM-resident flasher for the app-region swap (spikes #1–#3, #5). The dual-bank +migration (#4) was the documented fallback if spike #3 failed on hardware; it +did not, so #4 is moot. + +This is the path in the field today. What follows are the safety invariants it +enforces — all of them still current, and all of them still load-bearing. **Safety invariants enforced in code:** - The app region is **never** erased until the staged copy is CRC-verified @@ -128,6 +181,29 @@ documented fallback if spike #3 fails on hardware. - Every step before the RAM flasher is abortable; the running firmware stays intact until the flasher actually runs. +## The one test still outstanding + +Everything else here is answered. This is the whole remaining scope of Phase 0: + +**Prove that an interrupted update leaves a recoverable unit** (spike #1). + +1. On a bench unit, erase one page at `FW_APP_BASE` (`0x27000`) — enough to + invalidate the application's vector table. +2. Power-cycle. Do **not** press anything, plug in USB, or attach SWD. +3. Confirm the Adafruit bootloader comes up advertising BLE DFU. +4. Push a fresh image over the air with nRF Connect (native app — not subject + to the Chrome Web Bluetooth blocklist that forced self-flashing in the + first place) and confirm the unit boots it. + +If that holds, the recovery net is real and Phase 0 is closed. If it does +not, the honest consequence is that a sealed unit interrupted mid-swap is +unrecoverable, and the apply path needs the dual-bank migration (#4) before it +is safe to keep shipping to sealed hardware. + +Worth doing on a unit you are willing to lose, and worth doing *before* plan +0004 piece 3 (SD-direct apply) touches the swap — that change would otherwise +land on an unproven recovery net. + ## Fleet migration The first firmware carrying this `FW*` protocol is pushed to already-sealed diff --git a/docs/insta360-ble-current-implementation.md b/docs/plans/0001-insta360-ble-current-implementation.md similarity index 100% rename from docs/insta360-ble-current-implementation.md rename to docs/plans/0001-insta360-ble-current-implementation.md diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md new file mode 100644 index 0000000..849481f --- /dev/null +++ b/docs/plans/0002-sprint-mode.md @@ -0,0 +1,478 @@ +# Sprint Mode (Autocross / Point-to-Point) — Concept & Cross-Repo Roadmap + +> Status: **SHIPPED.** All §7 questions are decided and all three repos +> have landed their phase: `DovesLapTimer` (`SprintTimer` + +> `CrossingEngine`), `DovesDataLogger` (`/TRACKS/SPRINT/`, `TS*` opcodes, +> `race_mode`, and the on-device course creator of §5), and +> `DovesDataViewer` (its own plan 0015 — course model, editor, sync, +> Device tab, and reading runs back out of a log). +> Scope spans three repos; each phase lands on that repo's beta branch +> (DovesLapTimer `BETA` → DovesDataLogger `BETA` → DovesDataViewer, last). +> +> Still open, both sequenced to the end on purpose and tracked in the +> webapp's plan 0015: the **Android IPC `TS*` parity** (gated on that app's +> release) and the **sync-prune** of §2. The creator makes the second one +> matter more — every event walked adds a course to a file the device +> re-parses through a 4 KB budget. + +## 1. Problem & Goal + +Autocross (and hillclimb / sprint-style events) doesn't fit the current +timing model. A run is **point-to-point**: a start line and a *separate* +finish line, no laps. Drivers make multiple runs per session — cross the +finish, loop back around, wait (often minutes, engine on or off) at the +start line, run again. Sectors may exist, but the crossing tech is the +same as circuit — actually lighter: two lines to monitor plus optional +splits. + +The circuit assumption is baked in at every layer today: + +- **Timing library**: start line *is* the finish line; everything is lap + accounting (`laps++`, `raceStarted`, best/last lap, direction detection). +- **Course detection**: `CourseDetector` identifies the course by driving a + full lap back to your own dropped waypoint and matching odometer distance + to `lengthFt`. In sprint you *never return to the start* mid-run — + length-based detection is structurally impossible, not just untuned. +- **Session lifecycle**: `checkAutoIdle()` ends the session after 60 s + below 2 mph (`BirdsEye.ino:1142-1183`). Staging at an autocross start + line looks exactly like "done for the day" — the session would be killed + between every run. + +**Goal**: a `race_mode` device setting — `circuit` (default, current +behavior, untouched) vs `sprint` — toggled from the webapp, with sprint +tracks stored separately so everything existing stays backwards compatible. + +## 2. Product Shape + +| Aspect | Circuit (today, unchanged) | Sprint (new) | +|---|---|---| +| Mode select | default | **automatic — mode follows the detected track** (decided): if the nearest manifest match came from `/TRACKS/SPRINT/`, the device knocks into sprint mode. A `race_mode` **preference setting** (`circuit` default / `sprint`, webapp `SSET`) exists only as the **tiebreak when both kinds are in range** — see §7 Q1 for the decided rules (it also serves fixed sprint courses, e.g. a permanent rally layout, where the course-of-the-day heuristic never fires). | +| Track storage | `/TRACKS/*.json` | `/TRACKS/SPRINT/*.json` (new folder; circuit tracks stay put) | +| Track detection | haversine proximity vs manifest | same mechanism, scanning sprint manifest entries | +| Course detection | `CourseDetector` (length-based) → Lap Anything fallback | **none** — **date-based selection** (decided): load only the most-recently-created course by `date_created`. One course in RAM. | +| Course lines | S/F (+ S2+S3 pair) | **start + finish required; up to 2 optional sector lines** (a single split must be legal — see Phase 1) | +| Timing | lap timer (S/F crossing, laps) | run timer (start line → finish line, N runs/session) | +| Fallback | Lap Anything (`WaypointLapTimer`) | none meaningful — no matched sprint track ⇒ log-only session; the real answer is the on-device course creator (§5) | +| Session | one `.dovex`, laps line in header | one `.dovex` for the whole event, runs line in header | +| Race entry | auto (RPM > 500 or ≥ 10 mph) or manual | same triggers — driving to the queue should clear 5–10 mph, and karts have RPM — but **expect more manual race entry** at autocross; keep the manual path prominent and don't make sprint depend on auto triggers | + +Future modes note: this two-value scheme (`CIRCUIT`/`SPRINT`) is expected to +grow — a **drag mode** is on the horizon and will be a bigger lift (staging, +reaction time, fixed distances). Everything mode-shaped (DOVEX marker, track +`type` field, folder scheme) should be an open enum, not a boolean. + +### Course lifecycle at a sprint venue (user report #3) — date-based selection + +The same entrant attends the same venue most weekends, **and the course is +different every event** (cones get re-laid). So sprint "courses" are +disposable, dated layouts of a persistent venue (track): + +- New sprint-course JSON field: **`date_created`** — stamped automatically by + whatever created the course (webapp or the on-device creator, §5); the user + never edits it. **Decided: full sortable ISO-8601 timestamp + (`YYYY-MM-DDTHH:MM`)** — autocross venues re-lay courses same-day + (morning/afternoon configs) and a date-only value can't order those; + lexicographic max = newest falls out for free. Device-generated file/course + name suffixes use the same timestamp, killing same-day collisions too. +- Loading a sprint track loads **only the newest course** by `date_created` + into memory — no CourseDetector, no default-course setting, one course, tiny + RAM footprint. This may evolve (this user races weekly and will be a + valuable data source), but v1 is deliberately simple. +- Old courses accumulate in the track file — the on-device JSON parse buffer + is 4096 bytes, so a weekly venue overflows it within months. **Decided: + webapp-side prune on sync** — when the app syncs a sprint track it pulls + the full file (archiving all courses app-side), then rewrites the device + copy keeping only the **most recent single day of courses** + (sync → delete → push new file). v1 may ship *before* this lands, with a + loud documented disclaimer that un-synced devices will eventually hit the + course cap. + +### Real-world cadence (user report, 2026-08) + +An actual autocross entrant describes the day like this: runs happen in +**heats of ~4** — one run at a time, then a stop of only **30–45 seconds** +in the queue while others go, then the next run. After the 4th run the +whole run group rotates out ("then the cars go") for a long break — +tens of minutes — before another heat of 4. They leave the Insta360 X4 +recording across an entire heat and end up with "a couple of 20 min +videos". + +That cadence pins the session model (user has since **confirmed the +engine stays running between runs**, so the mapping below holds with no +caveats): + +- **Session = heat.** One heat = one `.dovex` = one camera video. This is + exactly what the camera-paired firmware already does today: the engine + stays running through a heat, so the camera keeps recording; 30 s of + engine-off at heat end auto-stops the recording *and* ends the log + session (`cameraConsumeAutoStop()`), and the next heat's engine start is + a fresh wake → new session. Sprint mode should preserve this mapping, + not fight it — and **run boundaries must never touch the camera**. +- **Between-run stops are shorter than the 60 s auto-idle window**, so the + idle killer is less catastrophic than first assumed — but queue position + isn't guaranteed (grid delays, red flags), so the engine-aware idle rule + below is still wanted as a safety margin rather than a rewrite. +- **Run counts are small** (4-ish per session), so run history/state is + trivial memory-wise; per-run re-arm through the same start line ~every + minute is the hot path to test. + +Key research finding that makes this cheap: **the crossing math is already +line-agnostic.** `DovesLapTimer::_detectLineCrossing()` +(`DovesLapTimer.cpp:198-306` on the library's BETA branch) takes an +arbitrary (A, B) line + an external crossing flag and returns the +interpolated crossing — a separate finish line reuses it verbatim. There is +even a standing TODO at `DovesLapTimer.cpp:143` about making +`checkStartFinish()` portable for split timing. What's missing is +structural, not mathematical. + +## 3. Phase 1 — DovesLapTimer (`BETA`): the timing engine + +The engine lives in the library from day one (firmware BETA builds already +track the library's BETA branch in CI, so co-development is wired). + +- **`SprintTimer` class**, sibling of `DovesLapTimer`, sharing the + crossing-detection path. Refactor `_detectLineCrossing()` + + `insideLineThreshold()` / `pointOnSideOfLine()` / the crossing ring + buffer into a reusable core (the `DovesLapTimer.cpp:143` TODO), rather + than copy-pasting. +- **Run accounting instead of lap accounting** (semantics decided): + **both lines are monitored continuously** — same as the circuit timer + monitors S/F + S2 + S3 every loop. Two states, purely line-driven: + - `WAITING` → **start crossing** begins a run (interpolated time). + - `RUNNING` → **finish crossing** completes the run; + **start crossing cancels the in-progress run and starts a brand new + one** (the botched-course case: driver messes up, drives back around, + re-launches — sprint's equivalent of circuit's + every-S/F-crossing-closes-and-opens-a-lap rule). + - Finish crossings while `WAITING` are **ignored** (driving back past + the finish on the return loop must not do anything). + - **DNF is just normal operation**: an abandoned run never completes and + records nothing; the driver eventually kills the engine and the + normal session-end paths take over. No special DNF state. + - Nuance: crossing detection is direction-agnostic, so a *backward* + start crossing on the return loop opens a bogus run — which the + restart rule then self-heals at the real launch (the forward crossing + cancels it). A backward *finish* crossing while `RUNNING` is the only + truly bogus completion; it requires re-entering the finish zone + mid-run (rare on a one-way autocross course). Cheap hardening if it + matters: gate the finish line on crossing sign + (`pointOnSideOfLine()` already returns signed sides). + + Surface: run count, current run time, last run, best run (+ run + number), run history. No `laps`, no `DirectionDetector`, no + S/F-restarts-sector-1 entanglement. +- **Course model**: add `finish_a/b_lat/lng` to the sprint course config. + v1 sectors (decided): **start + finish required, up to 2 optional split + lines** — matching the S2/S3 shape the device UI and circuit model + already speak — but **a single split must be legal** + (`areSectorLinesConfigured()` requires *both* today; that all-or-nothing + gate gets relaxed). The webapp's ordered `sectors[]` list means N splits + can come later without a data-model change. +- **Course selection without detection**: a public `selectCourse(int)` on + `CourseManager` (today `_activeCourseIndex` / `_detectionComplete` are + private with no setter — `_activateLapAnything()` is the only way to + short-circuit detection). Sprint mode always uses it — the firmware + picks the newest course by `date_created` (§2) and selects it directly; + it's independently useful for circuit too (adjacent to library issue + #35, proximity-based detection). +- **Memory**: don't instantiate the 8-slot by-value course array for a + mode that needs exactly one active course (~29 KB regardless of count + today — library issue #22). +- **Known constraint to design around**: the crossing ring buffer is a + single shared instance with only-one-line-crossing-at-a-time mutual + exclusion (`loop()`, `DovesLapTimer.cpp:78-130`). Autocross paddocks + often place start and finish within meters of each other — either give + sprint lines independent buffers or document a placement constraint. +- **Tests**: follow the existing host-native pattern — + `test_synthetic_track.cpp` builds a deterministic synthetic circuit; a + sprint suite generates an open path (straight / L) with a start line + near step 0 and finish near step N, asserting run times. Extend + `replay_runner.h`'s `ReplayConfig` (today it only carries `sfA/sfB`) + with finish-line fields; a real recorded autocross NMEA/DOVEX fixture is + wanted eventually. +- Related library issues worth folding in while in there: #32 + (crossing-event callback — exactly the seam a run-complete edge wants), + #30 (pit-lane special line pair — same "non-lap line" shape), #34 + (session-persistent state injection). + +## 4. Phase 2 — DovesDataLogger (`BETA`): consume it + +- **Mode-by-folder detection**: `trackDetectionLoop()` picks the nearest + manifest entry as today; if that entry's `kind` says sprint, the session + runs in sprint mode — build the sprint timer path instead of + `CourseManager`+`CourseDetector`, and select the course by newest + `date_created` (§2). When both kinds are in range, the `race_mode` + **preference setting** breaks the tie (rules in §7 Q1): default row in + `ensureDefaultSettings()` (`settings.ino:102-108`), global loaded at + `BirdsEye.ino:765-795`, values `circuit` (default) / `sprint`. +- **RPM accuracy settings** (`spark_mode` + `cylinder_count`) are needed by + this same user group (autocross karts, possibly a 2-cyl monster) but are + independent of sprint mode — split out as **plan 0003**. +- **`/TRACKS/SPRINT/`**: the `/TRACKS` literal is duplicated across + `trackFolder[8]` (`BirdsEye.ino:469`), `makeFullTrackPath()` + (`sd_functions.ino:64`), `sdEnsureTracksFolder()`, the + `buildTrackList()` walk, and four splices in `bluetooth.ino` + (TLIST/TGET/TPUT/TDEL). Plan: add `kind` to `TrackManifestEntry` + (`project.h:228`), factor `buildTrackList()`'s walk into a + parameterized `scanTrackDir(folder, kind)`, make `makeFullTrackPath()` + manifest-entry-aware, and provision the sprint folder wherever + `sdEnsureTracksFolder()` runs (boot, upload, post-format). +- **Sprint track JSON**: same object format plus `"type": "sprint"` and + `finish_a/b_*` fields (and the ordered sectors list), parsed with the + established optional-`containsKey()` idiom in `parseTrackFile()`. The + folder already discriminates kind; the `type` field is cheap redundancy + and lets the webapp validate. +- **BLE track sync**: new folder-qualified opcodes (sprint variants of + `TLIST`/`TGET:`/`TPUT:`/`TDEL:`) rather than path-carrying filenames — + `filename_validator` deliberately rejects `/` and `..` to keep BLE + clients jailed to the tracks folder, and it should stay strict. +- **Third timing backend**: the `activeTimer*()` helpers + (`BirdsEye.ino:867-962`) are already a two-backend dispatch + (DovesLapTimer / WaypointLapTimer); `SprintTimer` slots in as a third. + Any new/changed helper must be mirrored in `sim/sim_prototypes.h` or the + sim build breaks. +- **Session lifecycle** (shaped by the heat cadence in §2): + - Session = heat. The camera-paired path already ends the session on + 30 s engine-off (`cameraConsumeAutoStop()`) — that *is* the heat + boundary, keep it. Run boundaries never touch the camera and never + end the session. + - Sprint-aware `checkAutoIdle()` for the no-camera case: engine-aware + idle (only start the idle clock when tach reads 0 *and* stationary) + and re-arm the grace at every run completion (today the 3-min grace + anchors once at session start and never re-arms). With 30–45 s + between-run stops this is a safety margin for queue delays, not the + main path. The camera-recording yield at `BirdsEye.ino:1158` is the + precedent for "something else says we're still active". + - Multiple runs live inside **one session / one `.dovex`** — a run + boundary must never call `endRaceSession()` (it deletes + `courseManager` and wipes best-run state). + - Run completion is an explicit edge (event/callback), not the current + `checkForNewLapData()` value-change dedupe (`BirdsEye.ino:389-406`) — + two identical run times in a row would be silently dropped. +- **DOVEX** (decided): add a trailing `race_mode` column to header + lines 1/2 — the format's established extension mechanism + (`device_name` was added the same way, `dovex_header.h:21-23`). + Values: **`CIRCUIT` / `SPRINT`** (parse case-insensitively; empty or + absent = `CIRCUIT`, so every legacy log parses correctly). "Sprint" is + the established motorsport term for one-at-a-time point-to-point timed + runs and covers autocross / hillclimb / stage-style events alike. The + column is deliberately just a **loading helper for the webapp** (pick + run-derivation vs lap-derivation — a GPS trace alone doesn't reveal the + mode) plus the replay page's Run-vs-Lap labels; nothing on-device + depends on it. The lap times line doubles as the run times line — + per-run sector/DNF detail, if ever wanted, would be a new line pair + after line 4 (old parsers never read past it), not a change to this + marker. Extend `tests/dovex_header_test.cpp`. +- **Display** (decided): **keep the existing "Lap" verbiage everywhere** — + the AX drivers call them laps anyway, so no mode-conditional relabeling, + no new page constants, and the fragile ordered page-constant blocks + (`ENDURANCE_MODE` / `SENSOREGG` reshuffles) stay untouched. The only + sprint-aware display change: while no run is active (`WAITING`), the + **Current Lap and Pace pages show `*waiting*`** — every other page + (tach, GPS debug, best lap, lap list, …) works normally, so the driver + can watch RPM etc. between runs. + +## 5. On-device course creator — the big ask + +> **Status: BUILT.** Main menu → **Create** → walk the cones. The model, +> validation, point averaging, name generation and JSON emission are the +> host-tested `course_creator` + `track_json` units; the sketch supplies +> rendering, GPS and SD. What actually shipped, and where it departs from +> the spec below, is recorded in *§5.1 What was built*. + +Sprint entrants can **walk the course** before the event (cones are laid out +fresh each time), so the device itself must be able to create a course — +standing at each cone and capturing GPS positions. Hard rule: **no text entry +on-device, ever.** All names are auto-generated and renameable later in the +webapp. + +### UI flow (as specced) + +New main-menu option **"Create Course"**: + +1. **Track prompt** — run the normal proximity track search, then ask + `Are you at {TRACK NAME}?` with options **Yes / New Track**. A new track + is written as `NEWTRACK_{date}.json` (no typing; renamed in the app + later). +2. **Type select** — choose **CIRCUIT** or **SPRINT** course. +3. **Line menu** — one row per line, labeled + `{Name} {* if required} {DONE stamp when both points collected}`: + - `Start/Finish *` (labeled just `Start *` in sprint) + - `Sector 2` + - `Sector 3` + - `Finish *` (sprint only) + - `Save` — writes the course as `NEWCOURSE_{date}`, back to main menu + - `Cancel` — back to main menu, discard +4. **Per-line menu**: + - `{Line label}` (reminder header) + - `Point A * {DONE}` + - `Point B * {DONE}` + - `Save` / `Back` +5. **Point menu**: `{Line label : A/B}` → **Save current pos** / Back. + +### Design notes / constraints found in research + +- **GPS gating**: capture requires a fix, and the auto-generated names + require GPS date/time — same `timeValid` gate as log-file creation. The + point screen should show live `h_acc` and warn/refuse on poor accuracy. +- **Point capture averages, not snapshots** (decided): "Save current pos" + runs a **3 s hold** — up to ~75 fixes at 25 Hz — averaged into the point, + with a "hold still… n/N" progress and live `h_acc` (warn/refuse on poor + accuracy). The user is standing at a cone anyway; this is free precision. +- **Name collisions** (resolved by §2's ISO decision): generated + `NEWTRACK_`/`NEWCOURSE_` suffixes carry the same date+time stamp as + `date_created`, so a second same-day creation can't collide. Note the + track-browser display truncates to 13 chars (`MAX_LOCATION_LENGTH`), so + on-screen disambiguation may still need the time portion favored over the + literal `NEWTRACK_` prefix. +- **This is the firmware's first track-JSON writer**: today the device only + reads track files (settings JSON read-modify-write is the closest + precedent, `settings.ino`). Appending a course means parse-modify-write of + a `/TRACKS/SPRINT/*.json` under the 4096-byte parse buffer — reinforces + the pruning question (§7). Writes go through the existing SD arbitration. +- **Save semantics**: captured points live in RAM until line-menu `Save`; + `Cancel`/power-loss discards. Fine for v1 — walking the course takes + minutes, not hours. +- Circuit courses created on-device get S/F + optional S2/S3 — exactly the + existing model, so the creator serves both modes from day one. + +### 5.1 What was built — decisions that differ from the spec above + +**Generated names favour the timestamp over the prefix.** The spec asked +for `NEWTRACK_{date}` / `NEWCOURSE_{date}` and flagged, in its own design +notes, that the track browser truncates to 13 chars +(`MAX_LOCATION_LENGTH`) so the prefix would swallow the disambiguating +part. That note is resolved here rather than left as a papercut: names are +**`N{YYMMDD}_{HHMM}`** — e.g. `N260803_1432`, 12 characters, unique to the +minute, chronologically sortable, and still obviously machine-generated. +A new track's `shortName` is **`MMDDHHMM`** (8 chars), which is exactly +the webapp's `Track.shortName` budget *and* half of the `(kind, shortName)` +key its device-sync merge uses — so two tracks walked on the same day +cannot collide there either. + +> **Webapp import, read this:** these two formats are the contract the +> import flow should match. `date_created` is unchanged — the sortable +> `YYYY-MM-DDTHH:MM` stamp — and is written for **sprint courses only**, +> matching the webapp's own "sprint-only" documentation of the field. + +**Two validation rules exist purely to keep courses editable in the +webapp.** Both are enforced before Save is allowed, because a course the +device writes and the app then refuses to save is worse than one that was +never written: + +- **Circuit sectors are all-or-nothing.** The webapp's `validateCourseSectors` + accepts zero sectors or exactly three majors (start/finish + two), so a + course carrying only sector 2 would load there but could never be saved + again. +- **Sprint splits fill in order.** The webapp stores splits as an ordered + list and re-exports them *positionally* into `sector_2` / `sector_3`, so + a lone sector 3 would come back as a sector 2 after one sync round trip + — a silent edit nobody made. + +**Line edits are scratch-then-commit.** Opening a line copies it into a +scratch buffer; `Save line` commits, `Back` discards. That makes Back a +real undo — re-walk one endpoint, decide you stood in the wrong place, and +leave the stored line untouched. + +**The capture hold can fail.** Three seconds at 25 Hz should gather ~75 +fixes; anything under 8 usable ones ends the hold as FAILED rather than +averaging noise into a timing line. Fixes worse than 10 m horizontal +accuracy are dropped outright (a point built from them is most of a cone +away), and fixes arriving after the window closes are ignored so a mean +the user has already been shown can't shift underneath them. + +**Appends are write-to-temp-then-rename.** Adding a course to an existing +track file is a read-modify-write; it serializes to `.tmp` and only +then replaces the original. Writing in place would mean a power loss +mid-serialize leaves a truncated file where a working track used to be — +and this runs in a field, on a battery, at an event. + +**Entry requires a fix and a time lock.** Every screen past the prompt +needs GPS (to capture) and the clock (to name the file), so the menu entry +refuses up front instead of letting someone walk a whole course and fail +at Save. + +**A new global, `gpsPvtSequence`.** The capture needed "is this a new PVT +sample?" and `gpsDataFresh` could not answer it — `GPS_LOOP()` consumes +that flag earlier in the same loop iteration, and `gpsData` holds its last +value between updates, so an un-gated feed would have averaged the same +fix ~250 times a second and reported a confidence the fix never had. +`gpsFrameCounter` is no help either (it zeroes every second for the +frame-rate maths). The new counter is monotonic and never reset. + +## 6. Phase 3 — DovesDataViewer (last) + +The old BETA branch was merged (PR #373) and deleted — this phase starts by +cutting a fresh beta branch. Nothing in the app anticipates modes today. + +- **Track/course editor**: a **circuit/sprint toggle on the track & course + editor** (decided — the primary mode signal; the `race_mode` device + setting is only the in-range tiebreak, §7 Q1). Sprint + courses get a finish line + optional splits; `date_created` is stamped + automatically on course creation and is **not user-editable**. The + editor also needs rename support for device-generated + `NEWTRACK_*`/`NEWCOURSE_*` names. +- **Sync-prune for sprint tracks** (decided, may land after v1 with a loud + disclaimer): syncing a sprint track archives all courses app-side, then + rewrites the device file keeping only the most recent single day of + courses (sync → delete → push new file). See §2. +- **Settings control type**: the `race_mode` preference (§2) and plan + 0003's `spark_mode` both want a proper `boolean`/`enum` control in + `deviceSettingsSchema.ts` + `DeviceSettingsTab.tsx` (today only + string/number text inputs exist). +- **Track model**: `type: 'circuit' | 'sprint'` on `Course`/`Track`, + flowing through `trackStorage.ts` ↔ `deviceTrackSync.ts` ↔ + `trackSubmission.ts` and a Supabase `courses` column. Relax + `validateCourseSectors` (currently 0 sectors or exactly-3-majors) and + add a finish-line handle to the track editor (`VisualEditor.tsx`, + `LineId = 'sf' | number`). Note `coursesMatch` compares only S/F + two + sector lines today — sprint fields must join the diff or sync status + lies. +- **Sync**: implement the sprint-folder opcodes in `trackSync.ts` / + `deviceTrackSync.ts` (all filenames are flat today, no directory + concept). Update `docs/ble-protocol.md` §9. +- **Runs view**: the viewer re-derives laps from GPS + (`lapCalculation.ts`) rather than trusting the header — sprint needs a + run-derivation variant (start-crossing → finish-crossing per run) and a + run-oriented `LapTable` view. The existing `isWaypointMode` branch is + the precedent for "session without real laps" flowing through the UI. +- Per that repo's conventions: a numbered `docs/plans/` design doc and + `docs/subsystems.md` updates. + +## 7. Open Questions + +1. ~~Mode selection?~~ **Decided: mode follows the detected track's + folder; the `race_mode` setting survives as the both-kinds-in-range + tiebreak.** Rules: with `race_mode=circuit` (default), prefer circuit — + *unless* the nearby sprint track has a course created **today** (event + day), in which case sprint wins. With `race_mode=sprint`, always prefer + the sprint track and load its newest course regardless of date — this + is the fixed-course case (e.g. a permanent rally layout that isn't + re-created per event). +2. ~~Minimum sector support?~~ **Decided: start + finish required, up to 2 + optional splits, single split legal.** +3. ~~Course-history pruning?~~ **Decided: webapp prunes on sync, keeping + the device's most recent single day of courses** (sync → delete → push + new file). v1 may ship before it lands, with a loud disclaimer about + the 4096-byte parse-buffer cap on un-synced devices. +4. ~~Run arming / DNF semantics?~~ **Decided.** Session arming is + unchanged from circuit: RPM held for ~a second brings up all systems + and recording, exactly like today. Run timing is purely line-driven + with both lines always hot: start crossing begins a run (and cancels + + restarts any run already in progress — the botched-course re-launch + rule); finish crossing completes it; finish crossings with no active + run are ignored. DNF needs no special record — the run just never + completes, and the engine eventually dying ends the session through + the normal paths. Full state machine in Phase 1 (§3). +5. ~~Between-run state?~~ **Decided: the device simply stays in normal + race mode between runs.** All pages remain live (tach/RPM, GPS debug, + best lap, lap list, …); the Current Lap and Pace pages show + `*waiting*` while no run is active. Best run, run history, and the log + file naturally survive the loop-back and queue wait since the session + never ends between runs (Phase 2 lifecycle). Best run is per-session + (one `.dovex` per heat) — matches existing `best_lap_ms` semantics. +6. ~~Runs-vs-laps wording?~~ **Decided: keep "laps" for now** — the AX + drivers themselves call them laps. Applies on-device (display, replay + page) and means the DOVEX `laps_ms` line needs no renaming; the + `race_mode` header column alone tells the webapp how to interpret the + data. Per-run sector detail, if ever wanted, is later. diff --git a/docs/plans/0003-rpm-spark-cylinder-settings.md b/docs/plans/0003-rpm-spark-cylinder-settings.md new file mode 100644 index 0000000..52be932 --- /dev/null +++ b/docs/plans/0003-rpm-spark-cylinder-settings.md @@ -0,0 +1,124 @@ +# RPM Accuracy — Spark Type & Cylinder Count Settings + +> 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 +> ignition pulse per revolution. +> +> Settings key is **`cylinder_count`** as drafted. The webapp's `enum` control +> landed alongside it (DovesDataViewer), so `spark_mode` is a real dropdown +> rather than the free-text stopgap this plan allowed for. + +## Goal / problem + +The tachometer counts **ignition pulses** from the inductive pickup and +currently treats one pulse as one revolution. That's only true for a 1-cyl +2-stroke or a 1-cyl wasted-spark 4-stroke — the common kart cases, which is +why it's been fine so far. Other engines skew RPM by a fixed factor: + +| Engine | Pulses per revolution | +|---|---| +| 2-stroke, per cylinder | 1 × cylinders | +| 4-stroke wasted spark, per cylinder | 1 × cylinders | +| 4-stroke single-fire (no wasted spark), per cylinder | 0.5 × cylinders | + +The logger should reflect **true RPM** in both the display and the DOVEX +`rpm` column, based on two new device settings. + +## New settings (2) + +| Key | Default | Meaning | +|---|---|---| +| `spark_mode` | `wasted` | `wasted` = one spark per rev (2T, or 4T wasted spark); `single` = one spark per two revs (4T single-fire) | +| `cylinder_count` | `1` | Cylinders visible to the pickup | + +`pulses_per_rev = cylinder_count × (spark_mode == wasted ? 1.0 : 0.5)` +`true RPM = pulse RPM ÷ pulses_per_rev` + +**Defaults exactly reproduce today's behavior** (divisor 1.0), so existing +devices are unaffected by `ensureDefaultSettings()` auto-populating the keys. + +## Approach + +- Settings rows in `ensureDefaultSettings()` (`settings.ino:102-108`), + loaded into globals in the settings block (`BirdsEye.ino:765-795`), + applied on boot like every other setting (webapp `SSET` + auto-reboot). +- **Single source of truth (decided)**: true RPM is computed **once** — at + the period→RPM conversion in `TACH_LOOP()`, *before* the Kalman filter — + and published as the one canonical value (`tachLastReported`). **No + consumer ever re-applies or re-derives the correction**: display (3 Hz), + DOVEX rows (25 Hz), camera FSM inputs, and the auto-race check all read + the same already-corrected number. Any future consumer must read it too, + never the raw pulse rate. While implementing, audit that nothing else + computes RPM from pulse periods independently. +- **Kalman note**: the correction must sit *before* the filter because the + filter's tuning is in true-RPM units — Q = 800 RPM² models kart engine + inertia and R_BASE = 2500 RPM² models measurement noise + (`tach_filter.h`). Feeding pulse-RPM and dividing afterward would make a + 2-cyl engine's process/measurement noise effectively half-scale, i.e. + differently-filtered behavior per engine type. Correct-then-filter keeps + one tuning valid for everyone. +- The divisor math itself belongs in the host-tested `tach_filter` pure unit + (or a tiny sibling) with tests per the repo convention. + +## Ripple effects (mostly good) + +- **RPM thresholds become engine-independent**: auto-race entry (>500), + camera wake/record/stop (500 / 1500 / 300) currently assume 1 pulse/rev; + on a 2-cyl they'd fire at half the true RPM. With correction, thresholds + mean what they say on every engine. +- **Pulse-rate ceiling** (resolved): the min gap now derives from + `pulses_per_rev` — 3 ms ÷ ppr — in `tach_filter::minPulseGapUs()`. + **The floor is 750 µs, not the 1.5 ms sketched here**: 1.5 ms still put a + 3-cylinder at ~13 300 true RPM, below the old ceiling. 750 µs holds the + full ~20 000 through four cylinders; past that the floor binds (10 000 at + eight), which is far clear of anything this logger targets. + ISR headroom was never the constraint — the body is <1 µs, so the floor's + worst case (~1300 int/s) is negligible. Ringing was, and the margin holds + from both ends: the tach input is RC-filtered (~100 µs) and the documented + pickup circuits emit pulses **milliseconds** wide — `TACHOMETER/README.md` + records circuit 1's 5 ms pulse as itself the ~9800 RPM limit on that + hardware, i.e. the pulse width, not the debounce, is what binds there. +- **Pickup placement nuance (document for users)**: `cylinder_count` is + "cylinders the pickup *sees*". A pickup clamped around ONE plug wire of a + 2-cyl sees one cylinder → leave `cylinder_count = 1`. Only a pickup on a + shared coil/all-cylinder harness sees them all. + +## Touch points + +`settings.ino` (defaults), `BirdsEye.ino` (globals + load), `tachometer.ino` ++ `tach_filter.{h,cpp}` + `tests/tach_filter_test.cpp` (divisor + tests), +README.md + CLAUDE.md settings tables. Webapp: keys auto-appear via the +generic settings list; a proper `enum` control type in +`deviceSettingsSchema.ts` is wanted for `spark_mode` (shared need with +nothing else right now — plain string works until then). + +## Status + +**Shipped.** No dependency on plan 0002. Landed before any camera/auto-race +tuning for multi-cylinder engines, as intended — those thresholds now mean +what they say on every engine. + +### What actually landed + +- `tach_filter::revsPerPulse()` / `minPulseGapUs()` — the geometry, in the + host-tested pure unit, sharing one private `pulsesPerRev()` so the RPM + scale and the debounce can never disagree. +- `tachRevsPerPulse` / `tachMinPulseGapUs` became boot-set globals; the + correction point in `TACH_LOOP()` was already there and already ahead of + the Kalman filter, so no restructuring was needed. +- Settings default in `ensureDefaultSettings()`; loaded in `setup()`. + Anything other than an explicit `"single"` degrades to `wasted`, so a + blank or future value reads as today rather than doubling every RPM. +- The audit the plan asked for came back clean: nothing else derives RPM + from pulse periods. The only other `60e6` in the tree is the simulator's + pulse *generator*, which is the inverse and matches the default. + +### Verified + +Unit tests cover the geometry, the clamps (a corrupt `cylinder_count` can +never divide by zero) and the ceiling property. End-to-end in the simulator: +6000 pulses/min reports 6000 RPM at the defaults and 3000 with +`revsPerPulse` forced to a twin — so the wiring is proven, not just the +math. Golden fixtures and the lap oracle are unchanged, which is the +evidence that existing devices are unaffected. diff --git a/docs/plans/0004-sd-direct-ota-staging.md b/docs/plans/0004-sd-direct-ota-staging.md new file mode 100644 index 0000000..8fb5a45 --- /dev/null +++ b/docs/plans/0004-sd-direct-ota-staging.md @@ -0,0 +1,184 @@ +# SD-Direct OTA Staging — Spike + the FWDFU Pre-Update + +> Status: **PARTIALLY SHIPPED**, in three pieces. Grew out of the OTA cap +> incident on PR #116 (image hit 100.9% of the 320 KB self-flash cap; it was +> at 98.2% before sprint mode). +> +> | # | Piece | Cap after | Status | +> |---|---|---|---| +> | 1 | `FWDFU` pre-update (USB UF2 escape hatch) | n/a | **shipped**, master + BETA, field-tested | +> | 2 | Even flash split (staging base `0xA4000` → `0x8E000`) | 320 → **408 KiB** | **shipped**, no hardware spike needed | +> | 3 | SD-direct apply (delete the staging region) | 408 → **~792 KiB** | **spike-gated, parked to the end of the project** | +> +> Piece 2 was originally filed below as the "someday/never" fallback. It was +> promoted because it is free: it needs no new code paths, only two +> constants, and it alone took the beta image from 99.0% of the cap to 77.6%. +> Piece 3 is still the endgame and still needs bench hardware. + +## Problem + +The self-flash OTA (plan 0000 / subsystem 11) parks the incoming image in a +**320 KB internal-flash staging region** before the RAM flasher swaps it +into the app region. Staging and app share one 820 KB stretch +(`0x27000..0xF4000`), so the staging region is both the OTA image cap and a +tax on app space. The debug kill switch (DovesLapTimer#48) bought back +~5 KB — the image sits at 99.4% of the cap. Every future feature refights +this. + +## Piece 2 (shipped): split the shared span evenly + +Before touching the apply path at all, the existing layout was simply +lopsided. App and staging share `[0x27000, 0xF4000)` = 839 680 B, and +**both** must be able to hold the image — the incoming one is staged up top, +then copied down over the app. So the largest installable image is half the +span. The split was 320 KiB staging against 500 KiB app, which capped OTA at +320 KiB while leaving ~180 KiB of app region no legal image could ever reach. + +Splitting evenly, rounded down to a 4 KiB erase page: + +``` +staging [0x8E000, 0xF4000) = 417 792 B = 408 KiB FW_MAX_IMAGE_SIZE +app [0x27000, 0x8E000) = 421 888 B = 412 KiB >= the cap +``` + +Two constants, plus `static_assert`s for page alignment and app-region fit. +No change to the apply sequence, the `FW*` protocol, the CRC, or the web app +(which never enforced a cap of its own — it relies on `FWERR:SIZE`). + +**Why this is safe for the existing fleet.** The staging base is read from +the *installed* firmware's constants at apply time; nothing about it is baked +into the image being delivered. A unit still on 3.0.x therefore stages at the +old `0xA4000` and installs a new-layout build normally. And because the new +app region ends at `0x8E000`, **below** the old `0xA4000` staging base, an +image built for this layout can never grow into an old unit's staging region. +Growing the cap is purely additive. + +**The one transitional constraint**: a unit on 3.0.x still enforces the old +320 KiB cap, so an image past that is un-installable *on that unit* until it +has taken a new-layout build (its fallback is the USB `FWDFU` → UF2 path). +CI warns on crossing the legacy cap for exactly this reason. Drop the warning +once the stragglers have migrated. + +## Piece 3 (spike-gated): stage from the SD card + +The image is **already fully staged and CRC-verified on the SD card** +(`/fw/pending.bin`) before it is ever copied to internal flash — the flash +staging region is a *second* copy that exists only because the RAM flasher +currently can't read SD. Teach the applier to read the image from SD and +the internal staging region disappears entirely: + +- App region grows from 512 KB to the full ~820 KB. +- `FWERR:SIZE` and the CI OTA gate stop being about staging and become + "fits the app region" (~2.5× today's image). +- The web-app protocol (`FW*`) is unchanged — same upload, same CRC, same + apply command. Only `fwStageToFlash()`/`fwRamFlasher()` change. + +### The hard part (why this is a spike) + +The apply runs with the SoftDevice disabled and interrupts off, from +RAM-resident code. Reading SD there means a **raw SPI + SD-protocol driver +in the RAM flasher** — no SdFat, no FAT walking at apply time. The planned +shape: + +1. **Pre-resolve the file before the destructive phase**: while SdFat is + still alive, walk `/fw/pending.bin`'s cluster chain and flatten it into + a sector-run list (start sector + count per contiguous run; a freshly + written file on a healthy card is usually 1–3 runs). CRC is already + verified at `FWDONE`; re-verify against the sector list before arming. +2. **RAM flasher loop**: raw single-block SD reads (CMD17 over + bit-banged-or-minimal SPI) from the sector list → NVMC program of the + app region, page by page. No filesystem logic at apply time. +3. **Recovery net unchanged**: GPREGRET OTA-DFU flag armed before the + erase; a failed/interrupted swap still lands in the bootloader's BLE + DFU. Additionally the UF2 path below is always available. + +### Hardware spikes required before field use (Phase-0 style) + +- Raw CMD17 reads with SoftDevice off / IRQs off: timing, card + re-init after SdFat is torn down, SPI clock choice (EMI posture applies — + apply happens parked, so the 8 MHz transfer clock rationale holds). +- Cards that stall mid-read (SD internal GC) vs. the WDT. +- Fragmented staging file behavior (worst-case sector-run list length). +- Power-loss matrix at each phase boundary. + +## The pre-update: `FWDFU` (ships NOW, master + BETA) + +The fleet insurance policy, shipped ahead of the rework so devices updated +*today* are ready for anything later: + +- New BLE command `FWDFU` → `FWDFU:OK` → reboot with GPREGRET `0x57` + (`DFU_MAGIC_UF2_RESET`). The **stock** Adafruit/Seeed bootloader then + enumerates as a USB mass-storage drive; copying a `.uf2` onto it flashes + the app region directly — **no size cap, no staging, no web app**. +- This makes every future migration (bigger images, the SD-direct rework, + even a bootloader swap someday) reachable with nothing but a USB cable: + command the device into UF2 mode over BLE, drag the file. +- The web app can later grow a "prepare for update" button that issues + `FWDFU` — deliberately out of scope now. +- Both channels already publish a per-board `.uf2` alongside the `.hex` and + the DFU `.zip` (`release.yml` / `beta.yml` convert the `.hex` with + `uf2conv` if the BSP didn't emit one), so there is always a file to drag. + No follow-up needed — an earlier draft of this plan said otherwise. +- A handful of sealed field units exist whose builders are out of contact; + any of them that takes this update once (via the current ≤320 KB web OTA + or nRF Connect) is permanently recoverable/updatable thereafter. + +## Sequencing + +1. ~~**Now**: `FWDFU` to `master` and `BETA` (this plan's PRs). Test on + BETA.~~ **Done** — merged both channels, flashed twice on beta clean. +2. ~~**Someday/never**: fall back to raising `FW_MAX_IMAGE_SIZE` + (staging-layout move).~~ **Done, and promoted out of "someday"** — piece 2 + above. It cost two constants and bought 88 KiB, so there was no reason to + hold it behind the spike. BETA first, then `master`. +3. ~~Publish a `.uf2` asset from `release.yml` / `beta.yml`.~~ **Already + done** — both workflows build a `.uf2` per board (converting the `.hex` + with `uf2conv` if the BSP didn't emit one) and stage it alongside the + `.hex` and `.zip`. An earlier draft of this plan claimed there was no + published file to drag after `FWDFU`; that was wrong. The escape hatch + is whole. +4. **Later — DDV upgrade-path mapping** (see below). Not on the critical + path for anything, but it is what makes a mixed fleet safe to operate. +5. **End of project — spike**: the four hardware validations above, on + bench hardware. +6. **End of project — implement**: SD-direct apply behind the spike + results; delete `FW_STAGE_BASE`/staging copy; CI OTA gate re-pointed at + the app-region bound. The options analysis lives in the PR #116 thread. + +With piece 2 shipped the pressure is off — 93 KiB of headroom against a beta +image that grew ~7.5 KiB for all of sprint mode. **Piece 3 is deliberately +parked until the end of the project**: it is the highest-risk change in the +OTA path (a raw SD driver running with the SoftDevice disabled, interrupts +off, from RAM, erasing the app region as it goes) and the headroom that +justified rushing it no longer needs rushing. Do it when the bench is free +and nothing else is in flight. + +## Follow-up: firmware upgrade-path mapping in DovesDataViewer + +Piece 2 introduced the first real instance of a general problem: **not every +firmware can install every other firmware.** A device on 3.0.x enforces the +old 320 KiB cap, so a >320 KiB build has to reach it over USB (`FWDFU` → +UF2) rather than over the air. Today that constraint lives only in a CI +warning and in this document — the web app will happily offer any release to +any device and let it fail at `FWBEGIN` with `FWERR:SIZE`. + +At the end of this project, DDV should gain an **upgrade-path map**: given +the device's reported firmware version (already available over DIS) and the +target release, decide whether the OTA path is valid, and if not, say so up +front and route the user to the USB/UF2 instructions instead of letting the +transfer fail late. + +Open — **scheme to be decided later.** Candidates worth weighing when we get +there: a per-release "minimum installed version for OTA" field in the +`gh-pages` manifest; an explicit stepping-stone chain (`3.0.x → 3.1.0 → +current`); or deriving it from the image size against a table of each +version's cap. The manifest field is probably the cheapest, since the +release workflow already publishes one — but the decision is deferred, not +made here. + +Prerequisite to check first: this only works if the historical builds are +actually still fetchable. **Believed yes — all master build versions look to +be retained** (GitHub Releases keep their assets indefinitely and every +version tag has one), but confirm before designing around it. Note the beta +channel is explicitly *latest-only* (`beta.yml` overwrites a flat `beta/` +path), so any stepping-stone chain can only be built from release builds. diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..3f0ec0f --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,55 @@ +# Plans — numbered design records + +This folder holds **design plans**: the internal thinking behind a chunk of +work — *how* it was built and, more importantly, *why*. They exist so we (and +AI agents) can recover the rationale behind a subsystem without burning tokens +re-researching the code. Read the relevant plan before changing the area it +covers. Same convention as DovesDataViewer's `docs/plans/` — the number doubles +as a lightweight "ticket number" across both repos' histories. + +## Naming — numbered, sequential + +Every plan is prefixed with a **zero-padded sequence number** and a short slug: + +``` +0000-firmware-ota-phase0.md +0001-insta360-ble-current-implementation.md +0002-sprint-mode.md +``` + +**To add a plan:** take the **next number after the highest one in this +folder** (don't reuse or backfill gaps), pick a short kebab-case slug, and +write `NNNN-slug.md`. The number is permanent — renaming would break +references, so it stays even if the slug later feels dated. + +Numbers are per-repo: this folder's `0002` and DovesDataViewer's `0002` are +unrelated. When a piece of work spans repos (like sprint mode), each repo gets +its own plan and they cross-link by full name. + +## Keeping plans current + +- **Update a plan while you execute it** — as decisions change, the plan + should reflect what was actually built, not just the original intent. +- **Only revisit an older plan later if you're working in code that references + it.** Don't sweep through and "refresh" plans speculatively; touch a plan + when its area is in play. + +## Commit messages must cite the plan number + +Any commit that is part of executing a plan **must reference the plan number** +in its message — `plan 0002:` as a prefix, or `(plan 0002)` inline. That's +what lets someone reading `git log` jump straight from a change back to the +reasoning behind it. + +## What a plan should contain + +No rigid template, but a good plan covers: +- **Goal / problem** — what we're solving and why it matters here. +- **Approach & key decisions** — the design chosen and the alternatives + rejected, with the *why*. This is the most valuable part. +- **Touch points** — the files/modules/subsystems involved. +- **Status / phasing** — what's done, what's pending, any follow-ups. + +Plans are referenced from `CLAUDE.md`, `ARCHITECTURE.md`, `CHANGELOG.md`, and +code comments — when you move or renumber one, update those references too +(and keep `CLAUDE.md`'s File Map in sync, per its maintainers note). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a8d1195..3d0385d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,10 @@ add_executable(birdseye_tests sd_format_page_test.cpp sat_bars_test.cpp sensoregg_protocol_test.cpp + sprint_select_test.cpp + crossing_pattern_test.cpp + course_creator_test.cpp + track_json_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp @@ -45,6 +49,10 @@ add_executable(birdseye_tests ${BIRDSEYE_DIR}/sd_format_page.cpp ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp + ${BIRDSEYE_DIR}/sprint_select.cpp + ${BIRDSEYE_DIR}/crossing_pattern.cpp + ${BIRDSEYE_DIR}/course_creator.cpp + ${BIRDSEYE_DIR}/track_json.cpp ) target_include_directories(birdseye_tests PRIVATE diff --git a/tests/course_creator_test.cpp b/tests/course_creator_test.cpp new file mode 100644 index 0000000..d75144e --- /dev/null +++ b/tests/course_creator_test.cpp @@ -0,0 +1,401 @@ +#include "doctest.h" + +#include + +#include + +#include "course_creator.h" + +using namespace course_creator; + +namespace { + +// Drive the flow up to the line menu for a given course type. +State atLineMenu(CourseKind kind, bool trackDetected = true) { + State s; + begin(s, trackDetected); + if (trackDetected) select(s, 0); // "Here" + select(s, kind == CourseKind::kSprint ? 1 : 0); // type + return s; +} + +// Index of a line's row on the line menu. +uint8_t rowOf(const State& s, LineId want) { + for (uint8_t i = 0; i < rowCount(s); i++) { + const RowRef r = rowAt(s, i); + if (r.row == Row::kLine && r.line == want) return i; + } + return 0; +} + +// Capture both endpoints of a line, the way the UI would. +void captureLine(State& s, LineId line, double lat, double lon) { + select(s, rowOf(s, line)); // open the line detail + uint32_t t = 1000; + for (uint8_t point = 0; point < 2; point++) { + select(s, point); // Point A / Point B + captureBegin(s, t); + for (uint16_t i = 0; i < kCaptureMinFixes; i++) { + captureAddFix(s, lat + point * 0.0001, lon, 1.0f, t + i); + } + t += kCaptureHoldMs; + REQUIRE(captureCommit(s, t)); + } + select(s, 2); // Save (commit the scratch line) +} + +} // namespace + +TEST_CASE("begin offers the track prompt only when a track is nearby") { + State s; + begin(s, true); + CHECK(s.screen == Screen::kTrackPrompt); + CHECK(s.trackChoiceOffered); + CHECK_FALSE(s.newTrack); + + // Nothing in range: there is no choice to make, so don't ask one. + begin(s, false); + CHECK(s.screen == Screen::kTypeSelect); + CHECK(s.newTrack); +} + +TEST_CASE("the track prompt records which track the course lands in") { + State s; + begin(s, true); + select(s, 0); + CHECK_FALSE(s.newTrack); + + begin(s, true); + select(s, 1); + CHECK(s.newTrack); + CHECK(s.screen == Screen::kTypeSelect); +} + +TEST_CASE("sprint gets a finish row, circuit does not") { + const State circuit = atLineMenu(CourseKind::kCircuit); + const State sprint = atLineMenu(CourseKind::kSprint); + + // 3 lines + Save + Cancel vs 4 lines + Save + Cancel. + CHECK(rowCount(circuit) == 5); + CHECK(rowCount(sprint) == 6); + + bool circuitHasFinish = false; + for (uint8_t i = 0; i < rowCount(circuit); i++) { + const RowRef r = rowAt(circuit, i); + if (r.row == Row::kLine && r.line == LineId::kFinish) circuitHasFinish = true; + } + CHECK_FALSE(circuitHasFinish); + + CHECK(rowAt(sprint, 3).row == Row::kLine); + CHECK(rowAt(sprint, 3).line == LineId::kFinish); + CHECK(rowAt(sprint, 4).row == Row::kSave); + CHECK(rowAt(sprint, 5).row == Row::kCancel); +} + +TEST_CASE("an out-of-range row clamps instead of wrapping") { + const State s = atLineMenu(CourseKind::kCircuit); + // A menuSelectionIndex left over from a longer menu must not be able to + // fire an action it was never pointing at — Cancel is the last row, and + // landing there is at worst a discard, never a bad save. + CHECK(rowAt(s, 99).row == Row::kCancel); +} + +TEST_CASE("the start line's label says whether it is also the finish") { + CHECK(std::string(lineLabel(LineId::kStart, CourseKind::kCircuit)) == "Start/Fin"); + CHECK(std::string(lineLabel(LineId::kStart, CourseKind::kSprint)) == "Start"); +} + +TEST_CASE("required lines depend on the course type") { + CHECK(lineRequired(LineId::kStart, CourseKind::kCircuit)); + CHECK(lineRequired(LineId::kStart, CourseKind::kSprint)); + CHECK(lineRequired(LineId::kFinish, CourseKind::kSprint)); + CHECK_FALSE(lineRequired(LineId::kFinish, CourseKind::kCircuit)); + CHECK_FALSE(lineRequired(LineId::kSector2, CourseKind::kSprint)); + CHECK_FALSE(lineRequired(LineId::kSector3, CourseKind::kCircuit)); +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +TEST_CASE("a circuit course needs its start/finish line") { + State s = atLineMenu(CourseKind::kCircuit); + CHECK(saveBlocked(s) == SaveBlock::kStartMissing); + CHECK_FALSE(canSave(s)); + + captureLine(s, LineId::kStart, 35.1, -97.1); + CHECK(saveBlocked(s) == SaveBlock::kNone); + CHECK(canSave(s)); +} + +TEST_CASE("circuit sectors are all-or-nothing") { + State s = atLineMenu(CourseKind::kCircuit); + captureLine(s, LineId::kStart, 35.1, -97.1); + + // One sector alone would load in the webapp but could never be saved + // there again — its validator wants zero sectors or exactly three majors. + captureLine(s, LineId::kSector2, 35.2, -97.2); + CHECK(saveBlocked(s) == SaveBlock::kSectorPair); + + captureLine(s, LineId::kSector3, 35.3, -97.3); + CHECK(saveBlocked(s) == SaveBlock::kNone); +} + +TEST_CASE("a half-captured circuit sector blocks the save too") { + State s = atLineMenu(CourseKind::kCircuit); + captureLine(s, LineId::kStart, 35.1, -97.1); + captureLine(s, LineId::kSector2, 35.2, -97.2); + captureLine(s, LineId::kSector3, 35.3, -97.3); + REQUIRE(canSave(s)); + + // Re-open sector 3, capture only point A, and save the line: the user + // believes they placed a line, so silently dropping it would be worse + // than refusing. + select(s, rowOf(s, LineId::kSector3)); + s.scratch = Line{}; + select(s, 0); // Point A + captureBegin(s, 5000); + for (uint16_t i = 0; i < kCaptureMinFixes; i++) captureAddFix(s, 35.4, -97.4, 1.0f, 5000 + i); + REQUIRE(captureCommit(s, 5000 + kCaptureHoldMs)); + select(s, 2); // Save the line + + CHECK(saveBlocked(s) == SaveBlock::kSectorPair); +} + +TEST_CASE("a sprint course needs a separate finish line") { + State s = atLineMenu(CourseKind::kSprint); + captureLine(s, LineId::kStart, 35.1, -97.1); + CHECK(saveBlocked(s) == SaveBlock::kFinishMissing); + + captureLine(s, LineId::kFinish, 35.5, -97.5); + CHECK(saveBlocked(s) == SaveBlock::kNone); +} + +TEST_CASE("sprint splits must be captured in order") { + State s = atLineMenu(CourseKind::kSprint); + captureLine(s, LineId::kStart, 35.1, -97.1); + captureLine(s, LineId::kFinish, 35.5, -97.5); + REQUIRE(canSave(s)); + + // The webapp stores splits as an ordered list and re-exports them + // positionally, so a lone sector 3 comes back as a sector 2. + captureLine(s, LineId::kSector3, 35.3, -97.3); + CHECK(saveBlocked(s) == SaveBlock::kSplitOrder); + + captureLine(s, LineId::kSector2, 35.2, -97.2); + CHECK(saveBlocked(s) == SaveBlock::kNone); +} + +TEST_CASE("one sprint split is legal on its own") { + State s = atLineMenu(CourseKind::kSprint); + captureLine(s, LineId::kStart, 35.1, -97.1); + captureLine(s, LineId::kFinish, 35.5, -97.5); + captureLine(s, LineId::kSector2, 35.2, -97.2); + CHECK(saveBlocked(s) == SaveBlock::kNone); +} + +// ─── Navigation ───────────────────────────────────────────────────────────── + +TEST_CASE("Save is refused while the course is incomplete") { + State s = atLineMenu(CourseKind::kSprint); + const uint8_t saveRow = 4; + REQUIRE(rowAt(s, saveRow).row == Row::kSave); + // Nothing captured — pressing Save must do nothing at all rather than + // write a course the device would later load and fail to time. + CHECK(select(s, saveRow) == Action::kNone); + + captureLine(s, LineId::kStart, 35.1, -97.1); + captureLine(s, LineId::kFinish, 35.5, -97.5); + CHECK(select(s, saveRow) == Action::kSaveCourse); +} + +TEST_CASE("Cancel exits the creator") { + State s = atLineMenu(CourseKind::kCircuit); + CHECK(select(s, 4) == Action::kExit); +} + +TEST_CASE("Back on a line discards the scratch edits") { + State s = atLineMenu(CourseKind::kCircuit); + captureLine(s, LineId::kStart, 35.1, -97.1); + const double committed = lineOf(s, LineId::kStart).aLat; + + select(s, rowOf(s, LineId::kStart)); + select(s, 0); // Point A + captureBegin(s, 9000); + for (uint16_t i = 0; i < kCaptureMinFixes; i++) captureAddFix(s, 40.0, -100.0, 1.0f, 9000 + i); + REQUIRE(captureCommit(s, 9000 + kCaptureHoldMs)); + select(s, 3); // Back + + CHECK(s.screen == Screen::kLineMenu); + CHECK(lineOf(s, LineId::kStart).aLat == doctest::Approx(committed)); +} + +TEST_CASE("opening a line seeds the scratch copy from what is committed") { + State s = atLineMenu(CourseKind::kCircuit); + captureLine(s, LineId::kStart, 35.1, -97.1); + + select(s, rowOf(s, LineId::kStart)); + // Re-opening a done line shows both points already captured, so a user + // can re-walk just one endpoint. + CHECK(s.scratch.hasA); + CHECK(s.scratch.hasB); + CHECK(s.scratch.aLat == doctest::Approx(35.1)); +} + +TEST_CASE("the capture screen targets the point that opened it") { + State s = atLineMenu(CourseKind::kSprint); + select(s, rowOf(s, LineId::kStart)); + + select(s, 0); + CHECK(s.screen == Screen::kPointCapture); + CHECK_FALSE(s.editingPointB); + + select(s, 1); // Back + select(s, 1); // Point B + CHECK(s.editingPointB); + CHECK(select(s, 0) == Action::kBeginCapture); +} + +// ─── Point capture ────────────────────────────────────────────────────────── + +TEST_CASE("a capture averages the fixes it collected") { + State s = atLineMenu(CourseKind::kCircuit); + select(s, rowOf(s, LineId::kStart)); + select(s, 0); + + captureBegin(s, 1000); + // Symmetric spread around 35.0 / -97.0 — the mean is the centre. + captureAddFix(s, 34.9, -97.1, 1.0f, 1010); + captureAddFix(s, 35.1, -96.9, 1.0f, 1020); + for (uint16_t i = 0; i < kCaptureMinFixes; i++) { + captureAddFix(s, 35.0, -97.0, 1.0f, 1100 + i); + } + + CHECK(capturePoll(s, 2000) == CaptureResult::kRunning); + CHECK(capturePoll(s, 1000 + kCaptureHoldMs) == CaptureResult::kDone); + REQUIRE(captureCommit(s, 1000 + kCaptureHoldMs)); + + CHECK(s.scratch.hasA); + CHECK(s.scratch.aLat == doctest::Approx(35.0)); + CHECK(s.scratch.aLon == doctest::Approx(-97.0)); + CHECK(s.screen == Screen::kLineDetail); +} + +TEST_CASE("fixes worse than the accuracy limit are dropped") { + State s; + begin(s, false); + captureBegin(s, 0); + + CHECK_FALSE(captureAddFix(s, 35.0, -97.0, kCaptureMaxHAccM + 0.1f, 10)); + CHECK_FALSE(captureAddFix(s, 35.0, -97.0, 0.0f, 20)); // no accuracy reported + CHECK(s.capture.fixes == 0); + CHECK(s.capture.rejected == 2); + + CHECK(captureAddFix(s, 35.0, -97.0, kCaptureMaxHAccM, 30)); + CHECK(s.capture.fixes == 1); +} + +TEST_CASE("a hold that gathers too few fixes fails instead of averaging noise") { + State s = atLineMenu(CourseKind::kCircuit); + select(s, rowOf(s, LineId::kStart)); + select(s, 0); + + captureBegin(s, 0); + for (uint16_t i = 0; i < kCaptureMinFixes - 1; i++) { + captureAddFix(s, 35.0, -97.0, 1.0f, i); + } + + CHECK(capturePoll(s, kCaptureHoldMs) == CaptureResult::kFailed); + CHECK_FALSE(captureCommit(s, kCaptureHoldMs)); + CHECK(s.captureFailed); + CHECK_FALSE(s.scratch.hasA); + // The hold is over, so the screen can offer a retry rather than sitting + // pinned at 100%. + CHECK_FALSE(s.capture.active); +} + +TEST_CASE("fixes arriving after the window do not shift the average") { + State s; + begin(s, false); + captureBegin(s, 0); + for (uint16_t i = 0; i < kCaptureMinFixes; i++) captureAddFix(s, 35.0, -97.0, 1.0f, i); + + CHECK_FALSE(captureAddFix(s, 80.0, 10.0, 1.0f, kCaptureHoldMs)); + CHECK(s.capture.fixes == kCaptureMinFixes); +} + +TEST_CASE("capture progress tracks elapsed time") { + State s; + begin(s, false); + CHECK(capturePercent(s, 0) == 0); // nothing running + + captureBegin(s, 1000); + CHECK(capturePercent(s, 1000) == 0); + CHECK(capturePercent(s, 1000 + kCaptureHoldMs / 2) == 50); + CHECK(capturePercent(s, 1000 + kCaptureHoldMs) == 100); + CHECK(capturePercent(s, 1000 + kCaptureHoldMs * 2) == 100); +} + +TEST_CASE("adding a fix with no hold running changes nothing") { + State s; + begin(s, false); + CHECK_FALSE(captureAddFix(s, 35.0, -97.0, 1.0f, 0)); + CHECK(capturePoll(s, 0) == CaptureResult::kIdle); +} + +TEST_CASE("Back on the capture screen abandons the hold") { + State s = atLineMenu(CourseKind::kCircuit); + select(s, rowOf(s, LineId::kStart)); + select(s, 0); + captureBegin(s, 0); + captureAddFix(s, 35.0, -97.0, 1.0f, 10); + + select(s, 1); // Back + CHECK(s.screen == Screen::kLineDetail); + CHECK_FALSE(s.capture.active); + CHECK(s.capture.fixes == 0); +} + +// ─── Name generation ──────────────────────────────────────────────────────── + +TEST_CASE("generated names fit the track browser without truncation") { + char name[kNameSize]; + REQUIRE(generatedName(name, sizeof(name), 26, 8, 3, 14, 32)); + CHECK(std::string(name) == "N260803_1432"); + // MAX_LOCATION_LENGTH is 13 including the NUL slot, which is exactly + // what this fits — the whole reason it isn't the spec's "NEWTRACK_". + CHECK(strlen(name) == 12); +} + +TEST_CASE("generated names zero-pad every field") { + char name[kNameSize]; + REQUIRE(generatedName(name, sizeof(name), 26, 1, 2, 3, 4)); + CHECK(std::string(name) == "N260102_0304"); +} + +TEST_CASE("a generated short name is exactly the webapp's 8-char budget") { + char shortName[kShortNameSize]; + REQUIRE(generatedShortName(shortName, sizeof(shortName), 8, 3, 14, 32)); + CHECK(std::string(shortName) == "08031432"); + // The webapp's device-sync merge keys on (kind, shortName), so two + // tracks walked the same day must not collide here. + CHECK(strlen(shortName) == 8); +} + +TEST_CASE("date_created is the sortable stamp sprint selection compares") { + char stamp[kDateCreatedSize]; + REQUIRE(generatedDateCreated(stamp, sizeof(stamp), 26, 8, 3, 14, 32)); + CHECK(std::string(stamp) == "2026-08-03T14:32"); + + // Lexicographic order must match chronological order — that is the whole + // contract sprint_select relies on. + char earlier[kDateCreatedSize]; + REQUIRE(generatedDateCreated(earlier, sizeof(earlier), 26, 8, 3, 9, 5)); + CHECK(std::string(earlier) < std::string(stamp)); +} + +TEST_CASE("name generation refuses a buffer that is too small") { + char tiny[4]; + CHECK_FALSE(generatedName(tiny, sizeof(tiny), 26, 8, 3, 14, 32)); + CHECK_FALSE(generatedShortName(tiny, sizeof(tiny), 8, 3, 14, 32)); + CHECK_FALSE(generatedDateCreated(tiny, sizeof(tiny), 26, 8, 3, 14, 32)); + CHECK_FALSE(generatedName(nullptr, 32, 26, 8, 3, 14, 32)); +} diff --git a/tests/crossing_pattern_test.cpp b/tests/crossing_pattern_test.cpp new file mode 100644 index 0000000..e065b9b --- /dev/null +++ b/tests/crossing_pattern_test.cpp @@ -0,0 +1,230 @@ +#include "doctest.h" +#include "crossing_pattern.h" + +#include +#include + +// --------------------------------------------------------------------------- +// Proof-of-equivalence test for the generated crossing animation. +// +// The two frames used to ship as hand-stored 1 KB PROGMEM bitmaps. They were +// deleted in favour of crossing_pattern::frameRects(); the original bytes are +// pinned below as goldens. Rasterizing the emitted rectangles must reproduce +// them exactly — every one of the 128x64 pixels, both frames — so the change +// is provably invisible on the device. +// +// Bit order matches Adafruit_GFX drawBitmap(): row-major, 16 bytes per row, +// MSB = leftmost pixel of each byte. +// --------------------------------------------------------------------------- + +// Golden: the original hand-stored image_data_calculating1[] bitmap (1024 B). +static const unsigned char kGoldenA[1024] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff +}; + +// Golden: the original hand-stored image_data_calculating2[] bitmap (1024 B). +static const unsigned char kGoldenB[1024] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x00 +}; + +namespace { + +// Rasterize one frame's rectangles into a 1024-byte 1bpp buffer. +void rasterize(bool flip, unsigned char* buf) { + std::memset(buf, 0, 1024); + crossing_pattern::Rect r[crossing_pattern::kMaxRects]; + const int n = crossing_pattern::frameRects(flip, r, crossing_pattern::kMaxRects); + for (int i = 0; i < n; i++) { + for (int y = r[i].y; y < r[i].y + r[i].h; y++) { + for (int x = r[i].x; x < r[i].x + r[i].w; x++) { + buf[y * 16 + x / 8] |= (unsigned char)(0x80 >> (x % 8)); + } + } + } +} + +} // namespace + +TEST_CASE("frameRects - flip=true reproduces the original frame 1 bitmap") { + unsigned char buf[1024]; + rasterize(true, buf); + CHECK(std::memcmp(buf, kGoldenA, 1024) == 0); +} + +TEST_CASE("frameRects - flip=false reproduces the original frame 2 bitmap") { + unsigned char buf[1024]; + rasterize(false, buf); + CHECK(std::memcmp(buf, kGoldenB, 1024) == 0); +} + +TEST_CASE("frameRects - emits 8 cells per frame, all 16x16, on the odd row bands") { + crossing_pattern::Rect r[crossing_pattern::kMaxRects]; + for (bool flip : {false, true}) { + const int n = crossing_pattern::frameRects(flip, r, crossing_pattern::kMaxRects); + CHECK(n == 8); + for (int i = 0; i < n; i++) { + CHECK(r[i].w == 16); + CHECK(r[i].h == 16); + // odd 16px row bands only: y = 16 or 48 + CHECK(((r[i].y / 16) % 2) == 1); + CHECK(r[i].x >= 0); + CHECK(r[i].x + r[i].w <= crossing_pattern::kWidth); + CHECK(r[i].y + r[i].h <= crossing_pattern::kHeight); + } + } +} + +TEST_CASE("frameRects - the two frames are column-disjoint (they alternate)") { + crossing_pattern::Rect a[8], b[8]; + crossing_pattern::frameRects(true, a, 8); + crossing_pattern::frameRects(false, b, 8); + for (int i = 0; i < 8; i++) { + for (int j = 0; j < 8; j++) { + const bool samePosition = (a[i].x == b[j].x) && (a[i].y == b[j].y); + CHECK_FALSE(samePosition); + } + } +} + +TEST_CASE("frameRects - respects a short output buffer and rejects bad input") { + crossing_pattern::Rect r[3]; + CHECK(crossing_pattern::frameRects(true, r, 3) == 3); + CHECK(crossing_pattern::frameRects(true, nullptr, 8) == 0); + CHECK(crossing_pattern::frameRects(true, r, 0) == 0); +} diff --git a/tests/dovex_header_test.cpp b/tests/dovex_header_test.cpp index fc05eaf..978fdf3 100644 --- a/tests/dovex_header_test.cpp +++ b/tests/dovex_header_test.cpp @@ -26,7 +26,8 @@ Metadata fixtureMeta() { "OKC", // shortName 62345UL, // bestLapMs 61890UL, // optimalMs - "ApexTurbo" // device + "ApexTurbo", // device + "CIRCUIT" // raceMode }; } @@ -59,7 +60,7 @@ TEST_CASE("format - line 1 is the column label with CRLF") { REQUIRE(dovex_header::format(buf, sizeof(buf), fixtureMeta(), laps, 1)); const std::string expected = - "datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name\r\n"; + "datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode\r\n"; CHECK(asString(buf, expected.size()) == expected); } @@ -70,7 +71,7 @@ TEST_CASE("format - line 2 fields appear in order") { // The metadata line follows line 1's CRLF and ends with its own CRLF. const std::string head = asString(buf, kHeaderSize); - CHECK(head.find("2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,ApexTurbo\r\n") + CHECK(head.find("2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,ApexTurbo,CIRCUIT\r\n") != std::string::npos); } @@ -84,7 +85,7 @@ TEST_CASE("format - bestLap / optimal of 0 become 'N/A'") { REQUIRE(dovex_header::format(buf, sizeof(buf), m, laps, 1)); const std::string head = asString(buf, kHeaderSize); - CHECK(head.find(",N/A,N/A,ApexTurbo\r\n") != std::string::npos); + CHECK(head.find(",N/A,N/A,ApexTurbo,CIRCUIT\r\n") != std::string::npos); } TEST_CASE("format - empty lap list still produces valid 1024-byte buffer") { @@ -201,7 +202,7 @@ TEST_CASE("format - null device renders an empty trailing column") { const std::string head = asString(buf, kHeaderSize); // Trailing comma then CRLF — the column exists but is empty. - CHECK(head.find("2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,\r\n") + CHECK(head.find("2025-03-11 14:30:00,Driver,Normal,OKC,62345,61890,,CIRCUIT\r\n") != std::string::npos); // And it round-trips back to an empty device. @@ -334,3 +335,81 @@ TEST_CASE("parse - skips zero-value lap tokens (matches firmware behavior)") { CHECK(readLaps[1] == 2000UL); CHECK(readLaps[2] == 3000UL); } + +// --------------------------------------------------------------------------- +// race_mode trailing column (sprint mode, plan 0002) +// --------------------------------------------------------------------------- + +TEST_CASE("format/parse - race_mode round-trips (SPRINT)") { + Metadata m = fixtureMeta(); + m.raceMode = "SPRINT"; + char buf[kHeaderSize]; + REQUIRE(dovex_header::format(buf, sizeof(buf), m, nullptr, 0)); + + ParsedHeader meta; + unsigned long readLaps[4]; + size_t readCount = 0; + REQUIRE(dovex_header::parse(buf, sizeof(buf), + meta, readLaps, 4, readCount)); + CHECK(std::string(meta.raceMode) == "SPRINT"); + CHECK(std::string(meta.device) == "ApexTurbo"); // column order held +} + +TEST_CASE("parse - legacy header without race_mode yields empty (= circuit)") { + // Headers written before the race_mode column existed: line 2 has + // only seven (or six) fields. raceMode must parse back as "". + char buf[kHeaderSize]; + std::memset(buf, '\n', sizeof(buf)); + const char* hdr = + "datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name\r\n" + "2025-03-11 14:30:00,Driver,Normal,OKC,58231,57900,ApexTurbo\r\n" + "laps_ms\r\n" + "58231\r\n"; + std::memcpy(buf, hdr, std::strlen(hdr)); + + ParsedHeader meta; + unsigned long readLaps[4]; + size_t readCount = 0; + REQUIRE(dovex_header::parse(buf, sizeof(buf), + meta, readLaps, 4, readCount)); + CHECK(std::string(meta.raceMode) == ""); + CHECK(std::string(meta.device) == "ApexTurbo"); +} + +TEST_CASE("format - null race_mode renders an empty trailing column") { + Metadata m = fixtureMeta(); + m.raceMode = nullptr; + char buf[kHeaderSize]; + REQUIRE(dovex_header::format(buf, sizeof(buf), m, nullptr, 0)); + + ParsedHeader meta; + unsigned long readLaps[4]; + size_t readCount = 0; + REQUIRE(dovex_header::parse(buf, sizeof(buf), + meta, readLaps, 4, readCount)); + CHECK(std::string(meta.raceMode) == ""); +} + +TEST_CASE("parse - empty middle field does not shift later columns") { + // Regression for the strtok-era splitter: an empty device_name used + // to swallow race_mode into the device column. Positional fields must + // survive empties. + char buf[kHeaderSize]; + std::memset(buf, '\n', sizeof(buf)); + const char* hdr = + "datetime,driver,course,short_name,best_lap_ms,optimal_ms,device_name,race_mode\r\n" + "2025-03-11 14:30:00,Driver,,OKC,1000,1000,,SPRINT\r\n" + "laps_ms\r\n" + "1000\r\n"; + std::memcpy(buf, hdr, std::strlen(hdr)); + + ParsedHeader meta; + unsigned long readLaps[4]; + size_t readCount = 0; + REQUIRE(dovex_header::parse(buf, sizeof(buf), + meta, readLaps, 4, readCount)); + CHECK(std::string(meta.course) == ""); // empty middle field + CHECK(std::string(meta.shortName)== "OKC"); // not shifted + CHECK(std::string(meta.device) == ""); // empty middle field + CHECK(std::string(meta.raceMode) == "SPRINT"); // not swallowed +} diff --git a/tests/gps_status_page_test.cpp b/tests/gps_status_page_test.cpp index 506b9b0..8ca9962 100644 --- a/tests/gps_status_page_test.cpp +++ b/tests/gps_status_page_test.cpp @@ -174,3 +174,42 @@ TEST_CASE("step - an armed lock countdown is never interrupted by idle") { CHECK(step(s, locked(kIdleTimeoutMs + 100)) == Exit::kStay); // past idle, still counting CHECK(step(s, locked(kIdleTimeoutMs - 5 + kAutoCloseMs)) == Exit::kToMenu); } + +// ─── timeSyncState ────────────────────────────────────────────────────────── +// +// A position fix and a usable clock are separate milestones. The page needs to +// name the outstanding one, because "no lock" on its own gave the user nothing +// to wait for — and the instinctive response (power-cycle) restarts the +// ~12.5-minute UTC decode being waited on. + +TEST_CASE("timeSyncState - no date/time yet is the earliest milestone") { + CHECK(timeSyncState(false, false) == TimeSync::kNoDateTime); +} + +TEST_CASE("timeSyncState - date/time present but UTC not resolved") { + // The common, slow case: a clean 3D fix minutes before fullyResolved. + CHECK(timeSyncState(true, false) == TimeSync::kResolving); +} + +TEST_CASE("timeSyncState - all three bits is locked") { + CHECK(timeSyncState(true, true) == TimeSync::kLocked); +} + +TEST_CASE("timeSyncState - resolved without date/time reports the earlier gap") { + // Not a state the module is expected to produce, but if it ever did, the + // page must not claim a lock it doesn't have. + CHECK(timeSyncState(false, true) == TimeSync::kNoDateTime); +} + +TEST_CASE("timeSyncState - kLocked matches exactly what gates timeValid") { + // gpsData.timeValid is validDate && validTime && fullyResolved, so the + // page's "locked" and the firmware's "safe to name a log file" must agree + // on every input — a page that says locked while logging still waits is + // the same confusion in a new place. + for (int i = 0; i < 4; i++) { + const bool dateTime = (i & 1) != 0; + const bool resolved = (i & 2) != 0; + const bool timeValid = dateTime && resolved; + CHECK((timeSyncState(dateTime, resolved) == TimeSync::kLocked) == timeValid); + } +} diff --git a/tests/sensoregg_protocol_test.cpp b/tests/sensoregg_protocol_test.cpp index ac81342..0dd1119 100644 --- a/tests/sensoregg_protocol_test.cpp +++ b/tests/sensoregg_protocol_test.cpp @@ -8,10 +8,12 @@ using namespace sensoregg_protocol; -// Golden PW-ADV-1 frame: the byte layout is the wire contract with the -// DovesSensorEgg firmware. If the layout changes, these vectors must -// change with it — deliberately. +// Golden frames: the byte layouts are the wire contract with the +// DovesSensorEgg firmware — the v2 bytes are IDENTICAL to the egg +// repo's pw_adv_encode golden fixture. If either layout changes, both +// repos' vectors must change with it — deliberately. // +// v1 (14 bytes): // FF FF company ID (SIG test/internal, inside the array) // 50 57 magic 'P' 'W' // 01 protocol version @@ -25,6 +27,12 @@ static const uint8_t kGoldenFrame[kPayloadLen] = { 0xFF, 0xFF, 0x50, 0x57, 0x01, 0x00, 0x64, 0x19, 0x4D, 0x01, 0x00, 0xFF, 0x2A, 0x01}; +// v2 (16 bytes): version 02, battery real (0x57 = 87%), and the aux +// thermistor appended: 0x00EA = 234 deci-degC = 23.4 C. +static const uint8_t kGoldenFrameV2[kPayloadLenV2] = { + 0xFF, 0xFF, 0x50, 0x57, 0x02, 0x00, 0x64, 0x19, + 0x4D, 0x01, 0x00, 0x57, 0x2A, 0x01, 0xEA, 0x00}; + // --------------------------------------------------------------------------- // Magic filter // --------------------------------------------------------------------------- @@ -57,7 +65,7 @@ TEST_CASE("sensoregg_protocol - magic rejects null and short input") { // Payload parse // --------------------------------------------------------------------------- -TEST_CASE("sensoregg_protocol - golden frame parses") { +TEST_CASE("sensoregg_protocol - golden v1 frame parses (aux is NaN)") { Reading r; REQUIRE(parsePayload(kGoldenFrame, sizeof(kGoldenFrame), r)); @@ -69,6 +77,34 @@ TEST_CASE("sensoregg_protocol - golden frame parses") { CHECK(r.status == 0); CHECK(r.battery == 0xFF); CHECK(r.sequence == 298); + // v1 has no aux field: it must parse as NaN, never 0.0 (a fake + // freezing-point reading on the intake page/log). + CHECK(std::isnan(r.auxC)); + CHECK(r.protoVersion == kProtocolVersion); +} + +TEST_CASE("sensoregg_protocol - golden v2 frame parses (egg-repo fixture bytes)") { + Reading r; + REQUIRE(parsePayload(kGoldenFrameV2, sizeof(kGoldenFrameV2), r)); + + CHECK(r.egtC == doctest::Approx(650.0f)); + CHECK(r.junctionC == doctest::Approx(33.3f)); + CHECK(r.auxC == doctest::Approx(23.4f)); + CHECK(r.battery == 87); + CHECK(r.sequence == 298); + CHECK(r.protoVersion == kProtocolVersionV2); +} + +TEST_CASE("sensoregg_protocol - v2 aux sentinel becomes NaN") { + uint8_t frame[kPayloadLenV2]; + for (size_t i = 0; i < kPayloadLenV2; i++) frame[i] = kGoldenFrameV2[i]; + frame[14] = 0x00; // 0x8000 LE: divider open/shorted on the egg + frame[15] = 0x80; + + Reading r; + REQUIRE(parsePayload(frame, sizeof(frame), r)); + CHECK(std::isnan(r.auxC)); + CHECK(r.egtC == doctest::Approx(650.0f)); // other fields unaffected } TEST_CASE("sensoregg_protocol - negative temperatures decode") { @@ -122,18 +158,35 @@ TEST_CASE("sensoregg_protocol - parse rejects short / null / bad version") { uint8_t frame[kPayloadLen]; for (size_t i = 0; i < kPayloadLen; i++) frame[i] = kGoldenFrame[i]; - frame[4] = 0x02; // future protocol version — layout unknown, reject + frame[4] = 0x03; // future protocol version — layout unknown, reject CHECK_FALSE(parsePayload(frame, sizeof(frame), r)); } -TEST_CASE("sensoregg_protocol - parse accepts longer-than-14 payloads") { - // Forward compatibility: a future egg may append fields. - uint8_t frame[kPayloadLen + 4] = {0}; - for (size_t i = 0; i < kPayloadLen; i++) frame[i] = kGoldenFrame[i]; +TEST_CASE("sensoregg_protocol - truncated v2 is corrupt, not v1") { + // A frame CLAIMING v2 but shorter than 16 bytes must be rejected — + // parsing its first 14 bytes as v1 would be accepting corruption. + uint8_t frame[kPayloadLenV2]; + for (size_t i = 0; i < kPayloadLenV2; i++) frame[i] = kGoldenFrameV2[i]; Reading r; - REQUIRE(parsePayload(frame, sizeof(frame), r)); + CHECK_FALSE(parsePayload(frame, kPayloadLen, r)); // 14 bytes + CHECK_FALSE(parsePayload(frame, kPayloadLenV2 - 1, r)); // 15 bytes + REQUIRE(parsePayload(frame, kPayloadLenV2, r)); // 16 is whole +} + +TEST_CASE("sensoregg_protocol - parse accepts longer-than-spec payloads") { + // Forward compatibility: a future egg may append fields to either + // version's layout. + uint8_t f1[kPayloadLen + 4] = {0}; + for (size_t i = 0; i < kPayloadLen; i++) f1[i] = kGoldenFrame[i]; + uint8_t f2[kPayloadLenV2 + 4] = {0}; + for (size_t i = 0; i < kPayloadLenV2; i++) f2[i] = kGoldenFrameV2[i]; + + Reading r; + REQUIRE(parsePayload(f1, sizeof(f1), r)); CHECK(r.egtC == doctest::Approx(650.0f)); + REQUIRE(parsePayload(f2, sizeof(f2), r)); + CHECK(r.auxC == doctest::Approx(23.4f)); } // --------------------------------------------------------------------------- @@ -252,10 +305,12 @@ TEST_CASE("sensoregg_protocol - scan tuning: invariants") { CHECK(kScanWindowUnits < kScanIntervalUnits); CHECK(kScanWindowUnits * 100 <= kScanIntervalUnits * 45); - // The interval must sit off the egg's ~100 ms adv interval (160 units) - // so the phases sweep instead of locking; at least a 5 ms offset keeps - // a deaf-zone park escaping within ~1 s (kStalenessMs). - constexpr uint16_t kEggAdvUnits = 160; + // The interval must sit off the egg's adv interval so the phases + // sweep instead of locking; at least a 5 ms offset keeps a deaf-zone + // park escaping within ~1 s (kStalenessMs). The egg de-aliased its + // interval to 179 units (111.875 ms) — this pin was stale at 160 + // until 2026-07-27. + constexpr uint16_t kEggAdvUnits = 179; uint16_t diff = kScanIntervalUnits > kEggAdvUnits ? kScanIntervalUnits - kEggAdvUnits : kEggAdvUnits - kScanIntervalUnits; diff --git a/tests/sprint_select_test.cpp b/tests/sprint_select_test.cpp new file mode 100644 index 0000000..7505144 --- /dev/null +++ b/tests/sprint_select_test.cpp @@ -0,0 +1,121 @@ +#include "doctest.h" +#include "sprint_select.h" + +using namespace sprint_select; + +// --------------------------------------------------------------------------- +// compareDateCreated — lexicographic ISO-8601, empty sorts oldest +// --------------------------------------------------------------------------- + +TEST_CASE("compareDateCreated - basic ordering") { + CHECK(compareDateCreated("2026-08-01T09:00", "2026-08-02T09:00") < 0); + CHECK(compareDateCreated("2026-08-02T09:00", "2026-08-01T09:00") > 0); + CHECK(compareDateCreated("2026-08-02T09:00", "2026-08-02T09:00") == 0); +} + +TEST_CASE("compareDateCreated - same-day morning vs afternoon relay") { + // The reason date_created carries a time: courses get re-laid same-day. + CHECK(compareDateCreated("2026-08-02T08:30", "2026-08-02T13:05") < 0); +} + +TEST_CASE("compareDateCreated - year/month boundaries") { + CHECK(compareDateCreated("2025-12-31T23:59", "2026-01-01T00:00") < 0); + CHECK(compareDateCreated("2026-09-30T12:00", "2026-10-01T12:00") < 0); +} + +TEST_CASE("compareDateCreated - empty and null sort oldest") { + CHECK(compareDateCreated("", "2026-08-02T09:00") < 0); + CHECK(compareDateCreated("2026-08-02T09:00", "") > 0); + CHECK(compareDateCreated(nullptr, "2026-08-02T09:00") < 0); + CHECK(compareDateCreated("", nullptr) == 0); + CHECK(compareDateCreated(nullptr, nullptr) == 0); +} + +TEST_CASE("compareDateCreated - date-only prefix still orders against timestamps") { + // A webapp that only wrote a date still sorts correctly vs a full + // timestamp on a different day; same-day it sorts before any timed + // entry, which is acceptable (the timed one is the deliberate relay). + CHECK(compareDateCreated("2026-08-01", "2026-08-02T00:00") < 0); + CHECK(compareDateCreated("2026-08-02", "2026-08-02T08:00") < 0); +} + +// --------------------------------------------------------------------------- +// newestCourseIndex — ties resolve to the LAST index +// --------------------------------------------------------------------------- + +TEST_CASE("newestCourseIndex - picks the newest") { + const char* dates[] = {"2026-07-12T09:00", "2026-08-02T08:30", "2026-07-26T10:00"}; + CHECK(newestCourseIndex(dates, 3) == 1); +} + +TEST_CASE("newestCourseIndex - weekly venue accumulation, newest last") { + const char* dates[] = {"2026-07-12T09:00", "2026-07-19T09:00", "2026-07-26T09:00", "2026-08-02T09:00"}; + CHECK(newestCourseIndex(dates, 4) == 3); +} + +TEST_CASE("newestCourseIndex - all-empty legacy file picks the last course") { + const char* dates[] = {"", "", ""}; + CHECK(newestCourseIndex(dates, 3) == 2); +} + +TEST_CASE("newestCourseIndex - tie on equal dates picks the later entry") { + const char* dates[] = {"2026-08-02T09:00", "2026-08-02T09:00"}; + CHECK(newestCourseIndex(dates, 2) == 1); +} + +TEST_CASE("newestCourseIndex - dated beats undated regardless of position") { + const char* dates[] = {"2026-08-02T09:00", "", ""}; + CHECK(newestCourseIndex(dates, 3) == 0); +} + +TEST_CASE("newestCourseIndex - degenerate inputs") { + const char* one[] = {"2026-08-02T09:00"}; + CHECK(newestCourseIndex(one, 1) == 0); + CHECK(newestCourseIndex(one, 0) == -1); + CHECK(newestCourseIndex(nullptr, 3) == -1); +} + +// --------------------------------------------------------------------------- +// isSameDay +// --------------------------------------------------------------------------- + +TEST_CASE("isSameDay - matches on the date prefix") { + CHECK(isSameDay("2026-08-02T08:30", "2026-08-02")); + CHECK(isSameDay("2026-08-02", "2026-08-02")); + CHECK_FALSE(isSameDay("2026-08-01T23:59", "2026-08-02")); +} + +TEST_CASE("isSameDay - malformed/short inputs are never today") { + CHECK_FALSE(isSameDay("", "2026-08-02")); + CHECK_FALSE(isSameDay(nullptr, "2026-08-02")); + CHECK_FALSE(isSameDay("2026-08-02", nullptr)); + CHECK_FALSE(isSameDay("2026-08", "2026-08-02")); +} + +// --------------------------------------------------------------------------- +// chooseKind — the mode tiebreak decision table (plan 0002 §7 Q1) +// --------------------------------------------------------------------------- + +TEST_CASE("chooseKind - single kind in range wins regardless of preference") { + CHECK(chooseKind(true, false, kPrefCircuit, false) == kCircuit); + CHECK(chooseKind(true, false, kPrefSprint, false) == kCircuit); + CHECK(chooseKind(false, true, kPrefCircuit, false) == kSprint); + CHECK(chooseKind(false, true, kPrefSprint, true) == kSprint); + // The event-day flag can't force sprint when no sprint track is near. + CHECK(chooseKind(true, false, kPrefCircuit, true) == kCircuit); +} + +TEST_CASE("chooseKind - both in range, circuit pref: sprint only on event day") { + CHECK(chooseKind(true, true, kPrefCircuit, false) == kCircuit); + CHECK(chooseKind(true, true, kPrefCircuit, true) == kSprint); +} + +TEST_CASE("chooseKind - both in range, sprint pref always wins (fixed rally course)") { + CHECK(chooseKind(true, true, kPrefSprint, false) == kSprint); + CHECK(chooseKind(true, true, kPrefSprint, true) == kSprint); +} + +TEST_CASE("chooseKind - neither in range defaults circuit (caller gates)") { + CHECK(chooseKind(false, false, kPrefCircuit, false) == kCircuit); + CHECK(chooseKind(false, false, kPrefSprint, false) == kCircuit); +} diff --git a/tests/tach_filter_test.cpp b/tests/tach_filter_test.cpp index f4c6ab3..0e79dbf 100644 --- a/tests/tach_filter_test.cpp +++ b/tests/tach_filter_test.cpp @@ -113,3 +113,102 @@ TEST_CASE("Kalman - tracks a ramp like a real engine pull") { CHECK(k.x > 6000.0f); CHECK(k.x < rpm); } + +// --------------------------------------------------------------------------- +// revsPerPulse — engine geometry (plan 0003) +// --------------------------------------------------------------------------- + +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("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("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("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("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)); +} + +// --------------------------------------------------------------------------- +// minPulseGapUs — debounce that keeps the true-RPM ceiling constant +// --------------------------------------------------------------------------- + +TEST_CASE("minPulseGapUs - unchanged for the historical single-cylinder case") { + CHECK(minPulseGapUs(1, true) == kBasePulseGapUs); +} + +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("minPulseGapUs - never goes below the floor") { + CHECK(minPulseGapUs(8, true) == kMinPulseGapFloorUs); + CHECK(minPulseGapUs(16, true) == kMinPulseGapFloorUs); +} + +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("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); +} + +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); +} diff --git a/tests/track_json_test.cpp b/tests/track_json_test.cpp new file mode 100644 index 0000000..d204fe0 --- /dev/null +++ b/tests/track_json_test.cpp @@ -0,0 +1,227 @@ +#include "doctest.h" + +#include + +#include + +#include "course_creator.h" +#include "track_json.h" + +using namespace track_json; +using course_creator::CourseKind; +using course_creator::LineId; + +namespace { + +// A course with the given lines already captured — bypasses the UI flow, +// which course_creator_test covers. +course_creator::State makeCourse(CourseKind kind) { + course_creator::State s; + s.kind = kind; + return s; +} + +void setLine(course_creator::State& s, LineId id, + double aLat, double aLon, double bLat, double bLon) { + course_creator::Line& l = s.lines[static_cast(id)]; + l.aLat = aLat; + l.aLon = aLon; + l.bLat = bLat; + l.bLon = bLon; + l.hasA = true; + l.hasB = true; +} + +std::string course(const course_creator::State& s, const char* name, const char* date = "") { + char buf[1024]; + const int n = formatCourse(buf, sizeof(buf), s, name, date); + REQUIRE(n > 0); + CHECK(strlen(buf) == static_cast(n)); + return std::string(buf); +} + +} // namespace + +// ─── formatFixed ──────────────────────────────────────────────────────────── + +TEST_CASE("formatFixed writes a plain fixed-point decimal") { + char buf[kCoordSize]; + REQUIRE(formatFixed(buf, sizeof(buf), 28.41270817, 8) > 0); + CHECK(std::string(buf) == "28.41270817"); + + REQUIRE(formatFixed(buf, sizeof(buf), -97.12345678, 8) > 0); + CHECK(std::string(buf) == "-97.12345678"); +} + +TEST_CASE("formatFixed zero-pads the fraction") { + char buf[kCoordSize]; + // The leading zeros of the fraction are load-bearing: "35.00000001" + // written as "35.1" is 11 km away. + REQUIRE(formatFixed(buf, sizeof(buf), 35.00000001, 8) > 0); + CHECK(std::string(buf) == "35.00000001"); + + REQUIRE(formatFixed(buf, sizeof(buf), 35.0, 8) > 0); + CHECK(std::string(buf) == "35.00000000"); +} + +TEST_CASE("formatFixed handles zero and small negatives") { + char buf[kCoordSize]; + REQUIRE(formatFixed(buf, sizeof(buf), 0.0, 8) > 0); + CHECK(std::string(buf) == "0.00000000"); + + // A value that rounds to zero must not come out as "-0.00000000" — a + // reader would take the sign at face value. + REQUIRE(formatFixed(buf, sizeof(buf), -0.000000001, 8) > 0); + CHECK(std::string(buf) == "0.00000000"); + + REQUIRE(formatFixed(buf, sizeof(buf), -0.5, 8) > 0); + CHECK(std::string(buf) == "-0.50000000"); +} + +TEST_CASE("formatFixed rounds half away from zero") { + // 1.125 is exactly representable in binary, so this really is the + // halfway case. Don't reach for something like 1.005 — that literal is + // actually 1.00499999... and would be testing the double, not the + // rounding. + char buf[kCoordSize]; + REQUIRE(formatFixed(buf, sizeof(buf), 1.125, 2) > 0); + CHECK(std::string(buf) == "1.13"); + REQUIRE(formatFixed(buf, sizeof(buf), -1.125, 2) > 0); + CHECK(std::string(buf) == "-1.13"); +} + +TEST_CASE("formatFixed keeps full precision at the coordinate extremes") { + char buf[kCoordSize]; + REQUIRE(formatFixed(buf, sizeof(buf), -179.99999999, 8) > 0); + CHECK(std::string(buf) == "-179.99999999"); + REQUIRE(formatFixed(buf, sizeof(buf), 89.12345678, 8) > 0); + CHECK(std::string(buf) == "89.12345678"); +} + +TEST_CASE("formatFixed reports a buffer that is too small") { + char tiny[4]; + CHECK(formatFixed(tiny, sizeof(tiny), 28.41270817, 8) == -1); + CHECK(formatFixed(nullptr, 32, 1.0, 8) == -1); + CHECK(formatFixed(tiny, 0, 1.0, 8) == -1); +} + +// ─── formatCourse ─────────────────────────────────────────────────────────── + +TEST_CASE("a minimal circuit course emits just its start/finish line") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 35.1, -97.1, 35.2, -97.2); + + CHECK(course(s, "N260803_1432") == + "{\"name\":\"N260803_1432\"," + "\"start_a_lat\":35.10000000,\"start_a_lng\":-97.10000000," + "\"start_b_lat\":35.20000000,\"start_b_lng\":-97.20000000}"); +} + +TEST_CASE("captured sectors are emitted, uncaptured ones are absent") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + setLine(s, LineId::kSector2, 5.0, 6.0, 7.0, 8.0); + setLine(s, LineId::kSector3, 9.0, 10.0, 11.0, 12.0); + + const std::string json = course(s, "C"); + CHECK(json.find("\"sector_2_a_lat\":5.00000000") != std::string::npos); + CHECK(json.find("\"sector_3_b_lng\":12.00000000") != std::string::npos); + + // parseTrackFile() probes for these keys with containsKey(), so an + // absent line and a line of zeroes mean very different things. + course_creator::State bare = makeCourse(CourseKind::kCircuit); + setLine(bare, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + CHECK(course(bare, "C").find("sector_2") == std::string::npos); + CHECK(course(bare, "C").find("sector_3") == std::string::npos); +} + +TEST_CASE("a half-captured line is not emitted") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + s.lines[static_cast(LineId::kSector2)].hasA = true; // point B never taken + + CHECK(course(s, "C").find("sector_2") == std::string::npos); +} + +TEST_CASE("a sprint course carries its finish line and date_created") { + course_creator::State s = makeCourse(CourseKind::kSprint); + setLine(s, LineId::kStart, 35.1, -97.1, 35.2, -97.2); + setLine(s, LineId::kFinish, 36.1, -98.1, 36.2, -98.2); + + const std::string json = course(s, "N260803_1432", "2026-08-03T14:32"); + CHECK(json.find("\"finish_a_lat\":36.10000000") != std::string::npos); + CHECK(json.find("\"finish_b_lng\":-98.20000000") != std::string::npos); + CHECK(json.find("\"date_created\":\"2026-08-03T14:32\"") != std::string::npos); +} + +TEST_CASE("a circuit course carries neither a finish line nor date_created") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + // Populated but must not be written: finish is meaningless on a circuit, + // and the webapp documents Course.dateCreated as sprint-only. + setLine(s, LineId::kFinish, 9.0, 9.0, 9.0, 9.0); + + const std::string json = course(s, "C", "2026-08-03T14:32"); + CHECK(json.find("finish") == std::string::npos); + CHECK(json.find("date_created") == std::string::npos); +} + +TEST_CASE("an empty date_created is omitted rather than written blank") { + course_creator::State s = makeCourse(CourseKind::kSprint); + setLine(s, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + setLine(s, LineId::kFinish, 5.0, 6.0, 7.0, 8.0); + CHECK(course(s, "S", "").find("date_created") == std::string::npos); +} + +TEST_CASE("formatCourse reports a buffer that is too small") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 35.1, -97.1, 35.2, -97.2); + + char buf[40]; + // A truncated track file is worse than no file — the device would load + // it and fail to parse, so the caller has to be told. + CHECK(formatCourse(buf, sizeof(buf), s, "N260803_1432", "") == -1); +} + +// ─── formatTrackFile ──────────────────────────────────────────────────────── + +TEST_CASE("a new circuit track file is a complete object with one course") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 35.1, -97.1, 35.2, -97.2); + + char buf[1024]; + const int n = formatTrackFile(buf, sizeof(buf), "N260803_1432", "08031432", + s, "N260803_1432", ""); + REQUIRE(n > 0); + CHECK(std::string(buf) == + "{\"longName\":\"N260803_1432\",\"shortName\":\"08031432\"," + "\"defaultCourse\":\"N260803_1432\",\"courses\":[" + "{\"name\":\"N260803_1432\"," + "\"start_a_lat\":35.10000000,\"start_a_lng\":-97.10000000," + "\"start_b_lat\":35.20000000,\"start_b_lng\":-97.20000000}]}"); +} + +TEST_CASE("a new sprint track file declares its type") { + course_creator::State s = makeCourse(CourseKind::kSprint); + setLine(s, LineId::kStart, 1.0, 2.0, 3.0, 4.0); + setLine(s, LineId::kFinish, 5.0, 6.0, 7.0, 8.0); + + char buf[1024]; + REQUIRE(formatTrackFile(buf, sizeof(buf), "N260803_1432", "08031432", + s, "N260803_1432", "2026-08-03T14:32") > 0); + const std::string json(buf); + // Redundant with the folder (which is authoritative), but it means a + // file moved by hand still says what it is. + CHECK(json.find("\"type\":\"sprint\"") != std::string::npos); + CHECK(json.find("\"courses\":[{") != std::string::npos); + CHECK(json.substr(json.size() - 2) == "]}"); +} + +TEST_CASE("formatTrackFile reports a buffer that is too small") { + course_creator::State s = makeCourse(CourseKind::kCircuit); + setLine(s, LineId::kStart, 35.1, -97.1, 35.2, -97.2); + + char buf[64]; + CHECK(formatTrackFile(buf, sizeof(buf), "N260803_1432", "08031432", + s, "N260803_1432", "") == -1); +} diff --git a/tests/wake_cause_test.cpp b/tests/wake_cause_test.cpp index 1f84958..65235fc 100644 --- a/tests/wake_cause_test.cpp +++ b/tests/wake_cause_test.cpp @@ -81,3 +81,23 @@ TEST_CASE("decode - stale latch without the OFF bit is ignored") { CHECK(decode({0, 1u << 2, 0}, kPins) == Cause::kColdBoot); CHECK(decode({kReasSreq, (1u << 2) | (1u << 3), 0}, kPins) == Cause::kSoftReset); } + +// --------------------------------------------------------------------------- +// Shutdown-side tach SENSE polarity vote +// --------------------------------------------------------------------------- + +TEST_CASE("tachIdleIsHigh - clear majorities decide the idle level") { + CHECK(tachIdleIsHigh(15, 15)); // idle-high circuit -> SENSE-LOW + CHECK(tachIdleIsHigh(14, 15)); + CHECK_FALSE(tachIdleIsHigh(0, 15)); // idle-low circuit -> SENSE-HIGH + CHECK_FALSE(tachIdleIsHigh(1, 15)); // one noise blip doesn't flip it + CHECK_FALSE(tachIdleIsHigh(7, 15)); // strict minority stays low + CHECK(tachIdleIsHigh(8, 15)); +} + +TEST_CASE("tachIdleIsHigh - ties and empty bursts default to idle-high") { + // The pull-up reads a floating/disconnected tach input as high, and + // idle-high preserves the historical SENSE-LOW arm. + CHECK(tachIdleIsHigh(5, 10)); + CHECK(tachIdleIsHigh(0, 0)); +}