From 89cd540f4b866fdf903d615734438eaeadc908e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 21:58:28 +0000 Subject: [PATCH 01/36] SensorEgg PW-ADV v2: Temp2 page, DOVEX Temp2 column, -Ofast isnan fix The egg now broadcasts a 16-byte v2 payload (aux intake-air thermistor appended at bytes 14-15, real battery percent in byte 11, version 0x02). This brings the logger up to speed while keeping v1 eggs working. Parser (sensoregg_protocol, host-tested): - Accepts v1 AND v2; v1 parses auxC as NaN, so a mixed fleet works. - A frame claiming v2 but shorter than 16 bytes is corrupt, not v1 - rejected rather than mis-parsed. - Reading gains auxC + protoVersion; battery is documented real. - New golden v2 fixture is byte-identical to the egg repo's pw_adv_encode fixture - the wire contract pinned from both ends. - The scan-tuning test's pinned egg adv interval was stale at 160 units; the egg de-aliased to 179 (111.875 ms) - pin updated, invariants still hold. RX path (sensoregg.ino): - The double-buffer captured a fixed 14 bytes, so v2's bytes 14-15 never reached the parser. Buffers now size kPayloadLenMax with per-slot lengths. - New accessors with the same staleness/zombie gating as the EGT: sensoreggAuxC() (NaN when stale/hung/v1/sentinel) and sensoreggBatteryPct() (0xFF when unknown). Flag-off stubs and sim module_stubs updated in lockstep. Display: - New Temp2 race page after Temp1 (page constants shifted inside the BIRDSEYE_ENABLE_SENSOREGG arm only): same big-number layout and staleness rules, subtext shows the egg battery percent ('--' when unknown) since the thermistor has no cold junction. DOVEX: - Temp2 trailing column appended (same backwards-compatible mechanism as device_name and Temp1/Junction1): aux temp in C, literal "nan" on stale/v1/invalid, never skips a GPS row, written on every build so the format does not fork by channel. Sim oracle still parses the hardware-recorded fixture (first 13 columns positional - unaffected). Fixed - isnan() compiled out by -Ofast: - The platform builds sketches with -Ofast (-ffinite-math-only), which constant-folds isnan() to false. The Temp1 page rendered lroundf(NaN) garbage ("-214748") instead of '---' on a stale link; the DOVEX temp columns survived only because dtostrf(NaN) emits a string the numeric guard rejects into the same "nan" fallback. Egg paths now use isNanF() (nan_bits.h, IEEE-754 bit-pattern check the optimizer cannot fold) - same fix the egg firmware shipped. Docs: CHANGELOG under [Unreleased] (MINOR - backwards-compatible column append), CLAUDE.md subsystem/file-map/format sections, ARCHITECTURE.md subsystem bullet, README data-format section (which was also missing the existing Temp1/Junction1 columns - now current). Verified: host suite green (v2 golden, truncation gates, v1 compat); sim builds and all 6 sim tests pass (goldens unchanged - boot/menu pages only; oracle parses the recorded .dovex fixture); compiles for xiaonRF52840Sense with the BETA flag set and with defaults. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M4WQVFAkryxYqTNmniVL7i --- ARCHITECTURE.md | 22 +- BirdsEye/BirdsEye.ino | 3577 ++++++++++++++------------- BirdsEye/display_pages.h | 3 +- BirdsEye/display_pages.ino | 2396 +++++++++--------- BirdsEye/display_ui.ino | 1722 ++++++------- BirdsEye/gps_functions.ino | 1835 +++++++------- BirdsEye/nan_bits.h | 32 + BirdsEye/sensoregg.h | 18 +- BirdsEye/sensoregg.ino | 38 +- BirdsEye/sensoregg_protocol.cpp | 11 +- BirdsEye/sensoregg_protocol.h | 51 +- BirdsEye/sim/sim_prototypes.h | 1 + BirdsEye/sim/stubs/module_stubs.cpp | 2 + CHANGELOG.md | 32 + CLAUDE.md | 2514 +++++++++---------- README.md | 978 ++++---- tests/sensoregg_protocol_test.cpp | 83 +- 17 files changed, 6784 insertions(+), 6531 deletions(-) create mode 100644 BirdsEye/nan_bits.h diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b772d6a..1b51f1d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,16 +97,18 @@ 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. - **Replay** (`replay`) — instant DOVEX header replay. - **Settings** (`settings`) — JSON key/value store on the SD card. - **CourseManager** (external library) — owns course detection, sector diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 4c60c4d..92b1bef 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -1,1788 +1,1789 @@ -/////////////////////////////////////////// -// 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 + +// 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 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 < 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 +} diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 232f8e4..1d336c4 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -39,7 +39,8 @@ 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..0d62e2c 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -1,1175 +1,1221 @@ -/////////////////////////////////////////// -// 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 "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) { + 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 (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 (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); + // 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 + // 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(); +} diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 62125e8..9cdcc9c 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -1,860 +1,862 @@ -/////////////////////////////////////////// -// 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) { + 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(); + } 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 + ) { + 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); + } + } + } +} diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index add230b..0b47417 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -1,910 +1,925 @@ -/////////////////////////////////////////// -// 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.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) + + // 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/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/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/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index f0496b0..7abaae9 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -89,6 +89,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(); 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/CHANGELOG.md b/CHANGELOG.md index e4b52fc..ccd22a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,39 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **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 +- **`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 +- 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). diff --git a/CLAUDE.md b/CLAUDE.md index 5d97d11..afe894b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1256 +1,1258 @@ -# 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 (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` 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 | +| `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,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`** 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` / `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). + +### 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). diff --git a/README.md b/README.md index a787871..8c2fc87 100644 --- a/README.md +++ b/README.md @@ -1,488 +1,490 @@ -# 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" +} +``` + +| 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,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/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; From 1b9601a083a4819d27616ae6727ba8ff65559cb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 04:44:05 +0000 Subject: [PATCH 02/36] docs: sprint mode (autocross) concept + cross-repo roadmap Concept/research doc for a new point-to-point 'sprint' race mode (separate start and finish lines, runs instead of laps) alongside the existing circuit behavior: race_mode device setting, /TRACKS/SPRINT folder, SprintTimer in DovesLapTimer BETA, firmware session-lifecycle changes, DataViewer work last. No code changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/sprint-mode-concept.md | 202 ++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/sprint-mode-concept.md diff --git a/docs/sprint-mode-concept.md b/docs/sprint-mode-concept.md new file mode 100644 index 0000000..a9434c4 --- /dev/null +++ b/docs/sprint-mode-concept.md @@ -0,0 +1,202 @@ +# Sprint Mode (Autocross / Point-to-Point) — Concept & Cross-Repo Roadmap + +> Status: **CONCEPT** — research + direction only, no implementation yet. +> Scope spans three repos; each phase lands on that repo's beta branch +> (DovesLapTimer `BETA` → DovesDataLogger `BETA` → DovesDataViewer, last). + +## 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 | `race_mode=sprint` setting, set via webapp `SSET`, applied on reboot (the existing settings contract — BLE disconnect auto-reboots) | +| 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** — single course, or webapp-selected `defaultCourse` when a track has several | +| 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 (Lap Anything is a lap timer; it produces nothing useful point-to-point) | +| Session | one `.dovex`, laps line in header | one `.dovex` for the whole event, runs line in header | + +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**: per-run state machine + `ARMED → RUNNING → FINISHED`, re-arming when the driver returns to the + start-line zone. Surface: run count, current run time, last run, best + run (+ run number), run history. No `laps`, no `DirectionDetector` + (direction is meaningless point-to-point), no S/F-restarts-sector-1 + entanglement. +- **Course model**: add `finish_a/b_lat/lng` to the sprint course config. + Sectors become **N ordered split lines** between start and finish rather + than the circuit's all-or-nothing S2+S3 pair + (`areSectorLinesConfigured()` requires *both* today — a single + mid-course split is currently impossible; the webapp's data model + already carries an ordered `sectors[]` list, so the library is the + bottleneck, not the data). +- **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 (webapp-chosen + `defaultCourse`); 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 + +- **`race_mode` setting**: default row in `ensureDefaultSettings()` + (`settings.ino:102-108`), loaded into a global in the settings block at + `BirdsEye.ino:765-795`. `SSET` is already generic (no key allowlist), so + the webapp can set it the moment the firmware reads it. Update the + settings tables in README.md and CLAUDE.md. +- **`/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 — the field-visible blocker**: + - Sprint-aware `checkAutoIdle()`: engine-aware idle (only start the + idle clock when tach reads 0 *and* stationary), a much longer window, + and re-arm the grace period at every run completion (today the 3-min + grace anchors once at session start and never re-arms). 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**: add a trailing `race_mode` column to header line 1 (the + format has an explicit trailing-column back-compat story — + `device_name` was added the same way, `dovex_header.h:21-23`); the lap + times line doubles as the run times line. Old readers ignore the extra + column; old files parse as circuit. Extend `tests/dovex_header_test.cpp`. +- **Display**: reuse existing page IDs with mode-conditional labels + (Lap → Run, Lap History → Run List, Best Lap → Best Run). The ordered + page-constant blocks with their `ENDURANCE_MODE` / `SENSOREGG` + reshuffles are the most fragile part of the UI — avoid new page + constants. + +## 5. 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. + +- **Settings UI**: `race_mode` auto-appears via the generic key/value list, + but a real toggle needs a new `boolean`/`enum` control type in + `deviceSettingsSchema.ts` + `DeviceSettingsTab.tsx` (today only + string/number text inputs exist). Update `docs/ble-protocol.md` §8.5. +- **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. + +## 6. Open Questions + +1. **Is `race_mode` really a device mode?** Alternative: mode follows the + detected track (which folder the nearest manifest entry came from), and + the setting is only a tiebreak when a venue has both kinds nearby. + Fewer webapp round-trips; slightly spookier behavior. +2. **Minimum sector support for v1** — none (start + finish only) ships + faster; N ordered splits is where the library should end up. +3. **Run arming semantics** — auto-arm on entering the start-line zone vs + a speed/launch threshold? What does a false start / DNF (never crosses + finish, returns to start) record as? +4. **Between-run state** — best run, run history, and the log file must + survive the loop-back and the wait (they will, as long as sessions + don't end between runs — see Phase 2 lifecycle). +5. **Replay page** — header `race_mode` column makes the replay results + page label runs vs laps correctly; anything more (per-run sectors) is + later. From cb018ece3914f19161b4ea0ff2dd52a5f245a701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:47:32 +0000 Subject: [PATCH 03/36] Fix battery-sleep reboot loop: sample tach idle level before arming SENSE System OFF entry hardcoded the tach wake pin as SENSE-LOW, assuming the line idles high under the pull-up. The tach pickup's Schmitt-inverter + optocoupler output stage can idle low, in which case DETECT is satisfied the instant System OFF is entered and the device wake-resets within a second whenever it sleeps on battery. USB sleep was unaffected because a present cable parks in the charging loop and never enters System OFF, and runtime RPM counting can't expose the polarity (a spark pulse yields exactly one falling edge either way), so only battery sleep showed it. Shutdown now samples the parked tach line (15 reads over ~30 ms) and arms SENSE for the opposite of the observed idle level. The majority vote lives in the host-tested wake_cause unit (tachIdleIsHigh); a tie or floating input resolves to idle-high, preserving the original SENSE-LOW arm. Buttons stay fixed SENSE-LOW (active-low by wiring). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BNr2KMMP1yjhPgdaUXHmGt --- ARCHITECTURE.md | 7 ++++++- BirdsEye/BirdsEye.ino | 29 ++++++++++++++++++++++++----- BirdsEye/wake_cause.cpp | 6 ++++++ BirdsEye/wake_cause.h | 14 ++++++++++++++ CHANGELOG.md | 11 +++++++++++ CLAUDE.md | 19 ++++++++++++++----- tests/wake_cause_test.cpp | 20 ++++++++++++++++++++ 7 files changed, 95 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b51f1d..828b41a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -183,7 +183,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 92b1bef..21f2731 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -1438,11 +1438,30 @@ static void shutdownSystemOff() { 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); + // 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], 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 ccd22a8..84125f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 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)` diff --git a/CLAUDE.md b/CLAUDE.md index afe894b..0271997 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -597,17 +597,26 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. 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 + 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 falling, engine start), any button, - or VBUS (USB plug-in, always armed on nRF52840). +- **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 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)); +} From 69773b106fbbdf5e859bd150d3a476d6e541b051 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:13:03 +0000 Subject: [PATCH 04/36] docs: fold real autocross cadence into sprint concept User report: heats of ~4 runs with 30-45 s queue stops, long break between heats, X4 left recording per heat (~20 min videos). Pins the session model to session = heat, matching the existing camera 30 s engine-off auto-stop; run boundaries never touch the camera or end the session. Auto-idle change downgraded to a safety margin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/sprint-mode-concept.md | 54 ++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/sprint-mode-concept.md b/docs/sprint-mode-concept.md index a9434c4..e58423e 100644 --- a/docs/sprint-mode-concept.md +++ b/docs/sprint-mode-concept.md @@ -43,6 +43,33 @@ tracks stored separately so everything existing stays backwards compatible. | Fallback | Lap Anything (`WaypointLapTimer`) | none meaningful — no matched sprint track ⇒ log-only session (Lap Anything is a lap timer; it produces nothing useful point-to-point) | | Session | one `.dovex`, laps line in header | one `.dovex` for the whole event, runs line in header | +### 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: + +- **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 @@ -131,13 +158,18 @@ track the library's BETA branch in CI, so co-development is wired). (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 — the field-visible blocker**: - - Sprint-aware `checkAutoIdle()`: engine-aware idle (only start the - idle clock when tach reads 0 *and* stationary), a much longer window, - and re-arm the grace period at every run completion (today the 3-min - grace anchors once at session start and never re-arms). The - camera-recording yield at `BirdsEye.ino:1158` is the precedent for - "something else says we're still active". +- **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). @@ -195,8 +227,12 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. a speed/launch threshold? What does a false start / DNF (never crosses finish, returns to start) record as? 4. **Between-run state** — best run, run history, and the log file must - survive the loop-back and the wait (they will, as long as sessions - don't end between runs — see Phase 2 lifecycle). + survive the loop-back and the ~30–45 s queue wait (they will, as long + as sessions don't end between runs — see Phase 2 lifecycle). + Cross-heat: does "best run" span the whole day or reset per heat/ + session? Per-session is what falls out naturally (one `.dovex` per + heat); a day-spanning best would need header aggregation in the + viewer, not firmware state. 5. **Replay page** — header `race_mode` column makes the replay results page label runs vs laps correctly; anything more (per-run sectors) is later. From 0b172c093ebb422055581811df0845b8b3b55df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:34:12 +0000 Subject: [PATCH 05/36] =?UTF-8?q?docs:=20sprint=20concept=20=E2=80=94=20en?= =?UTF-8?q?gine-on-between-runs=20confirmed,=20best=20run=20is=20per-sessi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/sprint-mode-concept.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/sprint-mode-concept.md b/docs/sprint-mode-concept.md index e58423e..f6751fe 100644 --- a/docs/sprint-mode-concept.md +++ b/docs/sprint-mode-concept.md @@ -53,7 +53,9 @@ 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: +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 @@ -229,10 +231,9 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. 4. **Between-run state** — best run, run history, and the log file must survive the loop-back and the ~30–45 s queue wait (they will, as long as sessions don't end between runs — see Phase 2 lifecycle). - Cross-heat: does "best run" span the whole day or reset per heat/ - session? Per-session is what falls out naturally (one `.dovex` per - heat); a day-spanning best would need header aggregation in the - viewer, not firmware state. + ~~Cross-heat best?~~ **Decided: best run is per-session** (one + `.dovex` per heat); nothing spans the day — matches the existing + per-session `best_lap_ms` semantics exactly. 5. **Replay page** — header `race_mode` column makes the replay results page label runs vs laps correctly; anything more (per-run sectors) is later. From 59399d1f7ed94686785d6d93d33757a690e8f0cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:41:10 +0000 Subject: [PATCH 06/36] =?UTF-8?q?docs:=20sprint=20concept=20=E2=80=94=20DO?= =?UTF-8?q?VEX=20race=5Fmode=20marker=20decided=20(CIRCUIT/SPRINT=20traili?= =?UTF-8?q?ng=20column)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/sprint-mode-concept.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/sprint-mode-concept.md b/docs/sprint-mode-concept.md index f6751fe..c4b8aa0 100644 --- a/docs/sprint-mode-concept.md +++ b/docs/sprint-mode-concept.md @@ -178,11 +178,20 @@ track the library's BETA branch in CI, so co-development is wired). - 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**: add a trailing `race_mode` column to header line 1 (the - format has an explicit trailing-column back-compat story — - `device_name` was added the same way, `dovex_header.h:21-23`); the lap - times line doubles as the run times line. Old readers ignore the extra - column; old files parse as circuit. Extend `tests/dovex_header_test.cpp`. +- **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**: reuse existing page IDs with mode-conditional labels (Lap → Run, Lap History → Run List, Best Lap → Best Run). The ordered page-constant blocks with their `ENDURANCE_MODE` / `SENSOREGG` From 285e73ac59d534a066ead1adc4e2e9bc69325cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:44:54 +0000 Subject: [PATCH 07/36] docs: adopt numbered docs/plans structure (DovesDataViewer convention) Move firmware-ota-phase0 -> plans/0000, insta360-ble-current- implementation -> plans/0001, sprint-mode-concept -> plans/0002 (chronological by original commit date), add the plans README, and fix the doc-path references in CLAUDE.md and CHANGELOG.md. Plan numbers now act as lightweight ticket numbers, cited from commit messages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- CHANGELOG.md | 2 +- CLAUDE.md | 3 +- .../0000-firmware-ota-phase0.md} | 0 ...01-insta360-ble-current-implementation.md} | 0 .../0002-sprint-mode.md} | 0 docs/plans/README.md | 55 +++++++++++++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) rename docs/{firmware-ota-phase0.md => plans/0000-firmware-ota-phase0.md} (100%) rename docs/{insta360-ble-current-implementation.md => plans/0001-insta360-ble-current-implementation.md} (100%) rename docs/{sprint-mode-concept.md => plans/0002-sprint-mode.md} (100%) create mode 100644 docs/plans/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd22a8..be422c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -852,7 +852,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 afe894b..e2c48dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,7 @@ handoff spec. |---|---| | `.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 | @@ -682,7 +683,7 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. 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`. + `docs/plans/0000-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. diff --git a/docs/firmware-ota-phase0.md b/docs/plans/0000-firmware-ota-phase0.md similarity index 100% rename from docs/firmware-ota-phase0.md rename to docs/plans/0000-firmware-ota-phase0.md 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/sprint-mode-concept.md b/docs/plans/0002-sprint-mode.md similarity index 100% rename from docs/sprint-mode-concept.md rename to docs/plans/0002-sprint-mode.md 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). From d143cc9cb402ffe53db5961871e517a7197a7392 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:05:14 +0000 Subject: [PATCH 08/36] plan 0002: mode-by-folder, date-based courses, on-device course creator; plan 0003: RPM spark/cylinder settings Fold in the latest user decisions: no race_mode device setting (mode follows the detected track's folder), sprint courses selected by newest date_created (same venue, new course every event), start+finish + up to 2 optional splits (single split legal), and the full on-device course-creator UI spec (no text entry; auto-named NEWTRACK/NEWCOURSE files). Adds design notes: ISO timestamp for date_created, point-capture averaging, first track-JSON writer, 4KB-buffer pruning question, both-kinds-in-range tiebreak. New plan 0003 splits out the spark_mode + cylinder_count settings for true-RPM display/logging. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0002-sprint-mode.md | 163 ++++++++++++++---- .../plans/0003-rpm-spark-cylinder-settings.md | 80 +++++++++ 2 files changed, 211 insertions(+), 32 deletions(-) create mode 100644 docs/plans/0003-rpm-spark-cylinder-settings.md diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md index c4b8aa0..0236723 100644 --- a/docs/plans/0002-sprint-mode.md +++ b/docs/plans/0002-sprint-mode.md @@ -35,13 +35,40 @@ tracks stored separately so everything existing stays backwards compatible. | Aspect | Circuit (today, unchanged) | Sprint (new) | |---|---|---| -| Mode select | default | `race_mode=sprint` setting, set via webapp `SSET`, applied on reboot (the existing settings contract — BLE disconnect auto-reboots) | +| 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. **No `race_mode` device setting** — an earlier draft had one; dropped. | | 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** — single course, or webapp-selected `defaultCourse` when a track has several | +| 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 (Lap Anything is a lap timer; it produces nothing useful point-to-point) | +| 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. *Recommendation: store a full sortable ISO-8601 timestamp + (`YYYY-MM-DDTHH:MM`), not `month/day/year` — autocross venues re-lay + courses same-day (morning/afternoon configs), and a date-only value can't + order those; lexicographic max = newest also falls out for free.* +- 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 forever — see the open question on + pruning (§7): the on-device JSON parse buffer is 4096 bytes, so a weekly + venue overflows it within months if nothing trims history. ### Real-world cadence (user report, 2026-08) @@ -98,18 +125,19 @@ track the library's BETA branch in CI, so co-development is wired). (direction is meaningless point-to-point), no S/F-restarts-sector-1 entanglement. - **Course model**: add `finish_a/b_lat/lng` to the sprint course config. - Sectors become **N ordered split lines** between start and finish rather - than the circuit's all-or-nothing S2+S3 pair - (`areSectorLinesConfigured()` requires *both* today — a single - mid-course split is currently impossible; the webapp's data model - already carries an ordered `sectors[]` list, so the library is the - bottleneck, not the data). + 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 (webapp-chosen - `defaultCourse`); it's independently useful for circuit too (adjacent - to library issue #35, proximity-based detection). + 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). @@ -132,11 +160,15 @@ track the library's BETA branch in CI, so co-development is wired). ## 4. Phase 2 — DovesDataLogger (`BETA`): consume it -- **`race_mode` setting**: default row in `ensureDefaultSettings()` - (`settings.ino:102-108`), loaded into a global in the settings block at - `BirdsEye.ino:765-795`. `SSET` is already generic (no key allowlist), so - the webapp can set it the moment the firmware reads it. Update the - settings tables in README.md and CLAUDE.md. +- **Mode-by-folder detection** (replaces the earlier `race_mode` setting + idea): `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). Open question: a + venue with both kinds in range needs a tiebreak (§7). +- **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 @@ -198,15 +230,76 @@ track the library's BETA branch in CI, so co-development is wired). reshuffles are the most fragile part of the UI — avoid new page constants. -## 5. Phase 3 — DovesDataViewer (last) +## 5. On-device course creator — the big ask + +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 should average, not snapshot**: a single 25 Hz fix is + 1–2 m noisy; standing at a cone for ~3–5 s and averaging fixes costs + nothing (the user is stationary anyway) and materially improves line + placement. Show a "hold still… n/N" progress. +- **Name collisions**: `NEWTRACK_{date}` / `NEWCOURSE_{date}` collide on a + second same-day creation, and the track-browser display truncates names + to 13 chars (`MAX_LOCATION_LENGTH`) so date-suffixed names can also look + identical on screen. Recommendation: include time in the generated suffix + (fits the `date_created` ISO recommendation in §2) or append a counter. +- **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. + +## 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. -- **Settings UI**: `race_mode` auto-appears via the generic key/value list, - but a real toggle needs a new `boolean`/`enum` control type in - `deviceSettingsSchema.ts` + `DeviceSettingsTab.tsx` (today only - string/number text inputs exist). Update `docs/ble-protocol.md` §8.5. +- **Track/course editor**: a **circuit/sprint toggle on the track & course + editor** (decided — this replaced the device-setting idea). 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. +- (The `boolean`/`enum` settings control type is no longer needed for + sprint, but plan 0003's `spark_mode` setting will want it.) - **Track model**: `type: 'circuit' | 'sprint'` on `Course`/`Track`, flowing through `trackStorage.ts` ↔ `deviceTrackSync.ts` ↔ `trackSubmission.ts` and a Supabase `courses` column. Relax @@ -226,23 +319,29 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. - Per that repo's conventions: a numbered `docs/plans/` design doc and `docs/subsystems.md` updates. -## 6. Open Questions +## 7. Open Questions -1. **Is `race_mode` really a device mode?** Alternative: mode follows the - detected track (which folder the nearest manifest entry came from), and - the setting is only a tiebreak when a venue has both kinds nearby. - Fewer webapp round-trips; slightly spookier behavior. -2. **Minimum sector support for v1** — none (start + finish only) ships - faster; N ordered splits is where the library should end up. -3. **Run arming semantics** — auto-arm on entering the start-line zone vs +1. ~~Is `race_mode` a device mode?~~ **Decided: mode follows the detected + track's folder — no device setting.** Remaining sub-question: the + **tiebreak when a venue has both kinds in range** (a circuit that hosts + parking-lot autocross). Candidate heuristic: if the nearest sprint + track has a course created *today*, it's an event day — prefer sprint; + otherwise prefer circuit. Or just prompt on-device. +2. ~~Minimum sector support?~~ **Decided: start + finish required, up to 2 + optional splits, single split legal.** +3. **Course-history pruning** — weekly events accumulate dated courses in + one track file; the 4096-byte parse buffer caps how long that works. + Device keeps only newest N? Webapp trims on sync? Needs an answer + before the course creator ships. +4. **Run arming semantics** — auto-arm on entering the start-line zone vs a speed/launch threshold? What does a false start / DNF (never crosses finish, returns to start) record as? -4. **Between-run state** — best run, run history, and the log file must +5. **Between-run state** — best run, run history, and the log file must survive the loop-back and the ~30–45 s queue wait (they will, as long as sessions don't end between runs — see Phase 2 lifecycle). ~~Cross-heat best?~~ **Decided: best run is per-session** (one `.dovex` per heat); nothing spans the day — matches the existing per-session `best_lap_ms` semantics exactly. -5. **Replay page** — header `race_mode` column makes the replay results +6. **Replay page** — header `race_mode` column makes the replay results page label runs vs laps correctly; anything more (per-run sectors) 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..bf7c62e --- /dev/null +++ b/docs/plans/0003-rpm-spark-cylinder-settings.md @@ -0,0 +1,80 @@ +# RPM Accuracy — Spark Type & Cylinder Count Settings + +> Status: **CONCEPT** — 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. + +## 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). +- Apply the divisor **once, at the period→RPM conversion in `TACH_LOOP()`**, + *before* the Kalman filter — the filter's Q (800 RPM², tuned for kart + inertia) and R are in true-RPM units, so feeding it corrected measurements + keeps the tuning meaningful. Every consumer (display 3 Hz, DOVEX rows + 25 Hz, camera FSM, auto-race check) reads `tachLastReported`, so one + conversion point corrects them all. +- 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**: the 3 ms minimum pulse gap caps at ~20 000 + pulses/min. At divisor 2 (2-cyl every-rev) that's only **~10 000 true + RPM** — a screaming 2-cyl 2T could exceed it. The min-gap likely needs to + derive from `pulses_per_rev` (e.g. 3 ms ÷ ppr, floor ~1.5 ms) — verify + ISR headroom before lowering. +- **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 + +Concept only. No dependency on plan 0002 — can ship before or after sprint +mode. Should ship *before* any camera/auto-race tuning work for multi-cyl +engines. From 439f31a4373532cfe574c57ec2f59c296c5c99ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:16:57 +0000 Subject: [PATCH 09/36] plan 0002+0003: lock in tiebreak setting, ISO dates, 3s point hold, sync-prune, single-source RPM - date_created: ISO timestamp decided (same stamp in generated names) - point capture: 3 s hold, ~75 fixes averaged, h_acc gating - pruning: webapp sync keeps last single day of courses on device (sync -> delete -> push new file); v1 may ship first with disclaimer - race_mode setting returns as the both-kinds-in-range tiebreak only: circuit default yields to a sprint course created today; sprint always prefers the newest sprint course (fixed rally layouts) - plan 0003: true RPM computed once before the Kalman (tuning stays in true-RPM units), tachLastReported is the single source of truth Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0002-sprint-mode.md | 90 +++++++++++-------- .../plans/0003-rpm-spark-cylinder-settings.md | 21 +++-- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md index 0236723..0e809c6 100644 --- a/docs/plans/0002-sprint-mode.md +++ b/docs/plans/0002-sprint-mode.md @@ -35,7 +35,7 @@ tracks stored separately so everything existing stays backwards compatible. | 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. **No `race_mode` device setting** — an earlier draft had one; dropped. | +| 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. | @@ -58,17 +58,23 @@ 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. *Recommendation: store a full sortable ISO-8601 timestamp - (`YYYY-MM-DDTHH:MM`), not `month/day/year` — autocross venues re-lay - courses same-day (morning/afternoon configs), and a date-only value can't - order those; lexicographic max = newest also falls out for free.* + 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 forever — see the open question on - pruning (§7): the on-device JSON parse buffer is 4096 bytes, so a weekly - venue overflows it within months if nothing trims history. +- 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) @@ -160,12 +166,14 @@ track the library's BETA branch in CI, so co-development is wired). ## 4. Phase 2 — DovesDataLogger (`BETA`): consume it -- **Mode-by-folder detection** (replaces the earlier `race_mode` setting - idea): `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). Open question: a - venue with both kinds in range needs a tiebreak (§7). +- **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**. @@ -267,15 +275,16 @@ New main-menu option **"Create Course"**: - **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 should average, not snapshot**: a single 25 Hz fix is - 1–2 m noisy; standing at a cone for ~3–5 s and averaging fixes costs - nothing (the user is stationary anyway) and materially improves line - placement. Show a "hold still… n/N" progress. -- **Name collisions**: `NEWTRACK_{date}` / `NEWCOURSE_{date}` collide on a - second same-day creation, and the track-browser display truncates names - to 13 chars (`MAX_LOCATION_LENGTH`) so date-suffixed names can also look - identical on screen. Recommendation: include time in the generated suffix - (fits the `date_created` ISO recommendation in §2) or append a counter. +- **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 @@ -293,13 +302,20 @@ 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 — this replaced the device-setting idea). Sprint + 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. -- (The `boolean`/`enum` settings control type is no longer needed for - sprint, but plan 0003's `spark_mode` setting will want it.) +- **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 @@ -321,18 +337,20 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. ## 7. Open Questions -1. ~~Is `race_mode` a device mode?~~ **Decided: mode follows the detected - track's folder — no device setting.** Remaining sub-question: the - **tiebreak when a venue has both kinds in range** (a circuit that hosts - parking-lot autocross). Candidate heuristic: if the nearest sprint - track has a course created *today*, it's an event day — prefer sprint; - otherwise prefer circuit. Or just prompt on-device. +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** — weekly events accumulate dated courses in - one track file; the 4096-byte parse buffer caps how long that works. - Device keeps only newest N? Webapp trims on sync? Needs an answer - before the course creator ships. +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 semantics** — auto-arm on entering the start-line zone vs a speed/launch threshold? What does a false start / DNF (never crosses finish, returns to start) record as? diff --git a/docs/plans/0003-rpm-spark-cylinder-settings.md b/docs/plans/0003-rpm-spark-cylinder-settings.md index bf7c62e..4b86ad6 100644 --- a/docs/plans/0003-rpm-spark-cylinder-settings.md +++ b/docs/plans/0003-rpm-spark-cylinder-settings.md @@ -39,12 +39,21 @@ devices are unaffected by `ensureDefaultSettings()` auto-populating the keys. - 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). -- Apply the divisor **once, at the period→RPM conversion in `TACH_LOOP()`**, - *before* the Kalman filter — the filter's Q (800 RPM², tuned for kart - inertia) and R are in true-RPM units, so feeding it corrected measurements - keeps the tuning meaningful. Every consumer (display 3 Hz, DOVEX rows - 25 Hz, camera FSM, auto-race check) reads `tachLastReported`, so one - conversion point corrects them all. +- **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. From 100d71768af5a4b723b67bc001ddbf05b29c2642 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:22:03 +0000 Subject: [PATCH 10/36] plan 0002: run semantics decided - both lines always hot, start-cross restarts, DNF is normal op Session arming unchanged (RPM ~1 s brings systems up). Runs are purely line-driven: start crossing begins a run and cancels/restarts any run in progress (botched-course re-launch); finish completes; finish ignored while no run active. DNF records nothing special. Notes the direction-agnostic backward-crossing nuance and the self-healing property of the restart rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0002-sprint-mode.md | 44 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md index 0e809c6..7cad8cc 100644 --- a/docs/plans/0002-sprint-mode.md +++ b/docs/plans/0002-sprint-mode.md @@ -124,12 +124,32 @@ track the library's BETA branch in CI, so co-development is wired). `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**: per-run state machine - `ARMED → RUNNING → FINISHED`, re-arming when the driver returns to the - start-line zone. Surface: run count, current run time, last run, best - run (+ run number), run history. No `laps`, no `DirectionDetector` - (direction is meaningless point-to-point), no S/F-restarts-sector-1 - entanglement. +- **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 @@ -351,9 +371,15 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. 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 semantics** — auto-arm on entering the start-line zone vs - a speed/launch threshold? What does a false start / DNF (never crosses - finish, returns to start) record as? +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** — best run, run history, and the log file must survive the loop-back and the ~30–45 s queue wait (they will, as long as sessions don't end between runs — see Phase 2 lifecycle). From ec8fa6937e20e59ad773a6bbc4c159ec7b456882 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:56:00 +0000 Subject: [PATCH 11/36] plan 0002: final decisions - race mode stays live between runs (*waiting* on lap/pace pages), keep 'laps' verbiage Design complete: all open questions resolved. Between runs the device remains in normal race mode with every page live; Current Lap and Pace show *waiting* while no run is active. Lap wording kept everywhere (AX drivers call them laps) - no relabeling, no page-constant churn, DOVEX laps_ms line unchanged; the race_mode header column alone informs the webapp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0002-sprint-mode.md | 37 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md index 7cad8cc..3dff2d4 100644 --- a/docs/plans/0002-sprint-mode.md +++ b/docs/plans/0002-sprint-mode.md @@ -1,6 +1,7 @@ # Sprint Mode (Autocross / Point-to-Point) — Concept & Cross-Repo Roadmap -> Status: **CONCEPT** — research + direction only, no implementation yet. +> Status: **CONCEPT — design complete, implementation-ready.** All §7 +> questions are decided; no implementation yet. > Scope spans three repos; each phase lands on that repo's beta branch > (DovesLapTimer `BETA` → DovesDataLogger `BETA` → DovesDataViewer, last). @@ -252,11 +253,14 @@ track the library's BETA branch in CI, so co-development is wired). 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**: reuse existing page IDs with mode-conditional labels - (Lap → Run, Lap History → Run List, Best Lap → Best Run). The ordered - page-constant blocks with their `ENDURANCE_MODE` / `SENSOREGG` - reshuffles are the most fragile part of the UI — avoid new page - constants. +- **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 @@ -380,12 +384,15 @@ cutting a fresh beta branch. Nothing in the app anticipates modes today. 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** — best run, run history, and the log file must - survive the loop-back and the ~30–45 s queue wait (they will, as long - as sessions don't end between runs — see Phase 2 lifecycle). - ~~Cross-heat best?~~ **Decided: best run is per-session** (one - `.dovex` per heat); nothing spans the day — matches the existing - per-session `best_lap_ms` semantics exactly. -6. **Replay page** — header `race_mode` column makes the replay results - page label runs vs laps correctly; anything more (per-run sectors) is - later. +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. From ef75267e75db4c42c06a088c2935cb61018a3f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:58:45 +0000 Subject: [PATCH 12/36] plan 0002: sprint_select pure unit + DOVEX race_mode trailing column sprint_select: newest-course-by-date_created ordering (sortable ISO strings, ties resolve to the later file entry) + the circuit-vs-sprint tiebreak decision table (race_mode pref; circuit yields to a sprint course created today). dovex_header: race_mode trailing column (CIRCUIT/SPRINT, empty = circuit, same append mechanism as device_name) and the line-2 splitter now preserves empty middle fields - the strtok version let a blank column shift every later column left (regression test included). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- BirdsEye/dovex_header.cpp | 54 ++++++++++------ BirdsEye/dovex_header.h | 15 +++-- BirdsEye/sprint_select.cpp | 51 +++++++++++++++ BirdsEye/sprint_select.h | 88 +++++++++++++++++++++++++ tests/CMakeLists.txt | 2 + tests/dovex_header_test.cpp | 89 ++++++++++++++++++++++++-- tests/sprint_select_test.cpp | 121 +++++++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 BirdsEye/sprint_select.cpp create mode 100644 BirdsEye/sprint_select.h create mode 100644 tests/sprint_select_test.cpp 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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index a8d1195..3b3ba60 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(birdseye_tests sd_format_page_test.cpp sat_bars_test.cpp sensoregg_protocol_test.cpp + sprint_select_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp @@ -45,6 +46,7 @@ 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 ) target_include_directories(birdseye_tests PRIVATE 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/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); +} From 7b8c1fdb2cf6c9a2e6e79cc3591af234b0509e29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:58:46 +0000 Subject: [PATCH 13/36] plan 0002: sprint mode core - /TRACKS/SPRINT, mode-by-folder, SprintTimer backend - /TRACKS/SPRINT folder (auto-provisioned; circuit tracks untouched); manifest entries carry their folder kind; buildTrackList scans both via the new scanTrackDir(folder, kind); makeFullTrackPath is kind-aware; FILEPATH_MAX 50->64 - parseTrackFile: track-level "type", per-course finish_* lines and date_created (containsKey idiom, same as sectors) - trackDetectionLoop: nearest entry PER KIND; race_mode setting (new, default circuit) breaks both-in-range ties via sprint_select, with the event-day heuristic parsing the sprint file's newest course date - sprint path skips CourseManager/CourseDetector: createSprintSession() picks the newest course and stands up the library's SprintTimer (start + separate finish + optional splits); sprintTimer != nullptr IS sprint mode, all activeTimer*() helpers duck-type runs as laps - lifecycle: run-count-edge history capture (identical consecutive run times are normal at autocross - value-change dedupe would drop them), each run re-arms the auto-idle grace, sprint idle is engine-aware (running engine at the start line never ends the session) - DOVEX header: sprint course name + race_mode=SPRINT - display: Current Lap / Pace show *waiting* between runs; 'laps' verbiage kept everywhere by design - sim: SprintTimer/CrossingEngine/sprint_select compiled into the TU, prototypes mirrored; all 6 sim tests (incl. lap oracles) green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- BirdsEye/BirdsEye.ino | 227 +++++++++++++++++++++++++++++++--- BirdsEye/display_pages.ino | 14 ++- BirdsEye/gps_functions.ino | 8 +- BirdsEye/project.h | 25 +++- BirdsEye/sd_functions.h | 12 +- BirdsEye/sd_functions.ino | 88 ++++++++++--- BirdsEye/settings.ino | 1 + BirdsEye/sim/CMakeLists.txt | 3 + BirdsEye/sim/sim_prototypes.h | 8 +- 9 files changed, 348 insertions(+), 38 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 21f2731..dc98552 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -42,6 +42,7 @@ #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 @@ -93,6 +94,7 @@ #include "sd_functions.h" #include "sensoregg.h" #include "settings.h" +#include "sprint_select.h" #include "tachometer.h" #include "usb_msc.h" #include "wake_cause.h" @@ -141,6 +143,13 @@ 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; @@ -152,6 +161,10 @@ 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]; @@ -185,7 +198,7 @@ bool btn3Held = false; #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 +#define FILEPATH_MAX 64 // "/TRACKS/SPRINT/" (15) + manifest name (31) + ".json" (5) + null = 52, using 64 for safety #include /////////////////////////////////////////// @@ -387,6 +400,26 @@ 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; @@ -467,6 +500,9 @@ bool sdFormatLastFailed = false; 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; @@ -739,9 +775,9 @@ void setup() { sdTrackSuccess = buildTrackList(); if(sdSetupSuccess && sdTrackSuccess) { debugln(F("Obtained Track List")); - for (int i = 0; i < numOfLocations; i++) { + for (int i = 0; i < trackManifestCount; i++) { char filepath[FILEPATH_MAX]; - makeFullTrackPath(locations[i], filepath); + makeFullTrackPath(trackManifest[i].filename, filepath, trackManifest[i].kind); debugln(filepath); } } @@ -782,6 +818,9 @@ void setup() { strncpy(settingDeviceName, buf, sizeof(settingDeviceName) - 1); settingDeviceName[sizeof(settingDeviceName) - 1] = '\0'; } + if (getSetting("race_mode", buf, sizeof(buf))) { + settingRaceModePrefSprint = (strcasecmp(buf, "sprint") == 0); + } crossingThresholdMeters = settingLapDetectionDistance; debug(F("Settings loaded: lap_dist=")); debug(settingLapDetectionDistance); @@ -860,6 +899,29 @@ void setup() { // 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. @@ -878,6 +940,7 @@ WaypointLapTimer* getActiveTimerWLT() { // 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(); @@ -886,6 +949,7 @@ bool activeTimerRaceStarted() { } bool activeTimerCrossing() { + if (sprintTimer != nullptr) return sprintTimer->getCrossing(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCrossing(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -894,6 +958,7 @@ bool activeTimerCrossing() { } int activeTimerLaps() { + if (sprintTimer != nullptr) return sprintTimer->getRuns(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getLaps(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -902,6 +967,7 @@ int activeTimerLaps() { } unsigned long activeTimerCurrentLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getCurrentRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCurrentLapTime(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -910,6 +976,7 @@ unsigned long activeTimerCurrentLapTime() { } unsigned long activeTimerLastLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getLastRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getLastLapTime(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -918,6 +985,7 @@ unsigned long activeTimerLastLapTime() { } unsigned long activeTimerBestLapTime() { + if (sprintTimer != nullptr) return sprintTimer->getBestRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getBestLapTime(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -926,6 +994,7 @@ unsigned long activeTimerBestLapTime() { } int activeTimerBestLapNumber() { + if (sprintTimer != nullptr) return sprintTimer->getBestRunNumber(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getBestLapNumber(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -934,6 +1003,7 @@ int activeTimerBestLapNumber() { } float activeTimerPaceDifference() { + if (sprintTimer != nullptr) return sprintTimer->getPaceDifference(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getPaceDifference(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -942,6 +1012,7 @@ float activeTimerPaceDifference() { } float activeTimerTotalDistance() { + if (sprintTimer != nullptr) return sprintTimer->getTotalDistanceTraveled(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getTotalDistanceTraveled(); WaypointLapTimer* wlt = getActiveTimerWLT(); @@ -950,20 +1021,83 @@ float activeTimerTotalDistance() { } 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 when a match is found within 5 miles + * 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; @@ -978,24 +1112,59 @@ void trackDetectionLoop() { if (millis() - lastManifestScan < 1000) return; lastManifestScan = millis(); - double bestDist = 999999.0; - int bestIndex = -1; + // 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 (dist < bestDist) { - bestDist = dist; - bestIndex = i; + if (trackManifest[i].kind == TRACK_KIND_SPRINT) { + if (dist < bestDistSprint) { bestDistSprint = dist; bestSprint = i; } + } else { + if (dist < bestDistCircuit) { bestDistCircuit = dist; bestCircuit = i; } } } - if (bestIndex >= 0 && bestDist <= TRACK_DETECT_RADIUS_MILES) { + 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(F(" (")); + debug(useKind == sprint_select::kSprint ? F(" [sprint] (") : F(" [circuit] (")); debug(bestDist, 2); debugln(F(" miles)")); @@ -1006,10 +1175,16 @@ void trackDetectionLoop() { // 8.3 limit), causing strcmp mismatches that silently skip real tracks. { char filepath[FILEPATH_MAX]; - makeFullTrackPath(trackManifest[bestIndex].filename, filepath); + makeFullTrackPath(trackManifest[bestIndex].filename, filepath, trackManifest[bestIndex].kind); int parseStatus = parseTrackFile(filepath); - if (parseStatus == PARSE_STATUS_GOOD && numOfTracks > 0) { + 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; @@ -1106,6 +1281,13 @@ void endRaceSession() { 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; @@ -1127,6 +1309,7 @@ void endRaceSession() { */ void createLapAnythingCourseManager() { if (courseManager != nullptr) return; // Already exists + if (sprintTimer != nullptr) return; // Sprint session owns timing activeTrackConfig.longName = "Unknown"; activeTrackConfig.shortName = ""; activeTrackConfig.courseCount = 0; @@ -1163,6 +1346,17 @@ void checkAutoIdle() { // 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; @@ -1348,7 +1542,10 @@ void writeDovexHeader() { const char* courseName = "Lap Anything"; const char* shortName = ""; - if (courseManager != nullptr) { + 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(); @@ -1362,6 +1559,8 @@ void writeDovexHeader() { 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]; diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 0d62e2c..531011e 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -661,7 +661,12 @@ void displayPage_gps_lap_time() { bool raceStarted = activeTimerRaceStarted(); unsigned long currentLapTimeMs = activeTimerCurrentLapTime(); - if (raceStarted) { + 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); @@ -701,7 +706,12 @@ void displayPage_gps_pace() { // main page into display.setTextColor(DISPLAY_TEXT_WHITE); const int lineHeight = 21; - if (paceRaceStarted && paceLaps >= 1) { + 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) { diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index 0b47417..cc71f48 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -469,7 +469,9 @@ void GPS_LOOP() { if (gpsDataFresh) { gpsDataFresh = false; - // Feed fresh GPS data into the active course/timer + // 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; @@ -478,6 +480,10 @@ void GPS_LOOP() { 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 diff --git a/BirdsEye/project.h b/BirdsEye/project.h index bef1f10..395bc49 100644 --- a/BirdsEye/project.h +++ b/BirdsEye/project.h @@ -218,17 +218,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 +260,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..4d1eaaa 100644 --- a/BirdsEye/sd_functions.h +++ b/BirdsEye/sd_functions.h @@ -49,9 +49,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 diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino index f9c2c36..9776008 100644 --- a/BirdsEye/sd_functions.ino +++ b/BirdsEye/sd_functions.ino @@ -58,10 +58,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 +122,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 +264,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 +301,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 +354,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 +370,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 +451,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 +479,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 +540,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++; } } diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index c43d628..e98684c 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -105,6 +105,7 @@ 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 }; char buf[48]; diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index ad18a69..3057f0d 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -132,10 +132,13 @@ 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}/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/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index 7abaae9..7c3a94a 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -25,6 +25,7 @@ #include #include +#include #include "SdFat.h" @@ -53,6 +54,10 @@ float activeTimerPaceDifference(); float activeTimerTotalDistance(); unsigned long activeTimerOptimalLapTime(); bool activeTimerSectorsConfigured(); +SprintTimer* getActiveTimerSprint(); +bool sprintModeIsActive(); +bool activeTimerRunActive(); +bool createSprintSession(); void trackDetectionLoop(); void endRaceSession(); void createLapAnythingCourseManager(); @@ -158,7 +163,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(); From 197e48188e4e11fc78ef30a2bc2ffb4f3501b0de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:58:46 +0000 Subject: [PATCH 14/36] plan 0002: CHANGELOG + CLAUDE.md + README for sprint mode core Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ CLAUDE.md | 44 +++++++++++++++++++++++++++++++++++++++----- README.md | 1 + 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f979cd2..bd5f569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,34 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### Added +- **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. diff --git a/CLAUDE.md b/CLAUDE.md index 44e5147..7dbc20b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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 | +| `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) | | `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 | @@ -573,7 +574,28 @@ loop() ~250 Hz 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. + 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) @@ -982,7 +1004,7 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. ### DOVEX Log (`.dovex` files) — New UI default ``` -datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_ms,device_name +datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_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 @@ -991,9 +1013,12 @@ timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x - **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. +- **`device_name`** and **`race_mode`** are trailing metadata columns + (after `optimal_lap_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 @@ -1043,6 +1068,13 @@ 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 @@ -1051,6 +1083,7 @@ Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). "bluetooth_pin": "7391", "camera_serial": "", "device_name": "ApexTurbo", + "race_mode": "circuit", "driver_name": "Driver", "lap_detection_distance": "7", "waypoint_detection_distance": "30", @@ -1065,6 +1098,7 @@ Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). | `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 | diff --git a/README.md b/README.md index 8c2fc87..03e21ce 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto |---|---|---| | `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` | From 7e904af63972883bbcbd09543c154d9dd54d3138 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 21:09:18 +0000 Subject: [PATCH 15/36] plan 0002: pass -DDOVES_DISABLE_DEBUG in all firmware builds (OTA cap fix) The beta image burst the 320 KB OTA self-flash cap (330,628 B, 100.9%) - and was already at 98.2% before sprint mode. Root cause: the DovesLapTimer library's debug pipeline is gated by a RUNTIME if(_serial) check, so ~150 debug call-sites and their F() strings stay resident in flash on hardware builds that never attach a debug Stream. DovesLapTimer#48 (BETA) adds the DOVES_DISABLE_DEBUG compile-time kill switch; this passes it in compile-sketch/beta/release so the dead pipeline is dropped (~6-8 KB), bringing the image back under the cap with real headroom instead of moving the OTA staging layout. IDE debug builds are unaffected (flag is per-build opt-in; documented in CONTRIBUTING). Requires DovesLapTimer#48 merged to BETA before the compile-sketch gate can pass (CI pulls the library's BETA branch). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- .github/workflows/beta.yml | 2 +- .github/workflows/compile-sketch.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 8 ++++++++ CONTRIBUTING.md | 7 +++++++ 5 files changed, 18 insertions(+), 3 deletions(-) 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..528231e 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -74,7 +74,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 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/CHANGELOG.md b/CHANGELOG.md index bd5f569..832decb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,14 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 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). 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). From 06a2915560bf37103772381494b8ab1e62bbfc5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 22:59:08 +0000 Subject: [PATCH 16/36] ci: re-run against DovesLapTimer BETA with DOVES_DISABLE_DEBUG (post-#48 merge) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb From 10e417bf65e898409bcc875131de1b6eebd9d0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:30:25 +0000 Subject: [PATCH 17/36] feat: FWDFU BLE command - reboot into UF2 mass-storage DFU (pre-update escape hatch) Rebooting with GPREGRET=0x57 (DFU_MAGIC_UF2_RESET, stock on the Adafruit/Seeed bootloader) brings the device up as a USB drive; copying a .uf2 flashes the app region directly - no image-size cap, no staging region, no web/BLE streaming. Deferred to the main loop like every FW command; acknowledges FWDFU:OK before the reboot, cleanly aborts any in-flight OTA first, and uses the same sd_power_gpregret SV-call pattern as the apply path's recovery flag. Groundwork for the SD-staged OTA rework: fleet units that take this update always have a size-unlimited update path over a USB cable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- BirdsEye/firmware_ota.h | 3 ++ BirdsEye/firmware_ota.ino | 64 ++++++++++++++++++++++++++++++++++++++- CHANGELOG.md | 9 ++++++ CLAUDE.md | 13 +++++++- 4 files changed, 87 insertions(+), 2 deletions(-) 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..65c068d 100644 --- a/BirdsEye/firmware_ota.ino +++ b/BirdsEye/firmware_ota.ino @@ -65,6 +65,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 +134,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 +219,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 +308,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 +690,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/CHANGELOG.md b/CHANGELOG.md index f979cd2..6405516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### 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. - **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. diff --git a/CLAUDE.md b/CLAUDE.md index 44e5147..915d8a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -509,7 +509,7 @@ loop() ~250 Hz 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 + 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 @@ -667,6 +667,17 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. 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 From 63409846898d480acd6ccacdcc5dd7146b84591b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:34:52 +0000 Subject: [PATCH 18/36] plan 0004: SD-direct OTA staging spike doc (FWDFU pre-update + rework design) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0004-sd-direct-ota-staging.md | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/plans/0004-sd-direct-ota-staging.md 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..0104143 --- /dev/null +++ b/docs/plans/0004-sd-direct-ota-staging.md @@ -0,0 +1,90 @@ +# SD-Direct OTA Staging — Spike + the FWDFU Pre-Update + +> Status: **SPIKE** — the pre-update (`FWDFU`) ships now on both channels; +> the SD-direct apply path is design + hardware validation work, not yet +> implemented. 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). + +## 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. + +## Chosen direction: 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. +- Release channels should publish a `.uf2` asset alongside the existing + DFU `.zip` so there is always a file to drag (follow-up to the release + workflow when the first post-FWDFU release is cut). +- 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. +2. **Spike**: the four hardware validations above, on bench hardware. +3. **Implement**: SD-direct apply behind the spike results; delete + `FW_STAGE_BASE`/staging copy; CI OTA gate re-pointed at the app-region + bound. +4. **Someday/never**: if the spike fails hard, fall back to raising + `FW_MAX_IMAGE_SIZE` (staging-layout move, plan 0002 discussion) — the + options analysis lives in the PR #116 thread. From b16c42e5aceca0751b3f85b83ed80c7e24cd8e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:57:50 +0000 Subject: [PATCH 19/36] perf: generate the crossing animation instead of storing 2 KB of bitmaps The two 'calculating' frames were hand-stored 1 KB PROGMEM bitmaps, but decoding them shows both are pure block patterns: eight 16x16 px cells confined to the odd 16 px row bands, with the two frames offset by one cell so alternating them scrolls sideways. Storing 2048 bytes of flash to say that is a bad trade. They are now emitted by the new host-tested crossing_pattern unit and drawn with fillRect(). Equivalence is proven, not assumed: the original 2 KB is pinned as goldens in crossing_pattern_test.cpp, which rasterizes the generated rectangles and memcmps all 128x64 pixels of both frames. The bird splash is untouched - it is real artwork, not a pattern. Reclaims 2,048 B of flash on an image that sits at 99.4% of the OTA cap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- BirdsEye/crossing_pattern.cpp | 28 ++++ BirdsEye/crossing_pattern.h | 48 ++++++ BirdsEye/display_pages.ino | 15 +- BirdsEye/images.h | 280 +++++++++----------------------- BirdsEye/sim/CMakeLists.txt | 1 + CHANGELOG.md | 13 ++ CLAUDE.md | 3 +- tests/CMakeLists.txt | 2 + tests/crossing_pattern_test.cpp | 230 ++++++++++++++++++++++++++ 9 files changed, 410 insertions(+), 210 deletions(-) create mode 100644 BirdsEye/crossing_pattern.cpp create mode 100644 BirdsEye/crossing_pattern.h create mode 100644 tests/crossing_pattern_test.cpp 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.ino b/BirdsEye/display_pages.ino index 531011e..d390ee7 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -4,6 +4,7 @@ /////////////////////////////////////////// #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" @@ -1217,12 +1218,16 @@ void displayCrossing() { display.setCursor(0, 0); #ifndef ENDURANCE_MODE - // Draw bitmap on the screen + // 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; - if (calculatingFlip) { - display.drawBitmap(0, 0, image_data_calculating1, 128, 64, 1); - } else { - display.drawBitmap(0, 0, image_data_calculating2, 128, 64, 1); + 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 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/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 3057f0d..e21c1b6 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -121,6 +121,7 @@ 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}/crossing_pattern.cpp ${BIRDSEYE_DIR}/dovex_header.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_status_page.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0940cd1..5a3f925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Changed +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index b3c0d66..723f24f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ All sketch sources live in `BirdsEye/` so the folder name matches the | `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) | +| `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) | @@ -120,6 +120,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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) | | `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 | diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3b3ba60..8c1ae27 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(birdseye_tests sat_bars_test.cpp sensoregg_protocol_test.cpp sprint_select_test.cpp + crossing_pattern_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp @@ -47,6 +48,7 @@ add_executable(birdseye_tests ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp ${BIRDSEYE_DIR}/sprint_select.cpp + ${BIRDSEYE_DIR}/crossing_pattern.cpp ) target_include_directories(birdseye_tests PRIVATE 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); +} From dda33f072997aaf581c5199556f6fd49c09c0968 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 00:15:27 +0000 Subject: [PATCH 20/36] feat: BLE sprint-track sync opcodes (TSLIST/TSGET/TSPUT/TSDEL) Sprint courses live in /TRACKS/SPRINT since plan 0002, but the BLE track commands all spliced /TRACKS - the new folder was only reachable over USB mass storage, which makes sprint mode unusable from the web app. Adds TS-prefixed twins of the four track verbs. They share the circuit code paths through a kind parameter (trackFolderFor()) rather than duplicating handlers, so the flash cost is a few hundred bytes on an image already at the OTA ceiling. TSLIST answers with its own TSFILE:/TSEND tokens so a sprint enumeration can't be mistaken for a circuit one; TSGET/TSPUT/TSDEL reuse the existing replies. Security posture is unchanged and deliberate: filename_validator still rejects '/', '..' and FAT-unsafe bytes on every track command, and the target folder is chosen by the OPCODE, never parsed from the wire - a client cannot path between the two folders. Circuit paths resolve byte-identically to before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- BirdsEye/bluetooth.ino | 2310 ++++++++++++++++++++-------------------- CHANGELOG.md | 13 + CLAUDE.md | 10 + 3 files changed, 1194 insertions(+), 1139 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 5a3f925..fa128b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **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 - **The crossing animation is generated, not stored — 2,048 B of flash reclaimed.** The two "calculating" frames shown while inside a crossing diff --git a/CLAUDE.md b/CLAUDE.md index 723f24f..edd3a9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,16 @@ loop() ~250 Hz - `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()` From 2e2f53824b558ad9e0b1de7d72d1e7de17b0fb06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 00:58:57 +0000 Subject: [PATCH 21/36] ci: pick the build channel from the source tree, not just the target compile-sketch resolved LAPTIMER_REF / FEATURE_FLAGS from base_ref and ref_name only. On the long-lived BETA -> master integration PR (#113) base_ref is master and, on a pull_request event, ref_name is the merge ref -- so BETA's source was compiled against master's channel config: DovesLapTimer pinned to v4.2.0 (no CrossingEngine/SprintTimer) and the SensorEgg flag off. Both board jobs failed on BirdsEye.ino:45:10: fatal error: SprintTimer.h: No such file and would have kept failing for as long as that PR stays open. Add head_ref == 'BETA' so a PR whose *source* is BETA builds with BETA's library ref and flags. Feature PRs into BETA (base_ref) and pushes to BETA (ref_name) are unchanged, as is every master/release build. The fallbacks stay pinned: promoting BETA to master still requires bumping them deliberately, which is now called out in the comment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- .github/workflows/compile-sketch.yml | 37 +++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index 528231e..155da3a 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. From d11d7d60e1385072f8788dc7b655901a5461b04f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:35:23 +0000 Subject: [PATCH 22/36] feat: raise the OTA image cap 320 KiB -> 408 KiB by splitting flash evenly Plan 0004, the half that needs no hardware spike. The app and the OTA 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 installable image is half the span. The split was lopsided -- 320 KiB staging against 500 KiB of app region -- which capped OTA at 320 KiB while leaving ~180 KiB of app region no legal image could ever reach. The beta build was sitting at 99.0% of that cap with 3,308 B free. Move the staging base 0xA4000 -> 0x8E000 for an even, page-aligned split: staging [0x8E000, 0xF4000) = 417,792 B = 408 KiB (FW_MAX_IMAGE_SIZE) app [0x27000, 0x8E000) = 421,888 B = 412 KiB (>= the cap) Two constants. No change to the apply sequence, the FW* protocol, the CRC, or the web app -- the client never enforced a cap of its own, it relies on the device's FWERR:SIZE. Beta image goes 99.0% -> 77.6%, 93 KiB free. Safe for the existing fleet: staging is chosen at apply time from the INSTALLED firmware's constants, nothing about it is baked into the image being delivered, so a unit on 3.0.x stages at the old 0xA4000 and installs this build normally. And the new app region ends below the old staging base, so an image built for this layout can never collide with an old unit's staging region. Add static_asserts for page alignment and app-region fit -- both were silent invariants a future constant edit could have broken, and getting either wrong means erasing live code or accepting an un-installable image. CI gains a second, non-fatal check: an image past the legacy 320 KiB can no longer be installed by units still on the old layout, which would need the USB FWDFU -> UF2 path instead. Warn so a fleet split is visible rather than discovered in the field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- .github/workflows/compile-sketch.yml | 20 +++++- BirdsEye/firmware_ota.ino | 49 ++++++++++++--- CHANGELOG.md | 25 ++++++++ CLAUDE.md | 4 +- docs/plans/0000-firmware-ota-phase0.md | 8 +++ docs/plans/0004-sd-direct-ota-staging.md | 78 ++++++++++++++++++++---- 6 files changed, 160 insertions(+), 24 deletions(-) diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index 155da3a..7b29ed2 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -139,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)" @@ -162,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/BirdsEye/firmware_ota.ino b/BirdsEye/firmware_ota.ino index 65c068d..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; diff --git a/CHANGELOG.md b/CHANGELOG.md index fa128b4..b533ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,31 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 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 diff --git a/CLAUDE.md b/CLAUDE.md index edd3a9e..5d27b52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1182,8 +1182,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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 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` | diff --git a/docs/plans/0000-firmware-ota-phase0.md b/docs/plans/0000-firmware-ota-phase0.md index 31166a3..9e7c134 100644 --- a/docs/plans/0000-firmware-ota-phase0.md +++ b/docs/plans/0000-firmware-ota-phase0.md @@ -29,6 +29,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` | | diff --git a/docs/plans/0004-sd-direct-ota-staging.md b/docs/plans/0004-sd-direct-ota-staging.md index 0104143..9d90004 100644 --- a/docs/plans/0004-sd-direct-ota-staging.md +++ b/docs/plans/0004-sd-direct-ota-staging.md @@ -1,9 +1,19 @@ # SD-Direct OTA Staging — Spike + the FWDFU Pre-Update -> Status: **SPIKE** — the pre-update (`FWDFU`) ships now on both channels; -> the SD-direct apply path is design + hardware validation work, not yet -> implemented. 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). +> 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**, not implemented | +> +> 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 @@ -15,7 +25,41 @@ 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. -## Chosen direction: stage from the SD card +## 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 @@ -80,11 +124,21 @@ The fleet insurance policy, shipped ahead of the rework so devices updated ## Sequencing -1. **Now**: `FWDFU` to `master` and `BETA` (this plan's PRs). Test on BETA. -2. **Spike**: the four hardware validations above, on bench hardware. -3. **Implement**: SD-direct apply behind the spike results; delete +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. **Next, no hardware needed**: publish a `.uf2` asset from `release.yml` / + `beta.yml`. `FWDFU` gets you into UF2 mode but there is currently no + published file to drag once you are there, which makes the escape hatch + only half-real. +4. **Spike**: the four hardware validations above, on bench hardware. +5. **Implement**: SD-direct apply behind the spike results; delete `FW_STAGE_BASE`/staging copy; CI OTA gate re-pointed at the app-region - bound. -4. **Someday/never**: if the spike fails hard, fall back to raising - `FW_MAX_IMAGE_SIZE` (staging-layout move, plan 0002 discussion) — the - options analysis lives in the PR #116 thread. + 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 now a +"do it properly when the bench is free" item, not a blocker. From f0e2178ab6bba53a31d57086e5f228eea11f6fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:00:52 +0000 Subject: [PATCH 23/36] docs(plan 0004): park SD-direct apply to end of project; DDV upgrade-path map Three updates to the OTA plan now that piece 2 (the even flash split) is merged on both channels and flashed clean on hardware: - Park piece 3 (SD-direct apply) to 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 crunch that justified rushing it is gone (93 KiB free against a beta image that grew ~7.5 KiB for all of sprint mode). Do it when the bench is free and nothing else is in flight. - Record the follow-up this raised: DovesDataViewer needs an upgrade-path MAP. Piece 2 created the first case where not every firmware can install every other one -- a 3.0.x device enforces the old 320 KiB cap, so a larger build must reach it over USB rather than OTA. Today that lives only in a CI warning; the web app will offer any release to any device and let it fail late at FWBEGIN. Scheme deliberately left undecided; candidates and their tradeoffs are listed, along with the prerequisite that historical builds stay fetchable (believed true for master releases, explicitly NOT for the latest-only beta channel). - Correct a wrong claim: the plan said no .uf2 was published, so FWDFU left you in UF2 mode with nothing to drag. Both release.yml and beta.yml already build and stage a per-board .uf2. The escape hatch was whole all along. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- docs/plans/0004-sd-direct-ota-staging.md | 68 +++++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/docs/plans/0004-sd-direct-ota-staging.md b/docs/plans/0004-sd-direct-ota-staging.md index 9d90004..8fb5a45 100644 --- a/docs/plans/0004-sd-direct-ota-staging.md +++ b/docs/plans/0004-sd-direct-ota-staging.md @@ -8,7 +8,7 @@ > |---|---|---|---| > | 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**, not implemented | +> | 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 @@ -115,9 +115,10 @@ The fleet insurance policy, shipped ahead of the rework so devices updated 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. -- Release channels should publish a `.uf2` asset alongside the existing - DFU `.zip` so there is always a file to drag (follow-up to the release - workflow when the first post-FWDFU release is cut). +- 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. @@ -130,15 +131,54 @@ The fleet insurance policy, shipped ahead of the rework so devices updated (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. **Next, no hardware needed**: publish a `.uf2` asset from `release.yml` / - `beta.yml`. `FWDFU` gets you into UF2 mode but there is currently no - published file to drag once you are there, which makes the escape hatch - only half-real. -4. **Spike**: the four hardware validations above, on bench hardware. -5. **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. +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 now a -"do it properly when the bench is free" item, not a blocker. +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. From 4192612548e2f3c5fc7c845ab6446b470d97866c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:46:41 +0000 Subject: [PATCH 24/36] docs: fix the DOVEX header column names to match what is emitted CLAUDE.md documented the metadata line as datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_ms,... but dovex_header.cpp:55 emits datetime,driver,course,short_name,best_lap_ms,optimal_ms,... Three of the eight names were wrong. This matters because the parser on the webapp side is key-mapped, not positional: DovesDataViewer's dovexParser reads `driver` / `course` / `optimal_ms` off the header row by name, so it matches the CODE. Anyone trusting the doc would have "fixed" the parser to look for names that never appear and silently blanked those fields for every log. Found while surveying the viewer for sprint-mode work (DovesDataViewer plan 0015, which records the discrepancy on its side too). README.md's copy had the right names but predates race_mode, so it stopped one column short. Add it, and extend the example row to match with CIRCUIT. Also drop the now-misaligned "(column labels)" / "(session metadata)" annotations from those two lines -- the content says what they are; laps_ms on line 3 does not, so it keeps its gloss. dovex_header.h:11 was already correct and is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- CLAUDE.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d27b52..fc74e06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1026,7 +1026,7 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. ### DOVEX Log (`.dovex` files) — New UI default ``` -datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_ms,device_name,race_mode +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 @@ -1036,7 +1036,7 @@ timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x - **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_lap_ms`, in that order). Appending keeps old logs + (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`, diff --git a/README.md b/README.md index 03e21ce..94cc91c 100644 --- a/README.md +++ b/README.md @@ -225,8 +225,8 @@ DOVEX files (`.dovex`) use a reserved **1 KB** header for session metadata, with **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 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 From 5d0da0bae2c12cf589c9f22e6b0318a4cec0c759 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:01:55 +0000 Subject: [PATCH 25/36] plan 0002: course-creator model and the firmware's first track-JSON writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pure units for the on-device course creator (§5). Everything that can be decided without hardware is decided here, so the sketch is left with rendering, GPS and SD. course_creator owns the model: which rows each screen shows, which lines a course type requires, whether the course may be saved yet, the point-averaging hold, and name generation. Navigation INPUT is left to the sketch's existing menuSelectionIndex/menuLimit machinery — the unit supplies the row count and interprets the chosen index, which avoids reimplementing a menu the firmware already has. Two save rules are about the webapp, not this device, and are worth naming: circuit sectors are all-or-nothing (its validator wants zero or exactly three majors), and sprint splits fill in order (it re-exports them positionally, so a lone sector 3 returns as a sector 2 after one sync). A course this device writes and that app then refuses to save is worse than one never written. Capture averages rather than snapshots — the user is standing at the cone anyway. A hold that gathers fewer than eight usable fixes FAILS instead of averaging noise into a timing line, fixes worse than 10 m are dropped, and fixes arriving after the window are ignored so a mean already shown to the user cannot shift underneath them. Names are "N{YYMMDD}_{HHMM}". §5 proposed a literal NEWTRACK_ prefix and noted in the same breath that the 13-char track browser would truncate it — every same-day creation would then render identically on-device, which defeats picking one. Favouring the timestamp resolves that note: unique to the minute, chronologically sortable, still obviously generated. The 8-char short name is exactly the webapp's Track.shortName budget and half of the (kind, shortName) key its sync merge uses. track_json emits the object format parseTrackFile() already reads. It builds text by hand because the coordinate formatting is the hard part either way: this core has no working "%f" in snprintf (the sketch reaches for dtostrf everywhere for that reason) and dtostrf does not exist on the host. formatFixed does it with integer math instead — identical on both targets and testable to the last digit, the same reasoning behind gps_time's hand-rolled u64ToDecimalString. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/course_creator.cpp | 370 +++++++++++++++++++++++++++++++ BirdsEye/course_creator.h | 296 +++++++++++++++++++++++++ BirdsEye/track_json.cpp | 229 +++++++++++++++++++ BirdsEye/track_json.h | 84 +++++++ tests/CMakeLists.txt | 4 + tests/course_creator_test.cpp | 401 ++++++++++++++++++++++++++++++++++ tests/track_json_test.cpp | 227 +++++++++++++++++++ 7 files changed, 1611 insertions(+) create mode 100644 BirdsEye/course_creator.cpp create mode 100644 BirdsEye/course_creator.h create mode 100644 BirdsEye/track_json.cpp create mode 100644 BirdsEye/track_json.h create mode 100644 tests/course_creator_test.cpp create mode 100644 tests/track_json_test.cpp 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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c1ae27..3d0385d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,6 +30,8 @@ add_executable(birdseye_tests 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 @@ -49,6 +51,8 @@ add_executable(birdseye_tests ${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/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); +} From 4c1ea53a028c7ffed9dfa702c7d5c6600b405790 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:02:15 +0000 Subject: [PATCH 26/36] plan 0002: write a walked course to the SD card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdSaveCreatedCourse lives with the rest of the track file I/O. A new track is one emitted object written straight out; an append is a read-modify-write through the existing 4 KB trackJson document. The append serializes to .tmp and renames over the original only once it is closed. Rewriting in place would mean a power loss or a yanked card 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. Both on-disk shapes are appendable: the object format's "courses" array and the legacy bare array, which IS the course list. Two refusals rather than a silent half-success: past MAX_LAYOUTS the device would write a course it then never loads, and an ArduinoJson overflow means the file would no longer fit the parse budget on the next boot. buildTrackList() re-runs after a successful write, since a course missing from the manifest does not exist as far as proximity detection is concerned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/sd_functions.h | 39 +++++++++ BirdsEye/sd_functions.ino | 177 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/BirdsEye/sd_functions.h b/BirdsEye/sd_functions.h index 4d1eaaa..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 @@ -93,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 9776008..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_*) @@ -571,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; +} From 92b968fbc5b9b2714faf760a8423e3461ca4c225 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:02:15 +0000 Subject: [PATCH 27/36] =?UTF-8?q?plan=200002:=20Create=20Course=20on=20the?= =?UTF-8?q?=20main=20menu=20=E2=80=94=20five=20screens=20over=20the=20mode?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sketch side of the course creator: five page constants, the live model instance, GPS feeding, and the renderers. Glue is distributed the way gps_status_page and sd_format_page already are — pages in display_pages, routing in display_ui, state and helpers in BirdsEye.ino — rather than adding a module for one feature. Every row rendered comes from course_creator::rowAt() instead of a local list, so a row cannot display in one order and act in another. The Save row says WHY it is refused rather than being a button that silently does nothing. Entry needs a fix and a time lock, and refuses at the menu: every screen past the prompt needs GPS to capture and the clock to name the file, so failing here beats failing after a whole course has been walked. Feeding the averaging hold needed a new monotonic gpsPvtSequence. gpsDataFresh could not do 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 folded one fix in ~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 line-menu header abbreviates the course type so the track name survives whole — 21 characters at size 1, and a track name may use 13 of them. The name is the part that answers "am I adding this to the right track?". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/BirdsEye.ino | 220 +++++++++++++++++++++++++++++++++++++ BirdsEye/display_pages.h | 8 ++ BirdsEye/display_pages.ino | 217 +++++++++++++++++++++++++++++++++++- BirdsEye/display_ui.ino | 40 ++++++- BirdsEye/gps_functions.ino | 1 + 5 files changed, 482 insertions(+), 4 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index dc98552..d27b65f 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -82,6 +82,7 @@ #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" @@ -346,6 +347,14 @@ struct 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 @@ -497,6 +506,25 @@ bool enableLogging = false; 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"; @@ -596,6 +624,13 @@ 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; @@ -1493,6 +1528,190 @@ void sdFormatPageLoop() { } } +/////////////////////////////////////////// +// 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). * @@ -1984,6 +2203,7 @@ void loop() { 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(); diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 1d336c4..6569bad 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -34,6 +34,14 @@ 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(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index d390ee7..1dad34d 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -117,7 +117,7 @@ void displayPage_main_menu() { // 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"}; + static const char* const kMenuItems[] = {"Race", "Review", "Transfer", "Create", "Camera"}; const int itemCount = (int)(sizeof(kMenuItems) / sizeof(kMenuItems[0])); const int visibleRows = 3; @@ -1234,3 +1234,218 @@ void displayCrossing() { 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 9cdcc9c..3eb41f9 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -283,6 +283,12 @@ void displaySetup() { } 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 @@ -308,6 +314,18 @@ void handleMenuPageSelection() { // 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")); @@ -567,6 +585,16 @@ void displayLoop() { 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) { @@ -653,11 +681,16 @@ void displayLoop() { // 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 + currentPage == PAGE_CAMERA_TEST || + courseCreatorActive() ) { insideMenu = true; if (currentPage == PAGE_MAIN_MENU) { - menuLimit = 4; // Race, Review, Transfer, Camera + 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) { @@ -698,7 +731,8 @@ void displayLoop() { // Power Off) (#7). bool reverseDirection = (currentPage == PAGE_MAIN_MENU || currentPage == PAGE_PAIR_CAMERA || - currentPage == PAGE_CAMERA_TEST); + currentPage == PAGE_CAMERA_TEST || + courseCreatorActive()); // BUTTON UP (or DOWN for reversed menus) if (btn1->pressed) { diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index cc71f48..cf6b593 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -219,6 +219,7 @@ void onPVTReceived(UBX_NAV_PVT_data_t *pvt) { gpsData.milliseconds = (pvt->iTOW % 1000); // ms from GPS time-of-week gpsDataFresh = true; + gpsPvtSequence++; // monotonic; for consumers that run after GPS_LOOP() gpsFrameCounter++; } From 51e0e603d9bbbf1541e801b67aa87eb97f2b0ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:02:34 +0000 Subject: [PATCH 28/36] plan 0002: golden-test the course creator through the real menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sim compiles the same .ino sources the Arduino build concatenates, so it is the only compile check this repo has outside CI — and with PVT injection it can drive the creator for real rather than just build it. Five new fixtures walk the actual menus, inject actual fixes, run an actual 3 s averaging hold, and lock the rendered pixels: the no-GPS refusal, the type picker, an empty line menu with Save refused, the line detail, the idle capture screen, and the line detail again with point A captured. That last one is the one worth having — it proves the hold completes and commits through the real loop, which is exactly the path the gpsPvtSequence fix was about. The existing camera fixtures moved because Create Course took index 3 on the main menu; their hashes are unchanged. main_menu_transfer's hash moved because the menu gained an item. The SdFat shim gains rename(), which the append path needs. It refuses to clobber an existing destination like the real SdFat does, so the firmware's remove-then-rename ordering stays load-bearing in the sim too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/sim/CMakeLists.txt | 2 + BirdsEye/sim/golden/golden_hashes.txt | 9 ++- BirdsEye/sim/golden_main.cpp | 90 ++++++++++++++++++++++++++- BirdsEye/sim/sdfat_shim/SdFat.h | 3 + BirdsEye/sim/sdfat_shim/sim_vfs.cpp | 13 ++++ BirdsEye/sim/sim_prototypes.h | 12 ++++ 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index e21c1b6..e899116 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -121,6 +121,7 @@ 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 @@ -135,6 +136,7 @@ set(SIM_CORE_SOURCES ${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 diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index d1500c9..3e306dd 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -1,8 +1,15 @@ gps_status_no_fix 900 fb82cebc 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 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..ea5125c 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -78,8 +78,41 @@ 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; +// 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. +void injectFix(int frames, double lat, double lon, double hAccM = 1.2) { + 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 = 0.0; + 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 +144,60 @@ 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); + + // 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); + + press(2); press(2); press(2); press(2); @@ -121,7 +205,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 7c3a94a..28b85a9 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -29,6 +29,7 @@ #include "SdFat.h" +#include "course_creator.h" #include "gps_status_page.h" #include "sd_format_page.h" #include "wake_cause.h" @@ -65,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(); @@ -83,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(); From 6712205af2cb8b5a07fe31307728371605ba5c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:02:34 +0000 Subject: [PATCH 29/36] plan 0002: document the course creator and close out the sprint plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds subsystem 15 to CLAUDE.md (file map, page constants, key constants), the ARCHITECTURE subsystem entry, and the CHANGELOG feature entry. Plan 0002 gains a §5.1 recording what was actually built and, more usefully, where it departs from the spec and why: the name format (resolving §5's own note that the browser truncates a NEWTRACK_ prefix), the two webapp-compatibility save rules, scratch-then-commit line edits, the failable capture hold, the temp-file append, and the new gpsPvtSequence global. The generated name and short-name formats are called out for the webapp's import flow, which is being designed now — they are the contract it should match. The plan's status moves from CONCEPT to SHIPPED: all three repos have landed their phase. The two deliberately-deferred items (Android IPC parity, sync-prune) are noted, with the observation that the creator makes pruning matter more — every event walked appends a course to a file the device re-parses through a 4 KB budget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- ARCHITECTURE.md | 10 +++- CHANGELOG.md | 26 +++++++++++ CLAUDE.md | 60 ++++++++++++++++++++++++ docs/plans/0002-sprint-mode.md | 84 +++++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 828b41a..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. @@ -109,6 +110,13 @@ to the matching `*_LOOP()`. pages are compiled out, BLE returns to lazy init, and the DOVEX `Temp1`/`Junction1`/`Temp2` columns are written as `nan` so the log format stays identical across channels. +- **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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b533ef0..64e787c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,32 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### 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 diff --git a/CLAUDE.md b/CLAUDE.md index fc74e06..03b3c06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,8 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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 | | `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 | @@ -420,6 +422,11 @@ loop() ~250 Hz - 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). @@ -1019,6 +1026,55 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. 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 @@ -1159,6 +1215,10 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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` | +| 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 | `BirdsEye.ino` | | Tach ring buffer | 16 entries | `BirdsEye.ino` | | Tach Kalman Q | 800 RPM² | `tach_filter.h` | diff --git a/docs/plans/0002-sprint-mode.md b/docs/plans/0002-sprint-mode.md index 3dff2d4..849481f 100644 --- a/docs/plans/0002-sprint-mode.md +++ b/docs/plans/0002-sprint-mode.md @@ -1,9 +1,19 @@ # Sprint Mode (Autocross / Point-to-Point) — Concept & Cross-Repo Roadmap -> Status: **CONCEPT — design complete, implementation-ready.** All §7 -> questions are decided; no implementation yet. +> 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 @@ -264,6 +274,12 @@ track the library's BETA branch in CI, so co-development is wired). ## 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 @@ -320,6 +336,70 @@ New main-menu option **"Create Course"**: - 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 From cf9f028db9853fdbbd95ba39cd10a7c441fe9c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:49:28 +0000 Subject: [PATCH 30/36] fix: say which GPS milestone is outstanding on the status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "FIX (time sync)" parses as a KIND of fix — a time-only, position-less one — when it meant the opposite: the position fix is good and the clock isn't ready yet. So a healthy device looked broken. That cost a bench session today, and the instinctive response to it, a power cycle, is actively harmful: it restarts the ~12.5-minute UTC decode being waited on. The line now reads "FIX ok UTC..", and line 3 names the outstanding milestone instead of leaving the user with nothing to wait for: "UTC: no date/time", then "UTC: resolving <=12m" once date and time are valid but fullyResolved is not. That second state is the slow, normal one — the leap-second parameters live in nav-message subframe 4 page 18, which repeats every ~12.5 minutes, so a clean 3D fix minutes ahead of timeValid is expected rather than a fault. The bound is worst case, not an estimate; seeing a number at all is what stops the power-cycling. Nothing is lost: the constellation readout keeps that line once the clock is locked, so the diagnostic only takes the space while there is something to diagnose. gpsData gains the two halves of timeValid so the page can tell them apart, and the three-way classification is a pure timeSyncState() in gps_status_page rather than branching in the renderer. One of its tests asserts kLocked agrees with timeValid across all four input combinations — a page claiming a lock while logging still waits would be the same confusion wearing a different hat. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/BirdsEye.ino | 6 +++++ BirdsEye/display_pages.ino | 27 ++++++++++++++++--- BirdsEye/gps_functions.ino | 7 ++--- BirdsEye/gps_status_page.cpp | 9 +++++++ BirdsEye/gps_status_page.h | 28 +++++++++++++++++++ BirdsEye/sim/golden/golden_hashes.txt | 2 +- CHANGELOG.md | 19 +++++++++++++ CLAUDE.md | 3 ++- tests/gps_status_page_test.cpp | 39 +++++++++++++++++++++++++++ 9 files changed, 131 insertions(+), 9 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index d27b65f..7734f17 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -336,6 +336,12 @@ struct GpsData { 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; diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 1dad34d..4723c2b 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -69,7 +69,12 @@ void displayPage_gps_status() { display.println(countdown); } else { if (gpsData.fix) { - display.print(F("FIX (time sync) ")); + // "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 ")); } @@ -80,9 +85,23 @@ void displayPage_gps_status() { 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")); + // 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(); diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index cf6b593..3b0139c 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -207,9 +207,10 @@ void onPVTReceived(UBX_NAV_PVT_data_t *pvt) { // 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.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; 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/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index 3e306dd..ae85ada 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -1,4 +1,4 @@ -gps_status_no_fix 900 fb82cebc +gps_status_no_fix 900 f2276ffc main_menu_race -1 34b50cd7 main_menu_transfer -1 40da09f8 transfer_menu -4 3013bbe2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e787c..38469b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Fixed +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index 03b3c06..002021c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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 | +| `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 | @@ -1197,6 +1197,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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` | 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); + } +} From 6cec29d6a537b131215e44f4c2f4650da2704f54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:49:28 +0000 Subject: [PATCH 31/36] fix: say which GPS milestone is outstanding on the status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "FIX (time sync)" parses as a KIND of fix — a time-only, position-less one — when it meant the opposite: the position fix is good and the clock isn't ready yet. So a healthy device looked broken. That cost a bench session today, and the instinctive response to it, a power cycle, is actively harmful: it restarts the ~12.5-minute UTC decode being waited on. The line now reads "FIX ok UTC..", and line 3 names the outstanding milestone instead of leaving the user with nothing to wait for: "UTC: no date/time", then "UTC: resolving <=12m" once date and time are valid but fullyResolved is not. That second state is the slow, normal one — the leap-second parameters live in nav-message subframe 4 page 18, which repeats every ~12.5 minutes, so a clean 3D fix minutes ahead of timeValid is expected rather than a fault. The bound is worst case, not an estimate; seeing a number at all is what stops the power-cycling. Nothing is lost: the constellation readout keeps that line once the clock is locked, so the diagnostic only takes the space while there is something to diagnose. gpsData gains the two halves of timeValid so the page can tell them apart, and the three-way classification is a pure timeSyncState() in gps_status_page rather than branching in the renderer. One of its tests asserts kLocked agrees with timeValid across all four input combinations — a page claiming a lock while logging still waits would be the same confusion wearing a different hat. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/BirdsEye.ino | 6 +++++ BirdsEye/display_pages.ino | 27 ++++++++++++++++--- BirdsEye/gps_functions.ino | 7 ++--- BirdsEye/gps_status_page.cpp | 9 +++++++ BirdsEye/gps_status_page.h | 28 +++++++++++++++++++ BirdsEye/sim/golden/golden_hashes.txt | 2 +- CHANGELOG.md | 19 +++++++++++++ CLAUDE.md | 3 ++- tests/gps_status_page_test.cpp | 39 +++++++++++++++++++++++++++ 9 files changed, 131 insertions(+), 9 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index dc98552..0b93d05 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -335,6 +335,12 @@ struct GpsData { 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; diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index d390ee7..5ee6ccd 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -69,7 +69,12 @@ void displayPage_gps_status() { display.println(countdown); } else { if (gpsData.fix) { - display.print(F("FIX (time sync) ")); + // "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 ")); } @@ -80,9 +85,23 @@ void displayPage_gps_status() { 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")); + // 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(); diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index cc71f48..0641010 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -207,9 +207,10 @@ void onPVTReceived(UBX_NAV_PVT_data_t *pvt) { // 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.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; 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/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index d1500c9..c0f320c 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -1,4 +1,4 @@ -gps_status_no_fix 900 fb82cebc +gps_status_no_fix 900 f2276ffc main_menu_race -1 34b50cd7 main_menu_transfer -1 c79664ba transfer_menu -4 3013bbe2 diff --git a/CHANGELOG.md b/CHANGELOG.md index b533ef0..1091813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Fixed +- **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 - **BLE sprint-track sync — `TSLIST` / `TSGET:` / `TSPUT:` / `TSDEL:`** (plan 0002). The four existing track verbs gained `TS`-prefixed twins that diff --git a/CLAUDE.md b/CLAUDE.md index fc74e06..8785326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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) | | `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 | +| `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 | @@ -1141,6 +1141,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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` | 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); + } +} From a3282af13df4191018f3ae4b49d503113ee54edc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:01:27 +0000 Subject: [PATCH 32/36] fix: don't let auto-race hijack the menu the instant you land on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the bench: exiting the course creator dropped straight into race mode. The reporter's own guess was right — they were on a bike, above the 10 mph auto-race trigger, when they hit Cancel. autoRaceModeCheck() guarded only on "are we on the main menu", with no notion of WHEN we got there. displayLoop() switches the page at the end of one loop iteration and autoRaceModeCheck() runs at the top of the next, so a deliberate exit became "start racing" about four milliseconds later. The menu is never drawn; the device appears to act on its own. This was always true for every page, but the creator is what made it reachable: it is the one screen you use out on the course, on a vehicle that may well be rolling, and the save path lands on the menu too — so finishing a walked course could immediately start a session. Auto-race now requires the menu to have been settled for AUTO_RACE_MENU_GRACE_MS, anchored on the newest of the menu-arrival stamp and the three button lastPressed values. Using the button stamps as well as arrival means actively navigating the menu at speed defers it too, which is the same "a human is driving the UI, not the vehicle" signal. They persist across iterations (the menu-idle block relies on this already), so the guard doesn't care where in loop() it runs. The normal auto-race path is untouched: a device parked on the menu has been quiet for minutes before anyone drives off. The cost is up to three seconds of a session that starts by leaving a menu — and only when the user was pressing buttons moments earlier. switchToDisplayPage() is a safe place to stamp arrival: the direct `currentPage =` assignments elsewhere are all race-page rotation clamps, never the menu. The golden walk now exits the creator at 15 mph, which reproduces the bug exactly — with the guard removed the fixture fails "expected page -1, got 5" as the firmware logs "Auto-entering race mode". Verified by removing it. It coasts back to 0 mph afterwards, since gpsData holds its last value between PVTs and a latched 15 mph would trip auto-race once the window expired. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/BirdsEye.ino | 22 ++++++++++++++++++++++ BirdsEye/display_ui.ino | 6 ++++++ BirdsEye/project.h | 7 +++++++ BirdsEye/sim/golden_main.cpp | 22 +++++++++++++++++++--- CHANGELOG.md | 12 ++++++++++++ CLAUDE.md | 9 ++++++++- 6 files changed, 74 insertions(+), 4 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 7734f17..37c558d 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -184,6 +184,12 @@ char dovexReplayOptimal[16]; 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; @@ -1424,6 +1430,22 @@ 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; diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 3eb41f9..34c6a4a 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -237,6 +237,12 @@ void forceDisplayRefresh() { } 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(); } diff --git a/BirdsEye/project.h b/BirdsEye/project.h index 395bc49..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 diff --git a/BirdsEye/sim/golden_main.cpp b/BirdsEye/sim/golden_main.cpp index ea5125c..7c184cc 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -92,8 +92,10 @@ 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. -void injectFix(int frames, double lat, double lon, double hAccM = 1.2) { +// 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 @@ -102,7 +104,7 @@ void injectFix(int frames, double lat, double lon, double hAccM = 1.2) { p.lat = lat; p.lng = lon; p.altitude_m = 100.0; - p.speed_mph = 0.0; + p.speed_mph = speedMph; p.heading_deg = 0.0; p.h_acc_m = hAccM; p.hdop = 0.8; @@ -184,6 +186,15 @@ void runScript() { 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); @@ -197,6 +208,11 @@ void runScript() { 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); + press(2); press(2); press(2); diff --git a/CHANGELOG.md b/CHANGELOG.md index 38469b7..8283662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Fixed +- **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. + ### Fixed - **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 diff --git a/CLAUDE.md b/CLAUDE.md index 002021c..8ddb45d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -589,7 +589,13 @@ loop() ~250 Hz 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. + 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 @@ -1215,6 +1221,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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` | From e8d9a5bf4a5fd9382ae2d0bc4e1baa003517931f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:21:21 +0000 Subject: [PATCH 33/36] fix: show the crossing animation only on racing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the bench: setting a course-creator point while parked near an existing timing line showed the crossing flags over the creator screen. The overlay was gated by a BLOCKLIST — six pages it must not draw over — which meant it drew over everything else by default. That list never grew as pages were added, so the camera pages, the replay browser, the transfer menus, the main menu and (newest) the course creator all inherited it. And the trigger is not rare: the crossing zone reads true while STATIONARY inside it, which is precisely the state of someone standing at a timing line using the device. Inverted to a positive test. The running rotation is a contiguous id block, so "is this a racing page" is a range check; the two diagnostic pages at the bottom and the stop-logging page at the top stay excluded exactly 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. Strictly more restrictive than the old condition on every input. The new golden fixture parks on the OKC start/finish line with the track detected and locks that the menu still renders as a menu. It is NOT a regression test for this bug and says so: a true crossing flag needs an ARMED timer, and OKC ships eight courses so CourseDetector never locks one without driving a real lap. Confirmed by restoring the old blocklist — the fixture does not budge. The gate itself is argued from the page-id ranges. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/display_ui.ino | 22 ++++++++++++++++------ BirdsEye/sim/golden/golden_hashes.txt | 1 + BirdsEye/sim/golden_main.cpp | 18 ++++++++++++++++++ CHANGELOG.md | 11 +++++++++-- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 34c6a4a..a420fbd 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -565,13 +565,23 @@ void displayLoop() { 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 ( - currentPage != GPS_STATS && - currentPage != GPS_DEBUG && - currentPage != LOGGING_STOP && - currentPage != LOGGING_STOP_CONFIRM && - currentPage != PAGE_INTERNAL_FAULT && - currentPage != PAGE_INTERNAL_WARNING && + onRacingPage && isCrossing && inEndurance == false ) { diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index ae85ada..aafdb2b 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -11,5 +11,6 @@ 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 7c184cc..c65a47a 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -85,6 +85,11 @@ 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.) @@ -213,6 +218,19 @@ void runScript() { // 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 8283662..f9eb962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### 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 @@ -23,8 +32,6 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 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. - -### Fixed - **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 From a440cd8d81419ad2f1f966e98255dc365ecb6b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:11:18 +0000 Subject: [PATCH 34/36] feat(tach): true RPM from spark mode and cylinder count (plan 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pickup counts ignition sparks and the tach treated one spark as one revolution. That is only true for a single cylinder firing every rev — a 2-stroke, or a 4-stroke with wasted spark — which is the common kart case and why it has been fine. Anything else is out by a fixed factor: a twin firing every rev reads DOUBLE the real speed. Two settings fix it. pulses_per_rev = cylinder_count x (wasted ? 1.0 : 0.5), and the reciprocal 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^2 models crank inertia), so correcting afterwards would filter each engine type differently. The correction point already existed and was already in the right place; it was just hardcoded to 1.0. Defaults (1 cylinder, wasted) give exactly 1.0, so a device that has never been configured reads identically to before. Anything other than an explicit "single" degrades to wasted, so a blank, garbled or future value reads as today rather than doubling every RPM. THE DEBOUNCE HAD TO FOLLOW. A fixed 3 ms gap caps ~20,000 pulses/min, which on a twin firing every rev is only ~10,000 real RPM — the debounce would have become the ceiling. minPulseGapUs() derives it as 3 ms / pulses-per-rev. The floor is 750 us, not the 1.5 ms the plan sketched: 1.5 ms still left a triple at ~13,300 true RPM, under the old ceiling. 750 us holds the full ~20,000 through four cylinders. ISR headroom was never the constraint (<1 us body, ~1300 int/s worst case) — ringing was, and the margin holds from both ends: the input is RC-filtered ~100 us and the documented pickup circuits emit pulses MILLISECONDS wide, TACHOMETER/README.md recording circuit 1's 5 ms pulse as itself the ~9800 RPM limit on that hardware. The audit plan 0003 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. Knock-on: the RPM thresholds the device acts on — auto-race entry, camera wake/record/stop — now mean what they say on every engine. Verified end to end in the simulator, not just at the unit level: 6000 pulses/min reports 6000 RPM at the defaults and 3000 with revsPerPulse forced to a twin. Golden fixtures and the lap oracle are unchanged, which is the evidence existing devices are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/BirdsEye.ino | 44 ++++++++- BirdsEye/settings.ino | 5 + BirdsEye/tach_filter.cpp | 27 +++++ BirdsEye/tach_filter.h | 60 +++++++++++ CHANGELOG.md | 21 ++++ CLAUDE.md | 31 +++++- README.md | 6 +- .../plans/0003-rpm-spark-cylinder-settings.md | 53 ++++++++-- tests/tach_filter_test.cpp | 99 +++++++++++++++++++ 9 files changed, 327 insertions(+), 19 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 37c558d..3865458 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -96,6 +96,7 @@ #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" @@ -288,9 +289,14 @@ 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; +// 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. @@ -306,8 +312,12 @@ 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 +// 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 /////////////////////////////////////////// @@ -868,6 +878,30 @@ void setup() { 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); diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index e98684c..3000619 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -106,6 +106,11 @@ static void ensureDefaultSettings() { { "waypoint_speed", "30" }, { "camera_serial", "" }, // empty = no Insta360 paired { "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range + // Engine geometry for true RPM (plan 0003). These defaults are exactly the + // old hardcoded behaviour, so auto-populating them on an existing device + // changes nothing until the user says otherwise. + { "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/tach_filter.cpp b/BirdsEye/tach_filter.cpp index d6f55c7..d5629b6 100644 --- a/BirdsEye/tach_filter.cpp +++ b/BirdsEye/tach_filter.cpp @@ -30,4 +30,31 @@ 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. +float pulsesPerRev(int cylinderCount, bool wastedSpark) { + if (cylinderCount < kMinCylinders) cylinderCount = kMinCylinders; + if (cylinderCount > kMaxCylinders) cylinderCount = kMaxCylinders; + return (float)cylinderCount * (wastedSpark ? kPulsesPerRevWasted : kPulsesPerRevSingle); +} + +} // namespace + +float revsPerPulse(int cylinderCount, bool wastedSpark) { + return 1.0f / pulsesPerRev(cylinderCount, wastedSpark); +} + +uint32_t minPulseGapUs(int cylinderCount, bool wastedSpark) { + const float ppr = pulsesPerRev(cylinderCount, wastedSpark); + const float gap = (float)kBasePulseGapUs / ppr; + if (gap < (float)kMinPulseGapFloorUs) return kMinPulseGapFloorUs; + // 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 (uint32_t)(gap + 0.5f); +} + } // 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/CHANGELOG.md b/CHANGELOG.md index f9eb962..6364fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 8ddb45d..d499e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `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) | +| `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 | @@ -298,7 +298,9 @@ loop() ~250 Hz ### 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). +- 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 @@ -312,8 +314,24 @@ loop() ~250 Hz `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 +- 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. @@ -1168,6 +1186,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. "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", @@ -1186,6 +1206,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | `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()`. @@ -1227,7 +1249,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | 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 | `BirdsEye.ino` | +| 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` | diff --git a/README.md b/README.md index 94cc91c..f9fe15d 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,9 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto "driver_name": "Driver", "lap_detection_distance": "7", "waypoint_detection_distance": "30", - "waypoint_speed": "30" + "waypoint_speed": "30", + "spark_mode": "wasted", + "cylinder_count": "1" } ``` @@ -217,6 +219,8 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto | `lap_detection_distance` | Crossing detection threshold in meters | `7` | | `waypoint_detection_distance` | Waypoint proximity zone in meters (Lap Anything) | `30` | | `waypoint_speed` | Minimum speed in mph to activate lap timing | `30` | +| `spark_mode` | How often the ignition fires: `wasted` = once per revolution (2-stroke, or 4-stroke wasted spark), `single` = once per two revolutions (4-stroke single-fire) | `wasted` | +| `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 diff --git a/docs/plans/0003-rpm-spark-cylinder-settings.md b/docs/plans/0003-rpm-spark-cylinder-settings.md index 4b86ad6..52be932 100644 --- a/docs/plans/0003-rpm-spark-cylinder-settings.md +++ b/docs/plans/0003-rpm-spark-cylinder-settings.md @@ -1,9 +1,13 @@ # RPM Accuracy — Spark Type & Cylinder Count Settings -> Status: **CONCEPT** — split out of plan 0002 (sprint mode); independent of +> 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 @@ -63,11 +67,18 @@ devices are unaffected by `ensureDefaultSettings()` auto-populating the keys. 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**: the 3 ms minimum pulse gap caps at ~20 000 - pulses/min. At divisor 2 (2-cyl every-rev) that's only **~10 000 true - RPM** — a screaming 2-cyl 2T could exceed it. The min-gap likely needs to - derive from `pulses_per_rev` (e.g. 3 ms ÷ ppr, floor ~1.5 ms) — verify - ISR headroom before lowering. +- **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 @@ -84,6 +95,30 @@ nothing else right now — plain string works until then). ## Status -Concept only. No dependency on plan 0002 — can ship before or after sprint -mode. Should ship *before* any camera/auto-race tuning work for multi-cyl -engines. +**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/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); +} From fb0023a65f4ef26955bbc6d27b67c5f3f3a394a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:18:04 +0000 Subject: [PATCH 35/36] fix(tach): derive the pulse gap with integer math, not a rounding cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clang-tidy caught bugprone-incorrect-roundings on (uint32_t)(gap + 0.5f) — a real finding, not a false positive: that idiom rounds incorrectly for negatives and is a known bug class. lroundf would have silenced it, but the float was never needed. Pulses-per-rev is either `cylinders` (wasted spark) or `cylinders / 2` (single-fire), so the base gap divides exactly in both cases once the single-fire case is written as a doubled numerator. Integer throughout keeps float rounding out of a value the ISR compares against on every pulse, and drops a libm call from the firmware. Same numbers as before, so the tests are unchanged and still pass: 3000 / 1500 / 1000 / 750 for one through four cylinders wasted, 6000 for a single-fire single, floored at 750. Verified with the exact CI invocation locally — clang-tidy is clean across all eleven analyzed units, not just this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- BirdsEye/tach_filter.cpp | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/BirdsEye/tach_filter.cpp b/BirdsEye/tach_filter.cpp index d5629b6..0a7a4dc 100644 --- a/BirdsEye/tach_filter.cpp +++ b/BirdsEye/tach_filter.cpp @@ -35,26 +35,35 @@ 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. -float pulsesPerRev(int cylinderCount, bool wastedSpark) { - if (cylinderCount < kMinCylinders) cylinderCount = kMinCylinders; - if (cylinderCount > kMaxCylinders) cylinderCount = kMaxCylinders; - return (float)cylinderCount * (wastedSpark ? kPulsesPerRevWasted : kPulsesPerRevSingle); +int clampCylinders(int cylinderCount) { + if (cylinderCount < kMinCylinders) return kMinCylinders; + if (cylinderCount > kMaxCylinders) return kMaxCylinders; + return cylinderCount; } } // namespace float revsPerPulse(int cylinderCount, bool wastedSpark) { - return 1.0f / pulsesPerRev(cylinderCount, wastedSpark); + const float pulsesPerRev = (float)clampCylinders(cylinderCount) * + (wastedSpark ? kPulsesPerRevWasted : kPulsesPerRevSingle); + return 1.0f / pulsesPerRev; } uint32_t minPulseGapUs(int cylinderCount, bool wastedSpark) { - const float ppr = pulsesPerRev(cylinderCount, wastedSpark); - const float gap = (float)kBasePulseGapUs / ppr; - if (gap < (float)kMinPulseGapFloorUs) return kMinPulseGapFloorUs; + 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 (uint32_t)(gap + 0.5f); + return gap < kMinPulseGapFloorUs ? kMinPulseGapFloorUs : gap; } } // namespace tach_filter From e002779e4ee84cf020060a72d0ee73d7e1dc1f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:35:07 +0000 Subject: [PATCH 36/36] docs: record what field use settled in OTA Phase 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 0000 still read as five unvalidated spikes gating a field release. The apply path shipped and has been flashing loggers over the air for weeks, including an end-to-end run on a bench vehicle, so most of that is now answered by demonstration: - #2 (erase/write upper flash with the SoftDevice up) and #3 (the RAM flasher erasing the app region, copying the staged image, and booting it) are exercised in full by every successful update. - #5's soft-reset half likewise. Its power-loss half is not independently testable and does not need to be — GPREGRET being LOST on brownout is the premise, which is exactly why that case falls through to #1. - #4 was only ever contingent on #3 failing. It didn't, so #4 is moot; kept for the record as the escape route if plan 0004 piece 3 ever destabilises the swap. #1, the recovery net, is the one still open, and the point worth being precise about: a SUCCESSFUL update can never prove it. It only fires on an interrupted one — half-written vector table, or power lost mid-swap — and every update so far has succeeded. On sealed units that is the difference between a recoverable brick and a brick. Added an explicit "the one test still outstanding" section so the remaining scope is a four-step bench procedure rather than something to infer from prose, including the honest consequence if it fails (the dual-bank migration becomes necessary before shipping more sealed hardware) and the note that it wants doing BEFORE plan 0004 piece 3 touches the swap. Spike #3's framing was also actively misleading — written as "the spike that decides whether to ship the RAM flasher", which was answered by shipping it. CLAUDE.md said the apply path needs Phase 0 signed off before field release, which is no longer true and would mislead anyone reading the subsystem docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- CLAUDE.md | 10 ++- docs/plans/0000-firmware-ota-phase0.md | 92 ++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ddb45d..d11abbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -747,9 +747,13 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. 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/plans/0000-firmware-ota-phase0.md`. + 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. diff --git a/docs/plans/0000-firmware-ota-phase0.md b/docs/plans/0000-firmware-ota-phase0.md index 9e7c134..32fd0e8 100644 --- a/docs/plans/0000-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) @@ -54,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. @@ -70,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 @@ -85,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 @@ -99,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* @@ -117,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 @@ -136,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