diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 4f6c048..6c6701d 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -88,7 +88,9 @@ jobs: "SparkFun u-blox GNSS v3" \ "Seeed Arduino LSM6DS3" \ "ArxTypeTraits" - arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git + # Beta builds track the lap-timer library's own BETA branch so the + # two beta channels move together (master/release pin a tag). + arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#BETA pip install --user adafruit-nrfutil - name: Compile diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index f88e8a4..a777e1a 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -35,4 +35,7 @@ jobs: BirdsEye/gps_time.cpp \ BirdsEye/gps_validation.cpp \ BirdsEye/dovex_header.cpp \ - BirdsEye/filename_validator.cpp + BirdsEye/filename_validator.cpp \ + BirdsEye/sd_access_policy.cpp \ + BirdsEye/lap_format.cpp \ + BirdsEye/tach_filter.cpp diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index 7b52ec9..627b2bd 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -12,6 +12,13 @@ jobs: compile: 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.1.0' }} # 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. @@ -52,6 +59,7 @@ jobs: - name: Seeed Arduino LSM6DS3 - name: ArxTypeTraits - source-url: https://github.com/TheAngryRaven/DovesLapTimer.git + version: ${{ env.LAPTIMER_REF }} sketch-paths: | - BirdsEye cli-compile-flags: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c61319..820d3da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,9 @@ jobs: "SparkFun u-blox GNSS v3" \ "Seeed Arduino LSM6DS3" \ "ArxTypeTraits" - arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git + # Release builds pin the lap-timer library to a known-good tag — + # bump deliberately. (Beta builds track its BETA branch instead.) + arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#v4.1.0 pip install --user adafruit-nrfutil - name: Compile diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd4cdab..78a90b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,9 +107,23 @@ the main loop, and the GPS library reads from that buffer. This is why The BLE callbacks run in a **separate FreeRTOS task** from `loop()`, and SdFat is not thread-safe. Every SD user (logging, replay, BLE transfer, track parsing) must take a single mutex via `acquireSDAccess(mode)` / -`releaseSDAccess(mode)`. BLE commands that need the card defer their work -into `BLUETOOTH_LOOP()` (main-loop context) instead of touching SdFat from -the callback. +`releaseSDAccess(mode)`. Two layers make this sound: + +1. **Atomic transitions.** `acquireSDAccess()` evaluates the grant rules + and commits the new owner inside a FreeRTOS critical section + (`taskENTER_CRITICAL`, BASEPRI-masked so the SoftDevice's radio + interrupts are untouched) — a plain check-then-set on the shared flag + would be a TOCTOU between the two tasks. The grant/deny decision table + itself (same-mode re-acquire is idempotent; the brief `TRACK_PARSE` + mode is preemptible as leak recovery) is the host-tested + `sd_access_policy` pure unit. +2. **Single-task SdFat.** *Every* BLE command that touches the card — + `LIST`/`GET`/`DELETE`, `TLIST`/`TGET`/`TPUT`/`TDEL`, settings, firmware + OTA — is parsed in the callback (filenames validated there, RAM only) + and executed by `BLUETOOTH_LOOP()` on the main loop. Nothing calls + SdFat from the Bluefruit callback task, so the filesystem only ever + has one task in it; directory listings hold the lock for the whole + walk, and `DELETE` refuses while a transfer is streaming. ### DOVEX crash safety A `.dovex` file reserves the first 1 KB for session metadata (driver, diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 6c7bbfb..4db0b5d 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -258,12 +258,17 @@ volatile uint32_t tachLastPulseUs = 0; volatile bool tachHavePeriod = false; // Ring buffer: ISR writes pulse timestamps, TACH_LOOP reads and computes periods. -// Single-producer (ISR writes head), single-consumer (TACH_LOOP reads tail). -// 16 entries handles up to 20k RPM with 48ms of main-loop stall margin. +// 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) -static uint8_t tachRingTail = 0; // Main-loop read index (only TACH_LOOP 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 @@ -380,16 +385,11 @@ File replayFile; /////////////////////////////////////////// // SD CARD ACCESS STATE MANAGEMENT -// Prevents race conditions between logging, replay, and BLE file transfers +// 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. /////////////////////////////////////////// -// Note: Using #define instead of enum to avoid Arduino preprocessor issues -// (Arduino generates function prototypes before seeing enum definitions) -#define SD_ACCESS_NONE 0 // SD card not in use by any subsystem -#define SD_ACCESS_LOGGING 1 // Data logging active (dataFile in use) -#define SD_ACCESS_REPLAY 2 // Replay mode active (replayFile in use) -#define SD_ACCESS_BLE_TRANSFER 3 // BLE file transfer active (bleCurrentFile in use) -#define SD_ACCESS_TRACK_PARSE 4 // Track file parsing (temporary, should release quickly) - volatile int currentSDAccess = SD_ACCESS_NONE; // Replay function prototypes (must be after SdFat include for File type) @@ -775,6 +775,16 @@ bool activeTimerSectorsConfigured() { 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; @@ -1066,11 +1076,16 @@ void enterSleepMode() { digitalWrite(PIN_LSM6DS3TR_C_POWER, HIGH); // HIGH = disable power } - // 5. Set state + // 5. Re-arm the tach RPM wake trigger and drop pre-sleep pulse state. + // The ISR stays attached — the NEXT valid pulse sets tachHavePeriod and + // wakes straight into race mode. Without this clear, the flag stays + // latched from the first pulse since boot and sleep instantly bounces. + TACH_SLEEP(); + + // 6. Set state sleepModeActive = true; sleepEnteredAt = millis(); sleepLastGpsWake = millis(); - // Tach ISR stays attached -- RPM pulses will wake via tachHavePeriod } void exitSleepMode(bool rpmWake = false) { diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino index 80862a2..8b467bf 100644 --- a/BirdsEye/bluetooth.ino +++ b/BirdsEye/bluetooth.ino @@ -24,6 +24,25 @@ static volatile uint16_t trackUploadOffset = 0; 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. @@ -73,6 +92,10 @@ void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) { 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 @@ -133,11 +156,13 @@ void bleStartAdvertising() { } void bleSendFileList() { - // BLE callbacks run in a separate FreeRTOS task from loop(). - // SdFat is NOT thread-safe — concurrent access from BLE task and main - // loop (e.g. CSV logging) can corrupt internal state. - // Check if SD is in use and return BUSY if so. - if (currentSDAccess != SD_ACCESS_NONE) { + // 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; @@ -146,6 +171,7 @@ void bleSendFileList() { 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; } @@ -181,6 +207,7 @@ void bleSendFileList() { entry.close(); } root.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); fileListChar.notify((uint8_t*)"END", 3); debug(F("BLE: File list sent, ")); @@ -189,7 +216,9 @@ void bleSendFileList() { } void bleSendTrackList() { - if (currentSDAccess != SD_ACCESS_NONE) { + // 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; @@ -198,6 +227,7 @@ void bleSendTrackList() { 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; } @@ -222,6 +252,7 @@ void bleSendTrackList() { entry.close(); } trackDir2.close(); + releaseSDAccess(SD_ACCESS_BLE_TRANSFER); fileStatusChar.notify((uint8_t*)"TEND", 4); debug(F("BLE: Track list sent, ")); @@ -271,6 +302,20 @@ void bleDeleteFile(const char* filename) { 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")); @@ -283,6 +328,8 @@ void bleDeleteFile(const char* filename) { 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) { @@ -331,8 +378,14 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* 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) { - bleSendFileList(); + 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; @@ -343,7 +396,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* fileStatusChar.notify((uint8_t*)"ERROR", 5); return; } - bleStartFileTransfer(filename); + 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; @@ -353,7 +408,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* fileStatusChar.notify((uint8_t*)"NOT_FOUND", 9); return; } - bleDeleteFile(filename); + 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 || @@ -369,7 +426,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* // Track management commands } else if (strcmp(buffer, "TLIST") == 0) { - bleSendTrackList(); + 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. @@ -378,9 +437,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* fileStatusChar.notify((uint8_t*)"TERR:BAD_NAME", 13); return; } - char filepath[FILEPATH_MAX]; - snprintf(filepath, sizeof(filepath), "/TRACKS/%s", buffer + 5); - bleStartFileTransfer(filepath); + 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); @@ -518,6 +577,12 @@ void BLE_STOP() { bleTransferInProgress = false; 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; + // Disconnect any connected device if (Bluefruit.connected()) { Bluefruit.disconnect(Bluefruit.connHandle()); @@ -538,6 +603,33 @@ void BLE_STOP() { 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); @@ -801,6 +893,14 @@ void BLUETOOTH_LOOP() { 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); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index ef42350..3d21aee 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -4,6 +4,7 @@ /////////////////////////////////////////// #include "display_pages.h" +#include "lap_format.h" void displayPage_boot() { resetDisplay(); @@ -193,16 +194,9 @@ void displayPage_replay_results() { } display.print(F("Best: ")); - unsigned long minutes = bestTime / 60000; - unsigned long seconds = (bestTime % 60000) / 1000; - unsigned long milliseconds = bestTime % 1000; - if (minutes > 0) { display.print(minutes); display.print(F(":")); } - if (seconds < 10 && minutes > 0) display.print(F("0")); - display.print(seconds); - display.print(F(".")); - if (milliseconds < 100) display.print(F("0")); - if (milliseconds < 10) display.print(F("0")); - display.print(milliseconds); + 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(")")); @@ -211,16 +205,8 @@ void displayPage_replay_results() { if (strcmp(dovexReplayOptimal, "N/A") != 0 && dovexReplayOptimal[0] != '\0') { display.print(F("Opt: ")); unsigned long optMs = strtoul(dovexReplayOptimal, NULL, 10); - minutes = optMs / 60000; - seconds = (optMs % 60000) / 1000; - milliseconds = optMs % 1000; - if (minutes > 0) { display.print(minutes); display.print(F(":")); } - if (seconds < 10 && minutes > 0) display.print(F("0")); - display.print(seconds); - display.print(F(".")); - if (milliseconds < 100) display.print(F("0")); - if (milliseconds < 10) display.print(F("0")); - display.println(milliseconds); + lap_format::formatLapTime(optMs, lap_format::kOmit, lapStr, sizeof(lapStr)); + display.println(lapStr); } } @@ -362,27 +348,9 @@ void displayPage_gps_lap_time() { unsigned long currentLapTimeMs = activeTimerCurrentLapTime(); if (raceStarted) { - unsigned long minutes = currentLapTimeMs / 60000; - unsigned long seconds = (currentLapTimeMs % 60000) / 1000; - unsigned long milliseconds = currentLapTimeMs % 1000; - if (minutes > 0) { - display.print(minutes); - display.print(":"); - } else { - display.print(" "); - } - - if (seconds < 10) { - display.print(" "); - } - display.print(seconds); - display.print("."); - display.print(milliseconds); - if (milliseconds < 10) { - display.print("00"); - } else if (milliseconds < 100) { - display.print("0"); - } + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(currentLapTimeMs, lap_format::kSpace, lapStr, sizeof(lapStr)); + display.print(lapStr); } else { display.print(" N/A"); } @@ -467,28 +435,10 @@ void displayPage_gps_best_lap() { int bestLapNum = activeTimerBestLapNumber(); if (bestRaceStarted && bestLaps > 0) { - unsigned long minutes = bestLapTimeMs / 60000; - unsigned long seconds = (bestLapTimeMs % 60000) / 1000; - unsigned long milliseconds = bestLapTimeMs % 1000; display.setTextSize(3); - if (minutes > 0) { - display.print(minutes); - display.print(":"); - } else { - display.print(" "); - } - - if (seconds < 10) { - display.print(" "); - } - display.print(seconds); - display.print("."); - display.print(milliseconds); - if (milliseconds < 10) { - display.print("00"); - } else if (milliseconds < 100) { - display.print("0"); - } + 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")); @@ -561,28 +511,9 @@ void displayPage_optimal_lap() { display.setCursor(0, lineHeight); display.setTextSize(2); - unsigned long minutes = optLapTimeMs / 60000; - unsigned long seconds = (optLapTimeMs % 60000) / 1000; - unsigned long milliseconds = optLapTimeMs % 1000; - - if (minutes > 0) { - display.print(minutes); - display.print(":"); - } else { - display.print(" "); - } - - if (seconds < 10) { - display.print(" "); - } - display.print(seconds); - display.print("."); - display.print(milliseconds); - if (milliseconds < 10) { - display.print("00"); - } else if (milliseconds < 100) { - display.print("0"); - } + 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); @@ -629,10 +560,6 @@ void displayPage_gps_lap_list() { int pageEnd = pageStart + lapsPerPage; for (int lap = pageStart; lap < pageEnd; ++lap) { if (lap < lapHistoryCount) { - unsigned long minutes = lapHistory[lap] / 60000; - unsigned long seconds = (lapHistory[lap] % 60000) / 1000; - unsigned long milliseconds = lapHistory[lap] % 1000; - int actualLap = lap + 1; if (actualLap < 10) { display.print(F(" ")); @@ -641,12 +568,9 @@ void displayPage_gps_lap_list() { display.setTextSize(1); display.print(F(" ")); display.setTextSize(2); - display.print(minutes); - display.print(F(":")); - display.print(seconds); - display.print(F(".")); - display.print(milliseconds); - display.println(); + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(lapHistory[lap], lap_format::kShow, lapStr, sizeof(lapStr)); + display.println(lapStr); } } } else { diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index 334367a..d25d272 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -515,6 +515,15 @@ void GPS_RECONFIGURE() { 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: + // up to three myGNSS.begin() probes (~1.1 s of ping timeouts each) plus + // ~500 ms of explicit delay(). 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. + // Pet before every blocking probe (same rationale as fwStageToFlash()). + wdtPet(); + // Stop timer so GpsBufferedStream passes through to Serial1 directly. // The SparkFun library needs direct serial access for begin()/setSerialRate(). stopGpsSerialTimer(); @@ -532,6 +541,7 @@ bool GPS_BAUD_RECOVERY() { // Module not responding at 57600 — try 9600 (factory default) debugln(F("GPS baud recovery: trying 9600...")); + wdtPet(); // the first begin() just burned ~1 s of the 4 s budget GPS_SERIAL.end(); delay(50); GPS_SERIAL.begin(9600); @@ -540,12 +550,14 @@ bool GPS_BAUD_RECOVERY() { if (myGNSS.begin(gpsStream)) { // 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)) { debugln(F("GPS baud recovery: reconnected at 57600")); @@ -562,6 +574,7 @@ bool GPS_BAUD_RECOVERY() { } // Restore 57600 even on failure so other code doesn't break + wdtPet(); GPS_SERIAL.end(); delay(50); GPS_SERIAL.begin(GPS_BAUD_RATE); diff --git a/BirdsEye/lap_format.cpp b/BirdsEye/lap_format.cpp new file mode 100644 index 0000000..33c1a4a --- /dev/null +++ b/BirdsEye/lap_format.cpp @@ -0,0 +1,36 @@ +#include "lap_format.h" + +#include + +namespace lap_format { + +void formatLapTime(unsigned long ms, ZeroMinutesStyle style, + char* buf, size_t bufSize) { + if (buf == nullptr || bufSize == 0) return; + + const unsigned long minutes = ms / 60000UL; + const unsigned long seconds = (ms % 60000UL) / 1000UL; + const unsigned long millis = ms % 1000UL; + + if (minutes > 0) { + snprintf(buf, bufSize, "%lu:%02lu.%03lu", minutes, seconds, millis); + return; + } + + switch (style) { + case kShow: + snprintf(buf, bufSize, "0:%02lu.%03lu", seconds, millis); + break; + case kSpace: + // Leading space stands in for the minutes slot; %2lu space-pads + // seconds so the decimal point never moves on the big-font pages. + snprintf(buf, bufSize, " %2lu.%03lu", seconds, millis); + break; + case kOmit: + default: + snprintf(buf, bufSize, "%lu.%03lu", seconds, millis); + break; + } +} + +} // namespace lap_format diff --git a/BirdsEye/lap_format.h b/BirdsEye/lap_format.h new file mode 100644 index 0000000..e0bf3e5 --- /dev/null +++ b/BirdsEye/lap_format.h @@ -0,0 +1,42 @@ +#pragma once + +/////////////////////////////////////////// +// LAP TIME FORMATTING +// Single source of truth for rendering a lap time in milliseconds as +// "M:SS.mmm". The display pages used to carry six divergent inline +// copies of this math — three of which appended the millisecond +// zero-padding AFTER the value (7 ms rendered as ".700", a 693 ms error +// on the number the whole product exists to display) and one of which +// did no padding at all. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +#include + +namespace lap_format { + +// Worst case is "71582:47.295" (the full unsigned 32-bit ms range) — +// 12 chars + NUL. 16 leaves headroom. +constexpr size_t kLapTimeStrLen = 16; + +// How to render the minutes field when the time is under one minute. +// When minutes > 0 every style renders identically: "M:SS.mmm" with +// zero-padded seconds. +enum ZeroMinutesStyle : unsigned char { + kOmit, // "23.007" — compact (replay results) + kShow, // "0:23.007" — fixed field (lap history list) + kSpace, // " 23.007" — column-stable for big-font live pages: the + // minutes slot becomes a space and seconds are + // space-padded to two chars (" 3.007" never + // shifts the decimal point as the lap rolls past + // 10 s or 1 min) +}; + +// Format `ms` into `buf` (size `bufSize`, recommend kLapTimeStrLen). +// Milliseconds are always exactly three zero-padded digits. Output is +// always NUL-terminated (truncated if the buffer is too small). +void formatLapTime(unsigned long ms, ZeroMinutesStyle style, + char* buf, size_t bufSize); + +} // namespace lap_format diff --git a/BirdsEye/sd_access_policy.cpp b/BirdsEye/sd_access_policy.cpp new file mode 100644 index 0000000..891b76f --- /dev/null +++ b/BirdsEye/sd_access_policy.cpp @@ -0,0 +1,16 @@ +#include "sd_access_policy.h" + +namespace sd_access_policy { + +bool canAcquire(int current, int requested) { + if (current == requested) return true; // idempotent re-acquire + // Free, or held only by the preemptible (brief, leak-recoverable) + // track-parse mode. + return current == kNone || current == kTrackParse; +} + +bool releaseClears(int current, int releasing) { + return current == releasing && current != kNone; +} + +} // namespace sd_access_policy diff --git a/BirdsEye/sd_access_policy.h b/BirdsEye/sd_access_policy.h new file mode 100644 index 0000000..56805a4 --- /dev/null +++ b/BirdsEye/sd_access_policy.h @@ -0,0 +1,43 @@ +#pragma once + +/////////////////////////////////////////// +// SD ACCESS ARBITRATION POLICY +// The decision table for the SD card access "mutex" in sd_functions.ino: +// which subsystem may take ownership given who currently holds it. +// +// The policy itself is pure logic (no Arduino headers) so it is exercised +// by host tests. ATOMICITY IS NOT PROVIDED HERE — acquireSDAccess() / +// releaseSDAccess() must evaluate these predicates and commit the state +// change inside a critical section, because the Bluefruit callback task +// and the main loop share the owner variable. +/////////////////////////////////////////// + +namespace sd_access_policy { + +// SD owner modes. The SD_ACCESS_* macros in sd_functions.h alias these — +// these constants are the single source of truth for the values. +constexpr int kNone = 0; // card is free +constexpr int kLogging = 1; // DOVEX session logging (held all session) +constexpr int kReplay = 2; // DOVEX header replay +constexpr int kBleTransfer = 3; // BLE file transfer / track write / OTA staging +constexpr int kTrackParse = 4; // brief track-JSON / settings reads + +// May `requested` take ownership when `current` holds the card? +// - Re-acquiring the mode you already hold is allowed (idempotent), so +// retry loops (e.g. the 1 Hz log-file open retry) don't deadlock on +// themselves. Callers must therefore never use a same-mode acquire to +// enter a *second* concurrent operation — see bleDeleteFile()'s +// explicit bleTransferInProgress guard. +// - kTrackParse is preemptible: every TRACK_PARSE section is brief and +// synchronous (acquire and release inside one call), so a holder seen +// across calls can only be a leaked lock from a forgotten error-path +// release — letting others claim the card keeps a leak from bricking +// logging mid-race. +bool canAcquire(int current, int requested); + +// Does releasing `releasing` free the card when `current` holds it? +// Only the current holder's release clears ownership; a stale release +// from an error path that lost the lock must not free someone else's. +bool releaseClears(int current, int releasing); + +} // namespace sd_access_policy diff --git a/BirdsEye/sd_functions.h b/BirdsEye/sd_functions.h index 1f72943..096093b 100644 --- a/BirdsEye/sd_functions.h +++ b/BirdsEye/sd_functions.h @@ -7,14 +7,17 @@ // access mutex to avoid corrupting SdFat's internal state. /////////////////////////////////////////// -// SD access modes — used by acquireSDAccess / releaseSDAccess. -// TRACK_PARSE is treated as "preemptible" by other acquirers since it's -// always brief. -#define SD_ACCESS_NONE 0 -#define SD_ACCESS_LOGGING 1 -#define SD_ACCESS_REPLAY 2 -#define SD_ACCESS_BLE_TRANSFER 3 -#define SD_ACCESS_TRACK_PARSE 4 +#include "sd_access_policy.h" + +// SD access modes — used by acquireSDAccess / releaseSDAccess. Aliases of +// the sd_access_policy constants (the single source of truth for the values +// and the grant/deny rules — TRACK_PARSE is preemptible, same-mode +// re-acquire is idempotent; see sd_access_policy.h). +#define SD_ACCESS_NONE sd_access_policy::kNone +#define SD_ACCESS_LOGGING sd_access_policy::kLogging +#define SD_ACCESS_REPLAY sd_access_policy::kReplay +#define SD_ACCESS_BLE_TRANSFER sd_access_policy::kBleTransfer +#define SD_ACCESS_TRACK_PARSE sd_access_policy::kTrackParse // JSON parser status codes returned by parseTrackFile(). Defined in // BirdsEye.ino; redeclared here so callers in other .ino files can diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino index 80ef041..1a6ae1f 100644 --- a/BirdsEye/sd_functions.ino +++ b/BirdsEye/sd_functions.ino @@ -9,21 +9,30 @@ * @brief Attempt to acquire SD card access for a subsystem * @param mode The access mode being requested (SD_ACCESS_*) * @return true if access granted, false if SD busy with another operation + * + * The check-then-set must be atomic: the main loop and the Bluefruit + * callback task can both call this, and a plain `volatile int` test+store + * is a TOCTOU that lets two tasks each believe they own the card. + * taskENTER_CRITICAL() masks interrupts only up to + * configMAX_SYSCALL_INTERRUPT_PRIORITY (BASEPRI), so the SoftDevice's + * high-priority radio interrupts are never starved. The grant/deny rules + * live in the host-tested sd_access_policy pure unit. */ bool acquireSDAccess(int mode) { - // Allow re-acquiring same mode (idempotent) - if (currentSDAccess == mode) return true; - - // Only allow acquisition if currently free or track parsing (which is temporary) - if (currentSDAccess == SD_ACCESS_NONE || currentSDAccess == SD_ACCESS_TRACK_PARSE) { + bool granted; + taskENTER_CRITICAL(); + granted = sd_access_policy::canAcquire(currentSDAccess, mode); + if (granted) { currentSDAccess = mode; - return true; } + taskEXIT_CRITICAL(); - // SD busy with another subsystem - debug(F("SD access denied, current mode: ")); - debugln(currentSDAccess); - return false; + if (!granted) { + // SD busy with another subsystem + debug(F("SD access denied, current mode: ")); + debugln(currentSDAccess); + } + return granted; } /** @@ -31,16 +40,20 @@ bool acquireSDAccess(int mode) { * @param mode The access mode being released (must match current) */ void releaseSDAccess(int mode) { - if (currentSDAccess == mode) { + taskENTER_CRITICAL(); + if (sd_access_policy::releaseClears(currentSDAccess, mode)) { currentSDAccess = SD_ACCESS_NONE; } + taskEXIT_CRITICAL(); } /** * @brief Force release all SD access (use during cleanup/error recovery) */ void forceReleaseSDAccess() { + taskENTER_CRITICAL(); currentSDAccess = SD_ACCESS_NONE; + taskEXIT_CRITICAL(); } void makeFullTrackPath(const char* trackName, char* filepath) { diff --git a/BirdsEye/tach_filter.cpp b/BirdsEye/tach_filter.cpp new file mode 100644 index 0000000..d6f55c7 --- /dev/null +++ b/BirdsEye/tach_filter.cpp @@ -0,0 +1,33 @@ +#include "tach_filter.h" + +namespace tach_filter { + +void reset(Kalman& k) { + k.x = 0.0f; + k.p = kInitialUncertaintyP; +} + +void update(Kalman& k, float rpmMeasured, int periodCount) { + if (periodCount <= 0) return; + + // Predict step: constant-RPM model, uncertainty grows + k.p += kProcessNoiseQ; + + // Measurement noise scales inversely with number of periods + const float r = kMeasurementNoiseRBase / (float)periodCount; + + // Update step + const float gain = k.p / (k.p + r); + k.x += gain * (rpmMeasured - k.x); + k.p *= (1.0f - gain); + + // Uncertainty floor to prevent numerical collapse + if (k.p < kUncertaintyFloorP) k.p = kUncertaintyFloorP; +} + +float rpmFromMeanPeriodUs(float meanPeriodUs, float revsPerPulse) { + if (meanPeriodUs <= 0.0f || revsPerPulse <= 0.0f) return 0.0f; + return (60.0e6f * revsPerPulse) / meanPeriodUs; +} + +} // namespace tach_filter diff --git a/BirdsEye/tach_filter.h b/BirdsEye/tach_filter.h new file mode 100644 index 0000000..ac6e82e --- /dev/null +++ b/BirdsEye/tach_filter.h @@ -0,0 +1,53 @@ +#pragma once + +/////////////////////////////////////////// +// TACHOMETER KALMAN FILTER +// The 1-D Kalman filter that turns mean inter-pulse periods into a +// smoothed RPM estimate, extracted from tachometer.ino so the math is +// host-testable. The ISR/ring-buffer plumbing stays in the sketch; this +// unit owns the predict/update equations and the tuning constants. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace tach_filter { + +// Process noise Q: how much RPM^2 the true value can change between +// updates. A kart engine with a light flywheel can shift ~200 RPM per +// pulse at 5k RPM. 800 is conservative-smooth; raise to 1500–2000 if +// tracking feels sluggish. +constexpr float kProcessNoiseQ = 800.0f; + +// Measurement noise R base: variance of a single-period RPM measurement +// (~50 RPM std dev from combustion variation + ISR latency jitter). +// Scales inversely with the number of periods in the measurement — +// more pulses, more confidence. +constexpr float kMeasurementNoiseRBase = 2500.0f; + +// Uncertainty assigned at reset (engine stop / sleep / boot): high, so +// the first real measurement dominates the stale estimate. +constexpr float kInitialUncertaintyP = 10000.0f; + +// Floor that keeps the uncertainty from collapsing numerically to zero +// (which would make the filter stop tracking). +constexpr float kUncertaintyFloorP = 1.0f; + +struct Kalman { + float x = 0.0f; // RPM estimate + float p = kInitialUncertaintyP; // estimate uncertainty (RPM^2) +}; + +// Return the estimate to the rest state (RPM 0, high uncertainty). +void reset(Kalman& k); + +// Fold one measurement into the estimate: `rpmMeasured` is the RPM +// implied by the mean of `periodCount` inter-pulse periods. periodCount +// <= 0 is a no-op. +void update(Kalman& k, float rpmMeasured, int periodCount); + +// Convert a mean inter-pulse period (microseconds) to RPM for a pickup +// producing `revsPerPulse` revolutions per pulse (wasted spark = 1.0). +// Non-positive inputs return 0. +float rpmFromMeanPeriodUs(float meanPeriodUs, float revsPerPulse); + +} // namespace tach_filter diff --git a/BirdsEye/tachometer.h b/BirdsEye/tachometer.h index d29295e..a6d0859 100644 --- a/BirdsEye/tachometer.h +++ b/BirdsEye/tachometer.h @@ -15,7 +15,8 @@ extern volatile int tachLastReported; // Set true by the ISR on any valid pulse — sleep mode uses this as a -// wake trigger; main loop should NOT clear it (ISR owns it). +// wake trigger. Cleared ONLY by TACH_SLEEP() on sleep entry (re-arming +// the trigger); never clear it from the awake main loop. extern volatile bool tachHavePeriod; // ISR — must have C-style linkage for attachInterrupt(). @@ -24,3 +25,9 @@ void TACH_COUNT_PULSE(); // Drain ring buffer, update Kalman estimate, apply engine-stop timeout. // Call once per main-loop iteration (~250 Hz). void TACH_LOOP(); + +// Re-arm the RPM wake trigger and discard pre-sleep pulse/filter state. +// MUST be called from enterSleepMode() — otherwise a single engine pulse +// since boot leaves tachHavePeriod latched true and every sleep entry +// immediately RPM-wakes back into race mode with logging enabled. +void TACH_SLEEP(); diff --git a/BirdsEye/tachometer.ino b/BirdsEye/tachometer.ino index d953426..dc15360 100644 --- a/BirdsEye/tachometer.ino +++ b/BirdsEye/tachometer.ino @@ -15,20 +15,12 @@ /////////////////////////////////////////// #include "tachometer.h" +#include "tach_filter.h" // ---- Kalman filter state ---- -static float kalmanX = 0.0f; // RPM estimate -static float kalmanP = 10000.0f; // Estimate uncertainty (RPM^2) - -// Process noise Q: how much RPM^2 can change between Kalman updates. -// A kart engine with light flywheel can shift ~200 RPM per pulse at 5k RPM. -// Q=800 is conservative-smooth. Increase to 1500-2000 if tracking feels sluggish. -static const float KALMAN_Q = 800.0f; - -// Measurement noise R_BASE: uncertainty of a single-period RPM measurement. -// ~50 RPM std dev from combustion variation + ISR latency jitter = variance 2500. -// Scales inversely with number of periods: more pulses → lower noise. -static const float KALMAN_R_BASE = 2500.0f; +// The predict/update math and the tuning constants (Q, R_BASE, the +// uncertainty floor) live in the host-tested tach_filter pure unit. +static tach_filter::Kalman tachKalman; // After engine-stopped timeout, the first period is garbage (it spans the // entire stopped duration). This flag discards it. @@ -55,15 +47,29 @@ void TACH_COUNT_PULSE() { // Time-based debounce: reject ignition ringing within 3ms of last valid pulse if (dt < tachMinPulseGapUs) return; - // Record timestamp tachLastPulseUs = now; - tachRingBuf[tachRingHead] = now; - // ARM Cortex-M4: single-byte write is atomic. Data is visible before head - // advances because stores are observed in program order on same processor. - tachRingHead = (tachRingHead + 1) % TACH_RING_SIZE; - // Wake trigger for sleep mode (BirdsEye.ino reads this) + // Wake trigger for sleep mode (BirdsEye.ino reads this). Set even when + // the ring is full below — a dropped timestamp is still a real pulse. tachHavePeriod = true; + + // SPSC full check (one slot is sacrificed so head==tail is unambiguously + // "empty"). TACH_LOOP can be blocked for 100 ms–2 s by the same SD GC + // stalls the GPS serial ring exists for; at 6000 RPM a 200 ms stall + // overruns 16 entries, and advancing head unconditionally would lap the + // consumer and corrupt every period it computes. Dropping the pulse and + // flagging the gap is safe — the consumer discards the one period that + // spans the gap and the Kalman estimate coasts until fresh data arrives. + uint8_t nextHead = (tachRingHead + 1) % TACH_RING_SIZE; + if (nextHead == tachRingTail) { + tachRingOverflow = true; + return; + } + + // Data is visible before head advances because stores are observed in + // program order on the same processor (single-byte head write is atomic). + tachRingBuf[tachRingHead] = now; + tachRingHead = nextHead; } /** @@ -132,35 +138,59 @@ void TACH_LOOP() { periodSum += periods[i]; } float meanPeriodUs = (float)periodSum / (float)periodCount; - float rpmMeasured = (60.0e6f * tachRevsPerPulse) / meanPeriodUs; - - // Predict step: constant-RPM model, uncertainty grows - kalmanP += KALMAN_Q; - - // Measurement noise scales inversely with number of periods - float R = KALMAN_R_BASE / (float)periodCount; - - // Update step - float K = kalmanP / (kalmanP + R); - kalmanX += K * (rpmMeasured - kalmanX); - kalmanP *= (1.0f - K); - - // Uncertainty floor to prevent numerical collapse - if (kalmanP < 1.0f) kalmanP = 1.0f; + float rpmMeasured = + tach_filter::rpmFromMeanPeriodUs(meanPeriodUs, tachRevsPerPulse); + tach_filter::update(tachKalman, rpmMeasured, periodCount); } } + // ---- Step 3.5: Ring overflow recovery ---- + // The ISR dropped pulses while the ring was full (main loop stalled). + // The period between the last retained timestamp and the next retained + // pulse spans the whole gap and is NOT a real measurement — drop the + // carried prev-timestamp so that period is never computed. The estimate + // simply coasts until consecutive post-gap pulses arrive. + if (tachRingOverflow) { + tachRingOverflow = false; + tachHavePrevTimestamp = false; + } + // ---- Step 4: Engine-stopped timeout ---- // 32-bit reads are atomic on ARM Cortex-M4, no noInterrupts() needed uint32_t lastPulseUs = tachLastPulseUs; if ((uint32_t)(micros() - lastPulseUs) > tachStopTimeoutUs) { - kalmanX = 0.0f; - kalmanP = 10000.0f; // High uncertainty for next startup + tach_filter::reset(tachKalman); // High uncertainty for next startup tachNeedFirstPulseDiscard = true; tachHavePrevTimestamp = false; tachRingTail = tachRingHead; // Flush ring buffer } // ---- Step 5: Update reported value ---- - tachLastReported = (int)(kalmanX + 0.5f); + tachLastReported = (int)(tachKalman.x + 0.5f); +} + +/** + * Prepare the tachometer for sleep mode — call from enterSleepMode(). + * + * Re-arms the RPM wake trigger and discards pre-sleep pulse state. Without + * this, tachHavePeriod stays true forever after the first engine pulse + * since boot, and every sleep entry (long-press, menu idle, USB) instantly + * bounces through the RPM-wake path back into race mode with logging + * enabled — silently starting a session and draining the pack overnight. + * + * The ISR stays attached during sleep: the next valid pulse sets + * tachHavePeriod again, and THAT is the legitimate RPM wake. + */ +void TACH_SLEEP() { + tachHavePeriod = false; + + // Drop any buffered pre-sleep pulses and filter state so the wake's RPM + // computation starts clean instead of chewing on stale timestamps (the + // first post-wake period would otherwise span the whole sleep). + tachRingTail = tachRingHead; + tachRingOverflow = false; + tachHavePrevTimestamp = false; + tachNeedFirstPulseDiscard = true; + tach_filter::reset(tachKalman); + tachLastReported = 0; } diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b95b2..0bca209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,71 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Changed +- **CI now controls which DovesLapTimer the firmware is built against.** + Builds targeting (or running on) the `BETA` branch track the library's own + `BETA` branch, so the two beta channels move together; `master` CI and the + release/tag builds pin the known-good `v4.1.0` tag instead of floating on + the library's default-branch tip. Bump the pin deliberately when a new + library release is validated. + +### Fixed +- **Track detection no longer throttles the whole main loop.** The manifest + scan (O(N) software-double haversine, several ms at the 200-entry ceiling) + was gated only on `gpsData.fix`, which stays true between PVT updates — so + it ran every ~250 Hz loop iteration instead of "every GPS fix" as + documented, dragging the loop rate down while hunting for a track. The + scan is now throttled to 1 Hz, which is still instant at driving pace. +- **Tach ring buffer can no longer be lapped during SD stalls.** The pulse + ISR advanced the ring head unconditionally; an SD GC stall (the documented + 100 ms–2 s, the same reason the GPS serial ring exists) at racing RPM + overruns the 16-entry buffer, breaking the ring invariant and producing a + confident-but-wrong RPM that was logged to CSV and fed the >500 RPM + auto-race trigger. The ISR now checks full and drops the pulse instead, + flagging the gap so `TACH_LOOP()` discards the one period spanning it — + the Kalman estimate coasts briefly rather than going silently wrong. The + filter math itself moved to the host-tested `tach_filter` pure unit. +- **GPS baud recovery no longer risks tripping the 4 s hardware watchdog.** + `GPS_BAUD_RECOVERY()` can block for up to three ~1.1 s module probes plus + ~500 ms of delays — against a genuinely hung GPS that out-waited the WDT, + causing a reset → re-setup → recovery → reset boot loop. It now pets the + watchdog before each blocking probe (same treatment `fwStageToFlash()` + already had). +- **Sleep mode actually sleeps now.** The tach ISR latched `tachHavePeriod` + on the first engine pulse since boot and nothing ever cleared it, so every + sleep entry (long-press, 5-min menu idle, USB) instantly bounced through + the RPM-wake path back into race mode **with logging enabled** — the + device would silently start a new session and drain the pack overnight. + `enterSleepMode()` now calls the new `TACH_SLEEP()`, which re-arms the + wake trigger and drops stale ring-buffer/Kalman state; the next *real* + pulse still RPM-wakes straight into race mode as designed. +- **Lap times under 100 ms-fraction rendered wrong on the live pages.** The + current-lap, best-lap, and optimal-lap pages appended the millisecond + zero-padding *after* the value, so `1:23.007` displayed as `1:23.700` (a + 693 ms error), and the lap-history list did no padding at all (`1:5.7`). + All six divergent inline copies of the ms → `M:SS.mmm` math are replaced + by one host-tested `lap_format` unit; the replay page's already-correct + rendering is unchanged, and the lap list now shows zero-padded + `M:SS.mmm`. + +### Security +- **SD card arbitration race fixed — a BLE client in radio range could + corrupt the card.** `acquireSDAccess()` was a non-atomic check-then-set on + a shared flag, and several BLE commands (`LIST`, `GET:`, `DELETE:`, + `TLIST`, `TGET:`) ran SdFat directly in the Bluefruit callback task — + concurrently with the main loop's 25 Hz session logging. Overlapping + commands could close a file mid-read, delete the file being streamed + (`DELETE:` took no lock at all), or interleave two SdFat operations: + FAT corruption, lost session logs, or an SPI wedge. Now: lock + transitions are atomic (FreeRTOS critical section; the grant/deny table + is the new host-tested `sd_access_policy` unit), all five commands are + deferred to `BLUETOOTH_LOOP()` on the main loop like the + settings/track/OTA commands already were, directory listings hold the + lock for the whole walk, and `DELETE` takes the lock and refuses with + `BUSY` while a transfer is streaming. Protocol impact: commands that + arrive while another file command is still queued now get the existing + `BUSY` / `TERR:BUSY` replies instead of executing concurrently. + ## [2.2.3] - 2026-06-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d6e047f..7d669da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,12 +100,15 @@ desktop toolchain. This is where logic worth unit-testing lives. | `dovex_header.{h,cpp}` | DOVEX 1 KB header `format()` / `parse()` | | `filename_validator.{h,cpp}` | FAT-safe / traversal-proof check for BLE filenames | | `crc32.{h,cpp}` | CRC-32/IEEE-802.3 (zlib) incremental + hex; pins firmware-OTA CRC to the web client | +| `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) | ### Non-Source | Path | Contents | |---|---| -| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, 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) | +| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, 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). DovesLapTimer ref per channel: `BETA` builds track the library's `BETA` branch, master/release pin `v4.1.0` | | `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) | @@ -199,11 +202,16 @@ loop() ~250 Hz - 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`). `TACH_LOOP()` drains the buffer + 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 (RPM estimate `kalmanX` + uncertainty `kalmanP`). Process noise + 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 @@ -211,6 +219,9 @@ loop() ~250 Hz - `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. +- `TACH_SLEEP()` (called from `enterSleepMode()`) clears the latched + `tachHavePeriod` wake flag and drops ring/Kalman state so the RPM wake + trigger is re-armed for the *next* pulse instead of firing instantly. ### 3. Accelerometer (`accelerometer.ino`) @@ -239,13 +250,24 @@ loop() ~250 Hz - **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). + `BLE_TRANSFER` (3), `TRACK_PARSE` (4) — values and grant/deny rules + live in the host-tested `sd_access_policy` pure unit. + - 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_MAIN_MENU` (-1). - Racing: `GPS_STATS` (4) through `LOGGING_STOP` (12). @@ -279,7 +301,15 @@ loop() ~250 Hz `-DBIRDSEYE_BOARD_SENSE` / `-DBIRDSEYE_BOARD_NONSENSE` (defaults to `sense`). - MTU negotiation (requests 247, default 23). -- File listing does not require exclusive SD access; transfer does. +- **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()` / @@ -354,7 +384,9 @@ loop() ~250 Hz immediate Lap Anything activation. - **Track detection flow** (`trackDetectionLoop()`): 1. Valid GPS time lock acquired → DOVEX log file created (see GPS section). - 2. Every GPS fix, scans `trackManifest[]` via haversine. + 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. @@ -375,10 +407,14 @@ loop() ~250 Hz or USB connected on main menu. - **`enterSleepMode()`**: ends active race session, stops BLE, display off (I2C `DISPLAYOFF`), GPS backup mode (`powerOff(0)`), IMU power off, - GPS serial timer stopped. + GPS serial timer stopped, and `TACH_SLEEP()` re-arms the RPM wake + trigger (clears the latched `tachHavePeriod` + stale ring/Kalman state + — without this, one engine pulse since boot made every sleep entry + bounce straight back into race mode with logging on). - **Wake triggers** (checked every loop in sleep): - - **RPM wake**: tach ISR fires → `exitSleepMode(true)` → straight into - race mode with logging enabled, Lap Anything CourseManager created. + - **RPM wake**: tach ISR sets `tachHavePeriod` on the next valid pulse → + `exitSleepMode(true)` → straight into race mode with logging enabled, + Lap Anything CourseManager created. - **Button wake**: any button → `exitSleepMode(false)` → main menu. - **`exitSleepMode()`**: re-enables IMU, GPS wake, display on, GPS serial timer restarted. RPM wake skips menu and goes directly to race mode. @@ -555,8 +591,9 @@ Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). | 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² | `tachometer.ino` | -| Tach Kalman R_BASE | 2500 RPM² | `tachometer.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` | @@ -588,7 +625,7 @@ Stored in `trackLayouts[MAX_LAYOUTS]` (max 10 per track). | 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) | +| DovesLapTimer | Lap/sector timing (external: TheAngryRaven/DovesLapTimer). CI refs: `BETA`-targeted builds track the library's `BETA` branch; master/release builds pin `v4.1.0` (bump deliberately) | | Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant, ±16g) | | Bluefruit nRF52 | BLE (built into board package) | diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 420e2c9..3a1bb5c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -17,12 +17,18 @@ add_executable(birdseye_tests dovex_header_test.cpp filename_validator_test.cpp crc32_test.cpp + sd_access_policy_test.cpp + lap_format_test.cpp + tach_filter_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_time.cpp ${BIRDSEYE_DIR}/gps_validation.cpp ${BIRDSEYE_DIR}/dovex_header.cpp ${BIRDSEYE_DIR}/filename_validator.cpp ${BIRDSEYE_DIR}/crc32.cpp + ${BIRDSEYE_DIR}/sd_access_policy.cpp + ${BIRDSEYE_DIR}/lap_format.cpp + ${BIRDSEYE_DIR}/tach_filter.cpp ) target_include_directories(birdseye_tests PRIVATE diff --git a/tests/lap_format_test.cpp b/tests/lap_format_test.cpp new file mode 100644 index 0000000..5a1e350 --- /dev/null +++ b/tests/lap_format_test.cpp @@ -0,0 +1,93 @@ +#include "doctest.h" +#include "lap_format.h" + +#include + +using lap_format::formatLapTime; +using lap_format::kLapTimeStrLen; +using lap_format::kOmit; +using lap_format::kShow; +using lap_format::kSpace; + +static std::string fmt(unsigned long ms, lap_format::ZeroMinutesStyle style) { + char buf[kLapTimeStrLen]; + formatLapTime(ms, style, buf, sizeof(buf)); + return std::string(buf); +} + +// --------------------------------------------------------------------------- +// The H-1 regression: milliseconds must be zero-padded BEFORE the value, +// never after. 1:23.007 used to render as 1:23.700 — a 693 ms error. +// --------------------------------------------------------------------------- + +TEST_CASE("formatLapTime - small millisecond values are left-zero-padded") { + CHECK(fmt(83007, kOmit) == "1:23.007"); // the review's exact case + CHECK(fmt(83007, kShow) == "1:23.007"); + CHECK(fmt(83007, kSpace) == "1:23.007"); + + CHECK(fmt(83070, kOmit) == "1:23.070"); + CHECK(fmt(83700, kOmit) == "1:23.700"); // .700 is only ever 700 ms + + CHECK(fmt(7, kOmit) == "0.007"); + CHECK(fmt(70, kOmit) == "0.070"); + CHECK(fmt(700, kOmit) == "0.700"); +} + +// --------------------------------------------------------------------------- +// Whole-field behavior with minutes present (identical across styles) +// --------------------------------------------------------------------------- + +TEST_CASE("formatLapTime - minutes present: M:SS.mmm with zero-padded seconds") { + CHECK(fmt(60000, kOmit) == "1:00.000"); + CHECK(fmt(65007, kOmit) == "1:05.007"); // seconds zero-padded, not " 5" + CHECK(fmt(65007, kSpace) == "1:05.007"); + CHECK(fmt(65007, kShow) == "1:05.007"); + CHECK(fmt(599999, kOmit) == "9:59.999"); + CHECK(fmt(600000, kOmit) == "10:00.000"); +} + +// --------------------------------------------------------------------------- +// Sub-minute styles +// --------------------------------------------------------------------------- + +TEST_CASE("formatLapTime - kOmit drops the minutes field entirely") { + CHECK(fmt(23007, kOmit) == "23.007"); + CHECK(fmt(5123, kOmit) == "5.123"); + CHECK(fmt(0, kOmit) == "0.000"); +} + +TEST_CASE("formatLapTime - kShow renders a fixed 0: minutes field") { + CHECK(fmt(23007, kShow) == "0:23.007"); + CHECK(fmt(5123, kShow) == "0:05.123"); + CHECK(fmt(0, kShow) == "0:00.000"); +} + +TEST_CASE("formatLapTime - kSpace keeps the decimal point column-stable") { + // The minutes slot becomes a space and seconds are space-padded to two + // chars, so the '.' never moves while the lap is under a minute + // (matching the original live-page alignment behavior). + CHECK(fmt(23007, kSpace) == " 23.007"); + CHECK(fmt(5123, kSpace) == " 5.123"); + CHECK(fmt(0, kSpace) == " 0.000"); + CHECK(fmt(59999, kSpace) == " 59.999"); + CHECK(fmt(5123, kSpace).find('.') == fmt(59999, kSpace).find('.')); +} + +// --------------------------------------------------------------------------- +// Bounds and safety +// --------------------------------------------------------------------------- + +TEST_CASE("formatLapTime - full 32-bit range fits kLapTimeStrLen") { + CHECK(fmt(4294967295UL, kOmit) == "71582:47.295"); +} + +TEST_CASE("formatLapTime - truncates safely into a small buffer") { + char buf[5]; + formatLapTime(83007, kOmit, buf, sizeof(buf)); + CHECK(std::string(buf) == "1:23"); // truncated but NUL-terminated + + // Degenerate buffers must not crash or write. + formatLapTime(83007, kOmit, nullptr, 16); + char one[1]; + formatLapTime(83007, kOmit, one, 0); +} diff --git a/tests/sd_access_policy_test.cpp b/tests/sd_access_policy_test.cpp new file mode 100644 index 0000000..6119a5a --- /dev/null +++ b/tests/sd_access_policy_test.cpp @@ -0,0 +1,79 @@ +#include "doctest.h" +#include "sd_access_policy.h" + +using namespace sd_access_policy; + +static const int kAllModes[] = {kNone, kLogging, kReplay, kBleTransfer, kTrackParse}; +static const int kHolderModes[] = {kLogging, kReplay, kBleTransfer, kTrackParse}; + +// --------------------------------------------------------------------------- +// canAcquire — the arbitration decision table +// --------------------------------------------------------------------------- + +TEST_CASE("canAcquire - a free card grants any mode") { + for (int requested : kHolderModes) { + CHECK(canAcquire(kNone, requested)); + } +} + +TEST_CASE("canAcquire - re-acquiring the held mode is idempotent") { + for (int mode : kHolderModes) { + CHECK(canAcquire(mode, mode)); + } +} + +TEST_CASE("canAcquire - a non-preemptible holder denies every other mode") { + const int exclusive[] = {kLogging, kReplay, kBleTransfer}; + for (int current : exclusive) { + for (int requested : kHolderModes) { + if (requested == current) continue; + CHECK_FALSE(canAcquire(current, requested)); + } + } +} + +TEST_CASE("canAcquire - track-parse holder is preemptible by everyone") { + // TRACK_PARSE sections are brief and synchronous; a holder observed + // from another call site can only be a leaked lock, which must not + // brick logging mid-race. + for (int requested : kHolderModes) { + CHECK(canAcquire(kTrackParse, requested)); + } +} + +TEST_CASE("canAcquire - concrete cross-subsystem cases") { + // BLE transfer cannot steal the card from an active logging session. + CHECK_FALSE(canAcquire(kLogging, kBleTransfer)); + // Logging cannot start while a BLE transfer is streaming. + CHECK_FALSE(canAcquire(kBleTransfer, kLogging)); + // Replay and BLE transfer exclude each other. + CHECK_FALSE(canAcquire(kReplay, kBleTransfer)); + CHECK_FALSE(canAcquire(kBleTransfer, kReplay)); + // Settings/track reads (TRACK_PARSE) are denied while logging holds it. + CHECK_FALSE(canAcquire(kLogging, kTrackParse)); +} + +// --------------------------------------------------------------------------- +// releaseClears — only the current holder's release frees the card +// --------------------------------------------------------------------------- + +TEST_CASE("releaseClears - matching holder release frees the card") { + for (int mode : kHolderModes) { + CHECK(releaseClears(mode, mode)); + } +} + +TEST_CASE("releaseClears - a stale release must not free another holder") { + for (int current : kHolderModes) { + for (int releasing : kAllModes) { + if (releasing == current) continue; + CHECK_FALSE(releaseClears(current, releasing)); + } + } +} + +TEST_CASE("releaseClears - releasing an already-free card is a no-op") { + for (int releasing : kAllModes) { + CHECK_FALSE(releaseClears(kNone, releasing)); + } +} diff --git a/tests/tach_filter_test.cpp b/tests/tach_filter_test.cpp new file mode 100644 index 0000000..f4c6ab3 --- /dev/null +++ b/tests/tach_filter_test.cpp @@ -0,0 +1,115 @@ +#include "doctest.h" +#include "tach_filter.h" + +#include + +using namespace tach_filter; + +// --------------------------------------------------------------------------- +// rpmFromMeanPeriodUs — period → RPM conversion +// --------------------------------------------------------------------------- + +TEST_CASE("rpmFromMeanPeriodUs - real engine speeds at wasted spark") { + // 12 ms per rev = 5000 RPM (the comment in tachometer.ino's header) + CHECK(rpmFromMeanPeriodUs(12000.0f, 1.0f) == doctest::Approx(5000.0f)); + // 3 ms = the debounce floor = 20k RPM ceiling + CHECK(rpmFromMeanPeriodUs(3000.0f, 1.0f) == doctest::Approx(20000.0f)); + // 2 s = the sanity-bound ceiling = 30 RPM floor + CHECK(rpmFromMeanPeriodUs(2000000.0f, 1.0f) == doctest::Approx(30.0f)); +} + +TEST_CASE("rpmFromMeanPeriodUs - revsPerPulse scales linearly") { + // Half a rev per pulse (e.g. 2 pulses/rev pickup) halves the RPM. + CHECK(rpmFromMeanPeriodUs(12000.0f, 0.5f) == doctest::Approx(2500.0f)); + CHECK(rpmFromMeanPeriodUs(12000.0f, 2.0f) == doctest::Approx(10000.0f)); +} + +TEST_CASE("rpmFromMeanPeriodUs - non-positive inputs return 0") { + CHECK(rpmFromMeanPeriodUs(0.0f, 1.0f) == 0.0f); + CHECK(rpmFromMeanPeriodUs(-100.0f, 1.0f) == 0.0f); + CHECK(rpmFromMeanPeriodUs(12000.0f, 0.0f) == 0.0f); +} + +// --------------------------------------------------------------------------- +// Kalman update behavior +// --------------------------------------------------------------------------- + +TEST_CASE("Kalman - reset state is rest with high uncertainty") { + Kalman k; + k.x = 4321.0f; + k.p = 5.0f; + reset(k); + CHECK(k.x == 0.0f); + CHECK(k.p == kInitialUncertaintyP); +} + +TEST_CASE("Kalman - first update after reset jumps most of the way") { + // High post-reset uncertainty means the first measurement dominates: + // gain = (10000+800)/(10000+800+2500) ≈ 0.81. + Kalman k; + update(k, 5000.0f, 1); + CHECK(k.x > 4000.0f); + CHECK(k.x < 5000.0f); +} + +TEST_CASE("Kalman - converges to a constant measurement") { + Kalman k; + for (int i = 0; i < 50; i++) { + update(k, 5000.0f, 3); + } + CHECK(k.x == doctest::Approx(5000.0f).epsilon(0.001)); +} + +TEST_CASE("Kalman - approach to a constant input is monotonic (no overshoot)") { + Kalman k; + float prev = k.x; + for (int i = 0; i < 20; i++) { + update(k, 6000.0f, 2); + CHECK(k.x > prev); // climbing toward the measurement… + CHECK(k.x <= 6000.0f); // …without ever passing it + prev = k.x; + } +} + +TEST_CASE("Kalman - more periods per measurement means faster convergence") { + // R scales as R_BASE/periodCount, so a batch of 8 periods pulls the + // estimate harder than a single period does. + Kalman one, eight; + update(one, 5000.0f, 1); + update(eight, 5000.0f, 8); + CHECK(eight.x > one.x); +} + +TEST_CASE("Kalman - uncertainty never collapses below the floor") { + Kalman k; + for (int i = 0; i < 1000; i++) { + update(k, 5000.0f, 8); + } + CHECK(k.p >= kUncertaintyFloorP); +} + +TEST_CASE("Kalman - non-positive period count is a no-op") { + Kalman k; + update(k, 5000.0f, 3); + const float x = k.x; + const float p = k.p; + update(k, 9999.0f, 0); + update(k, 9999.0f, -1); + CHECK(k.x == x); + CHECK(k.p == p); +} + +TEST_CASE("Kalman - tracks a ramp like a real engine pull") { + // Feed an accelerating input (25 Hz batches, +100 RPM per batch) and + // require the estimate to lag but follow within the process noise's + // ability to track crankshaft inertia. + Kalman k; + float rpm = 3000.0f; + for (int i = 0; i < 40; i++) { + update(k, rpm, 3); + rpm += 100.0f; + } + // input is now 6900 (last fed 6900-100); estimate must be close behind + CHECK(k.x > 6000.0f); + CHECK(k.x < rpm); +}