diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 46af1be..93c5d29 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -93,7 +93,8 @@ jobs: "Adafruit SSD1306" \ "SparkFun u-blox GNSS v3" \ "Seeed Arduino LSM6DS3" \ - "ArxTypeTraits" + "ArxTypeTraits" \ + "Adafruit NeoPixel" # Beta builds track the lap-timer library's own BETA branch so the # two beta channels move together (master/release pin a tag). arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#BETA @@ -103,7 +104,7 @@ jobs: run: | arduino-cli compile \ --fqbn "${{ matrix.board.fqbn }}" \ - --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DFIRMWARE_VERSION_OVERRIDE=${{ steps.ver.outputs.betaver }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG -DBIRDSEYE_ENABLE_SENSOREGG=1" \ + --build-property "compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DFIRMWARE_VERSION_OVERRIDE=${{ steps.ver.outputs.betaver }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG -DBIRDSEYE_ENABLE_SENSOREGG=1 -DBIRDSEYE_ENABLE_NEOPIXEL=1" \ --output-dir dist \ --warnings none \ BirdsEye diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 697dc99..ddc7ee1 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -41,4 +41,8 @@ jobs: BirdsEye/lap_format.cpp \ BirdsEye/tach_filter.cpp \ BirdsEye/camera_fsm.cpp \ - BirdsEye/insta360_protocol.cpp + BirdsEye/insta360_protocol.cpp \ + BirdsEye/led_frame.cpp \ + BirdsEye/led_modes.cpp \ + BirdsEye/led_animations.cpp \ + BirdsEye/sector_purple.cpp diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index e664982..dbd9e92 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -34,10 +34,11 @@ jobs: # release tag. LAPTIMER_REF: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && 'BETA' || 'v4.3.0' }} # Feature flags for this build (see project.h). BETA enables the - # SensorEgg POC exactly as beta.yml does, so the flag-on build is - # compile-checked on the PR rather than first failing on the publish - # workflow. Everything else builds the master/release defaults (off). - FEATURE_FLAGS: ${{ (github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA') && '-DBIRDSEYE_ENABLE_SENSOREGG=1' || '' }} + # SensorEgg POC and the NeoPixel strip 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 -DBIRDSEYE_ENABLE_NEOPIXEL=1' || '' }} # Build both XIAO nRF52840 variants. The Sense board has the onboard # LSM6DS3 IMU; the plain board does not (accelerometer logging degrades # gracefully). Same MCU/BLE/bootloader otherwise. @@ -77,6 +78,7 @@ jobs: - name: SparkFun u-blox GNSS v3 - name: Seeed Arduino LSM6DS3 - name: ArxTypeTraits + - name: Adafruit NeoPixel - source-url: https://github.com/TheAngryRaven/DovesLapTimer.git version: ${{ env.LAPTIMER_REF }} sketch-paths: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc89a64..91fb3ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,8 @@ jobs: "Adafruit SSD1306" \ "SparkFun u-blox GNSS v3" \ "Seeed Arduino LSM6DS3" \ - "ArxTypeTraits" + "ArxTypeTraits" \ + "Adafruit NeoPixel" # Release builds pin the lap-timer library to a known-good tag — # bump deliberately. (Beta builds track its BETA branch instead.) arduino-cli lib install --git-url https://github.com/TheAngryRaven/DovesLapTimer.git#v4.3.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a5776d6..f88a91e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -110,6 +110,20 @@ to the matching `*_LOOP()`. pages are compiled out, BLE returns to lazy init, and the DOVEX `Temp1`/`Junction1`/`Temp2` columns are written as `nan` so the log format stays identical across channels. +- **NeoPixel strip** (`neopixel` + the `led_frame` / `led_modes` / + `led_animations` / `sector_purple` pure units) — 11 WS2812 pixels on + the NFC pads (converted to GPIO by a one-time, one-way UICR write on + first flag-on boot): two status alert LEDs flanking a 9-px strip that + shows an RPM scale until pace is meaningful and then a pace pip + (slower = left of center in red, faster = right in green), plus a + boot animation and a purple celebration when a session-best sector is + set (detected race-free against the lap timer's lap-line best-update + by snapshotting bests at sector open). One rule everything obeys: a + global brightness cap (`led_brightness`) applied at a single choke + point — no LED channel ever exceeds it. The strip's 5 V boost + converter has its EN pin driven low in sleep, so System OFF really + powers the LEDs down. Gated on `BIRDSEYE_ENABLE_NEOPIXEL`: on in + beta, off (fully compiled out, no UICR write) in master/release. - **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 diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 2abbe79..8081c3a 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -98,6 +98,7 @@ #include "gps_status_page.h" #include "haversine.h" #include "idle_policy.h" +#include "neopixel.h" #include "replay.h" #include "sat_bars.h" #include "sd_format_page.h" @@ -181,6 +182,11 @@ char settingDeviceName[32] = "BirdsEye"; // sprint track are within detection range (sprint_select::chooseKind). // It never overrides what is actually detected. bool settingRaceModePrefSprint = false; +// NeoPixel strip (plan 0006): global brightness cap (0-255, 0 = LEDs +// disabled entirely) and the rev limit (true RPM) the LED scale/flasher +// are anchored to. Read at boot only, like every other setting. +uint8_t settingLedBrightness = 64; +int settingRevLimit = 15000; // Track manifest for proximity detection TrackManifestEntry trackManifest[MAX_LOCATIONS]; @@ -980,6 +986,18 @@ void setup() { debug(F(" minGapUs=")); debugln((uint32_t)tachMinPulseGapUs); } + // NeoPixel strip (plan 0006). Both clamp back to the compiled-in + // default on a missing or nonsense value, per the house idiom. + if (getSetting("led_brightness", buf, sizeof(buf))) { + const int b = atoi(buf); + if (b >= 0 && b <= 255) settingLedBrightness = (uint8_t)b; + } + if (getSetting("rev_limit", buf, sizeof(buf))) { + const int r = atoi(buf); + // Floor keeps a garbled value from parking the scale at zero; + // ceiling matches the tach filter's ~20k true-RPM limit. + if (r >= 1000 && r <= 20000) settingRevLimit = r; + } crossingThresholdMeters = settingLapDetectionDistance; debug(F("Settings loaded: lap_dist=")); debug(settingLapDetectionDistance); @@ -993,6 +1011,15 @@ void setup() { debugln(settingDeviceName); } + // NeoPixel strip (plan 0006). MUST run before anything that can enable + // the SoftDevice (SENSOREGG_SETUP below calls bleCoreEnsureInit on the + // beta channel): the one-time UICR NFC->GPIO write needs direct NVMC + // access, which is illegal once the SoftDevice is up. Also before + // wdtSetup() so the one-time self-reset can't race the watchdog. Needs + // SETTINGS_SETUP (led_brightness) — a no-op unless + // BIRDSEYE_ENABLE_NEOPIXEL is set (beta channel only). + NEOPIXEL_SETUP(); + // Camera auto-record: load the persisted Insta360 serial + init the FSM CAMERA_SETUP(); @@ -1193,6 +1220,48 @@ bool activeTimerSectorsConfigured() { return false; } +// Sector accessors for the LED purple-sector monitor (plan 0006). +// Sprint-first like every sibling; WaypointLapTimer (Lap Anything) has +// no sectors, so those sessions return 0 and the monitor stays reset. +int activeTimerCurrentSector() { + if (sprintTimer != nullptr) return sprintTimer->getCurrentSector(); + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) return dlt->getCurrentSector(); + return 0; +} + +unsigned long activeTimerLapSectorTime(int sector) { + if (sprintTimer != nullptr) { + if (sector == 1) return sprintTimer->getCurrentLapSector1Time(); + if (sector == 2) return sprintTimer->getCurrentLapSector2Time(); + if (sector == 3) return sprintTimer->getCurrentLapSector3Time(); + return 0; + } + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) { + if (sector == 1) return dlt->getCurrentLapSector1Time(); + if (sector == 2) return dlt->getCurrentLapSector2Time(); + if (sector == 3) return dlt->getCurrentLapSector3Time(); + } + return 0; +} + +unsigned long activeTimerBestSectorTime(int sector) { + if (sprintTimer != nullptr) { + if (sector == 1) return sprintTimer->getBestSector1Time(); + if (sector == 2) return sprintTimer->getBestSector2Time(); + if (sector == 3) return sprintTimer->getBestSector3Time(); + return 0; + } + DovesLapTimer* dlt = getActiveTimerDLT(); + if (dlt) { + if (sector == 1) return dlt->getBestSector1Time(); + if (sector == 2) return dlt->getBestSector2Time(); + if (sector == 3) return dlt->getBestSector3Time(); + } + return 0; +} + /** * @brief Build the sprint session from the just-parsed track file: pick the * newest course by date_created (autocross venues re-lay the course every @@ -2223,6 +2292,10 @@ static void softResumeFromCharging() { // the POC is compiled out — BLE/camera stay lazy either way). SENSOREGG_WAKE(); + // Boost rail back up + strip re-init (no-op when compiled out or + // brightness 0). + NEOPIXEL_WAKE(); + DISPLAY_WAKE(); menuIdleTimerRunning = false; if (!sdSetupSuccess && sdCardUnformatted) { @@ -2276,6 +2349,11 @@ void enterShutdown() { pinMode(PIN_LSM6DS3TR_C_POWER, OUTPUT); digitalWrite(PIN_LSM6DS3TR_C_POWER, HIGH); } + + // LED strip blanked, then its 5 V boost rail off (EN driven LOW — + // retained through System OFF, and the charging loop below never + // re-enables it, so the strip is dark while charging too). + NEOPIXEL_SLEEP(); wdtPet(); // VBUS exception: never System OFF while a cable is present. Powering @@ -2337,6 +2415,10 @@ void loop() { displayPage_bluetooth(); } + // Keep the LED frame ticking so the strip blanks (composition sees + // the parked state) instead of freezing mid-pattern. + NEOPIXEL_LOOP(); + return; // Skip GPS, tach, lap checks while BLE is active } @@ -2367,6 +2449,9 @@ void loop() { displayPage_usb_storage(); } + // Same as the BLE branch: blank the strip rather than freeze it. + NEOPIXEL_LOOP(); + return; // host owns the card — skip GPS/tach/lap/SD entirely } @@ -2382,6 +2467,7 @@ void loop() { autoRaceModeCheck(); updateGpsLockHold(); CAMERA_LOOP(); // step the Insta360 auto-record FSM (GPS/tach fresh above) + NEOPIXEL_LOOP(); // LED strip frame (RPM/pace/purple 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, diff --git a/BirdsEye/led_animations.cpp b/BirdsEye/led_animations.cpp new file mode 100644 index 0000000..cdfbef6 --- /dev/null +++ b/BirdsEye/led_animations.cpp @@ -0,0 +1,144 @@ +#include "led_animations.h" + +namespace led_animations { + +using led_frame::Frame; +using led_frame::kPixelCount; +using led_frame::Rgb; + +uint32_t mix(uint32_t x) { + // xorshift-multiply avalanche (fmix32 shape) with a golden-ratio + // pre-xor so 0 doesn't map to 0. Cheap, stateless, and spreads + // consecutive inputs across the word. + x ^= 0x9E3779B9U; + x ^= x >> 16; + x *= 0x7FEB352DU; + x ^= x >> 15; + x *= 0x846CA68BU; + x ^= x >> 16; + return x; +} + +Rgb hueToRgb(uint8_t hue) { + // 3-segment wheel: 0..84 r->g, 85..169 g->b, 170..255 b->r. + if (hue < 85) { + uint8_t const ramp = (uint8_t)(hue * 3); + return Rgb{(uint8_t)(255 - ramp), ramp, 0}; + } + if (hue < 170) { + uint8_t const ramp = (uint8_t)((hue - 85) * 3); + return Rgb{0, (uint8_t)(255 - ramp), ramp}; + } + uint8_t const ramp = (uint8_t)((hue - 170) * 3); + return Rgb{ramp, 0, (uint8_t)(255 - ramp)}; +} + +// Layer white sparkle glints over the frame for one time slot. chance is +// per-pixel out of 256; the glint's brightness envelope is a triangle +// over the slot so pops swell and die instead of blinking. +static void addSparkles(Frame& f, uint32_t tMs, uint32_t seed, + uint8_t chance) { + uint32_t const slot = tMs / kSparkleSlotMs; + uint32_t const phase = tMs % kSparkleSlotMs; + // Triangle envelope 0..255..0 across the slot. + uint32_t const half = kSparkleSlotMs / 2; + uint32_t const env = phase < half ? (phase * 255) / half + : ((kSparkleSlotMs - phase) * 255) / half; + for (int i = 0; i < kPixelCount; i++) { + uint32_t const h = mix(seed ^ (slot * (uint32_t)kPixelCount + (uint32_t)i)); + if ((h & 0xFFU) < chance) { + // Peak brightness varies per glint (128..255). + uint8_t const peak = (uint8_t)(128 + ((h >> 8) & 0x7FU)); + uint8_t const level = (uint8_t)((env * peak) / 255); + f.px[i] = led_frame::add(f.px[i], led_frame::scale(led_frame::kWhite, level)); + } + } +} + +bool renderBoot(uint32_t tMs, uint32_t seed, Frame& out) { + led_frame::clear(out); + if (tMs >= kBootDurationMs) { + return false; + } + + // Global fade level: full through the comet phase, ramping to 0 at + // kBootFadeEndMs, then only sparkles remain. + uint32_t fade = 255; + if (tMs >= kBootFadeEndMs) { + fade = 0; + } else if (tMs >= kBootCometMs) { + fade = ((kBootFadeEndMs - tMs) * 255) / (kBootFadeEndMs - kBootCometMs); + } + + if (fade > 0) { + // Comet head position in 1/256ths of a pixel around the 11-px ring. + // While the comet phase runs it advances kBootCometRevs revolutions; + // during the fade it keeps drifting at the same rate (looks better + // than freezing) — position simply keeps accumulating. + uint32_t const headPos256 = + (tMs * (uint32_t)(kBootCometRevs * kPixelCount) * 256U) / kBootCometMs; + uint32_t const headPx = (headPos256 / 256U) % (uint32_t)kPixelCount; + // Hue advances a full wheel per revolution. + uint8_t const headHue = (uint8_t)(headPos256 / kPixelCount); + for (int back = 0; back < kBootCometTail; back++) { + int const px = (int)((headPx + (uint32_t)(kPixelCount - back)) % + (uint32_t)kPixelCount); + // Quadratic tail falloff: bright head, fast decay. + uint32_t const lin = (uint32_t)(kBootCometTail - back) * 255U / + (uint32_t)kBootCometTail; + uint8_t const level = (uint8_t)((lin * lin) / 255U); + Rgb const c = hueToRgb((uint8_t)(headHue + (uint8_t)(back * 12))); + out.px[px] = led_frame::add( + out.px[px], + led_frame::scale(led_frame::scale(c, level), (uint8_t)fade)); + } + } + + if (tMs >= kBootSparkleStartMs) { + addSparkles(out, tMs, seed, 40); + } + return true; +} + +bool renderPurple(uint32_t tMs, uint32_t seed, Frame& out) { + led_frame::clear(out); + if (tMs >= kPurpleDurationMs) { + return false; + } + + // Chain-absolute center of the whole 11-px run (strip centerline). + const int center = led_frame::kStripFirst + led_frame::kStripCenter; + + if (tMs < kPurpleWaveMs) { + // Expanding wave: radius sweeps 0..full-chain over the wave phase; + // the wavefront pixel gets a white kick so the edge reads as motion. + const int maxRadius = kPixelCount - 1 - center + 1; // reaches px 10 + int const radius = (int)((tMs * (uint32_t)(maxRadius + 1)) / kPurpleWaveMs); + for (int i = 0; i < kPixelCount; i++) { + int const d = i > center ? i - center : center - i; + if (d < radius) { + out.px[i] = led_frame::kPurple; + } else if (d == radius) { + out.px[i] = led_frame::add(led_frame::kPurple, + led_frame::scale(led_frame::kWhite, 96)); + } + } + return true; + } + + // Hold solid, then fade. + uint32_t level = 255; + if (tMs >= kPurpleFadeStartMs) { + level = ((kPurpleDurationMs - tMs) * 255) / + (kPurpleDurationMs - kPurpleFadeStartMs); + } + for (int i = 0; i < kPixelCount; i++) { + out.px[i] = led_frame::scale(led_frame::kPurple, (uint8_t)level); + } + if (tMs < kPurpleFadeStartMs) { + addSparkles(out, tMs, seed, 48); + } + return true; +} + +} // namespace led_animations diff --git a/BirdsEye/led_animations.h b/BirdsEye/led_animations.h new file mode 100644 index 0000000..8f3c758 --- /dev/null +++ b/BirdsEye/led_animations.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include "led_frame.h" + +/////////////////////////////////////////// +// LED GLOBAL ANIMATIONS +// The boot flourish and the purple-sector celebration, rendered as PURE +// functions of (time since start, seed): no rand(), no millis(), no +// retained state. Sparkle placement hashes (seed, time slot, pixel), so +// a given (tMs, seed) pair always produces the identical frame — host +// tests golden-lock exact output and the glue stays trivially +// restartable (re-arm = new start stamp + fresh seed). +// +// Boot (2600 ms): a hue comet circles the 11 px as a ring (2 revolutions +// with a trailing fade), the whole thing fades out, and white sparkle +// glints overlap the tail end. +// Purple (1600 ms): a purple wave expands from the strip center outward +// across all 11 px (status LEDs included — it's a celebration), holds +// solid with sparkles, then fades. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace led_animations { + +constexpr uint32_t kBootDurationMs = 2600; +// Boot phases: comet circles until kBootCometMs, global fade completes +// at kBootFadeEndMs, sparkles run kBootSparkleStartMs..end. +constexpr uint32_t kBootCometMs = 1500; +constexpr uint32_t kBootFadeEndMs = 2200; +constexpr uint32_t kBootSparkleStartMs = 900; +constexpr int kBootCometRevs = 2; +constexpr int kBootCometTail = 5; // pixels of trailing fade + +constexpr uint32_t kPurpleDurationMs = 1600; +constexpr uint32_t kPurpleWaveMs = 400; // center-out expansion +constexpr uint32_t kPurpleFadeStartMs = 1200; + +// Sparkle time-slot length: each (slot, pixel) pair rolls the hash once, +// so glints pop and die at a readable rate instead of shimmering at the +// frame rate. +constexpr uint32_t kSparkleSlotMs = 80; + +// Deterministic integer mixer (xorshift-style avalanche). Exposed for +// tests; also handy anywhere else that needs cheap stateless hashing. +uint32_t mix(uint32_t x); + +// 0..255 color wheel (r->g->b->r), full saturation. +led_frame::Rgb hueToRgb(uint8_t hue); + +// Render the frame at tMs since the animation started. Returns false +// once tMs >= duration (frame is cleared) — the caller drops back to +// normal composition. +bool renderBoot(uint32_t tMs, uint32_t seed, led_frame::Frame& out); +bool renderPurple(uint32_t tMs, uint32_t seed, led_frame::Frame& out); + +} // namespace led_animations diff --git a/BirdsEye/led_frame.cpp b/BirdsEye/led_frame.cpp new file mode 100644 index 0000000..fda161f --- /dev/null +++ b/BirdsEye/led_frame.cpp @@ -0,0 +1,41 @@ +#include "led_frame.h" + +namespace led_frame { + +int physicalIndex(int logical) { + return kChainReversed ? (kPixelCount - 1 - logical) : logical; +} + +void clear(Frame& f) { + for (int i = 0; i < kPixelCount; i++) { + f.px[i] = kOff; + } +} + +static uint8_t scaleChannel(uint8_t v, uint8_t level) { + // Round-to-nearest v*level/255. v<=255 so the result is <= level. + return (uint8_t)(((uint16_t)v * level + 127) / 255); +} + +Rgb scale(Rgb c, uint8_t level) { + return Rgb{scaleChannel(c.r, level), scaleChannel(c.g, level), + scaleChannel(c.b, level)}; +} + +void applyCap(Frame& f, uint8_t cap) { + for (int i = 0; i < kPixelCount; i++) { + f.px[i] = scale(f.px[i], cap); + } +} + +static uint8_t addChannel(uint8_t a, uint8_t b) { + uint16_t const s = (uint16_t)a + b; + return s > 255 ? 255 : (uint8_t)s; +} + +Rgb add(Rgb a, Rgb b) { + return Rgb{addChannel(a.r, b.r), addChannel(a.g, b.g), + addChannel(a.b, b.b)}; +} + +} // namespace led_frame diff --git a/BirdsEye/led_frame.h b/BirdsEye/led_frame.h new file mode 100644 index 0000000..b734e5d --- /dev/null +++ b/BirdsEye/led_frame.h @@ -0,0 +1,80 @@ +#pragma once + +#include + +/////////////////////////////////////////// +// LED FRAME MODEL +// The NeoPixel subsystem's pixel layout and the ONE brightness rule. +// The physical chain is 11 WS2812 pixels: pixel 0 and pixel 10 are the +// two status indicators, pixels 1..9 are the strip with a centerline +// pixel in the middle. Modes and animations author colors in full +// 0..255 and never think about brightness; applyCap() is the single +// choke point that enforces the global cap — after it runs, no channel +// of any pixel exceeds the cap, ever. (Deliberately NOT +// Adafruit_NeoPixel::setBrightness(), which rewrites the pixel buffer +// lossily and would spread the invariant across call sites.) +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace led_frame { + +// LOGICAL chain layout, always left-to-right as the driver sees it: +// logical px 0 is the LEFT status LED, 10 the RIGHT one, 1..9 the strip +// (addressed strip-relative 0..8 by the mode renderers; kStripCenter is +// strip-relative). Every renderer and animation authors in this space. +constexpr int kPixelCount = 11; +constexpr int kStatusLeft = 0; // logical left status LED +constexpr int kStatusRight = 10; // logical right status LED +constexpr int kStripFirst = 1; // strip = logical px 1..9 +constexpr int kStripCount = 9; +constexpr int kStripCenter = 4; // strip-relative centerline (logical px 5) + +// The hardware is wired data-in at the PHYSICAL RIGHT end: chain pixel +// 0 (first on the wire) is the rightmost LED, so the whole chain — +// status LEDs included — is mirrored relative to logical space. +// physicalIndex() does that mapping once, at push time; nothing that +// renders ever thinks about it. Set false if a future build wires +// data-in on the left. +constexpr bool kChainReversed = true; + +// Logical (left-to-right) index -> position on the physical wire. +int physicalIndex(int logical); + +struct Rgb { + uint8_t r; + uint8_t g; + uint8_t b; +}; + +// One full frame of the chain, chain-ordered (status, strip, status). +struct Frame { + Rgb px[kPixelCount]; +}; + +constexpr Rgb kOff{0, 0, 0}; +constexpr Rgb kRed{255, 0, 0}; +constexpr Rgb kGreen{0, 255, 0}; +constexpr Rgb kWhite{255, 255, 255}; +// Purple with a blue lean — pure 50/50 red+blue reads pink on WS2812. +constexpr Rgb kPurple{160, 0, 255}; +// Orange needs the green held well down or it reads yellow. +constexpr Rgb kOrange{255, 64, 0}; + +// All pixels off. +void clear(Frame& f); + +// Scale a color by level/255, round-to-nearest integer math. level 255 +// is identity, 0 is off. +Rgb scale(Rgb c, uint8_t level); + +// THE global brightness invariant. Scales every channel of every pixel +// by cap/255. Post-condition: every channel <= cap (input channels are +// <= 255, so the proportional scale can never exceed the cap). Called +// exactly once per frame, at push time. +void applyCap(Frame& f, uint8_t cap); + +// Saturating add, for layering sparkle highlights over a base frame. +Rgb add(Rgb a, Rgb b); + +} // namespace led_frame diff --git a/BirdsEye/led_modes.cpp b/BirdsEye/led_modes.cpp new file mode 100644 index 0000000..cfc38c1 --- /dev/null +++ b/BirdsEye/led_modes.cpp @@ -0,0 +1,90 @@ +#include "led_modes.h" + +#include + +namespace led_modes { + +using led_frame::kStripCenter; +using led_frame::kStripCount; +using led_frame::Rgb; + +PacePip pacePip(float paceMsPerM) { + float const mag = paceMsPerM < 0 ? -paceMsPerM : paceMsPerM; + if (mag <= kPaceDeadbandMsPerM) { + return PacePip{kStripCenter, led_frame::kWhite}; + } + // Steps away from center: ceil(mag / stepSize), clamped to the 4 + // pixels available per side. Ceil (not round) so the pip leaves the + // center the moment the deadband is exceeded. + const float stepSize = kPaceFullScaleMsPerM / (float)kStripCenter; + int steps = (int)(mag / stepSize); + if ((float)steps * stepSize < mag) { + steps++; + } + if (steps < 1) { + steps = 1; + } + if (steps > kStripCenter) { + steps = kStripCenter; + } + // Positive pace = slower than best = LEFT of center, red. + if (paceMsPerM > 0) { + return PacePip{kStripCenter - steps, led_frame::kRed}; + } + return PacePip{kStripCenter + steps, led_frame::kGreen}; +} + +void renderPace(float paceMsPerM, Rgb out[kStripCount]) { + for (int i = 0; i < kStripCount; i++) { + out[i] = led_frame::kOff; + } + out[kStripCenter] = led_frame::scale(led_frame::kWhite, kPaceCenterLevel); + PacePip const pip = pacePip(paceMsPerM); + out[pip.stripIndex] = pip.color; +} + +void renderScale(float value, const ScaleSpec& spec, Rgb out[kStripCount]) { + float const span = spec.max - spec.min; + float frac = span > 0 ? (value - spec.min) / span : 0.0f; + if (frac < 0) { + frac = 0; + } + if (frac > 1) { + frac = 1; + } + int const lit = (int)lroundf(frac * (float)kStripCount); + // A pixel is "past" the red fraction when its fill position crosses + // it: with redFrac 0.5 on 9 px that is indices 5..8 — red past the + // halfway mark, the center pixel itself still low-color. + int const redFrom = (int)lroundf(spec.redFrac * (float)kStripCount); + for (int i = 0; i < kStripCount; i++) { + if (i >= lit) { + out[i] = led_frame::kOff; + } else { + out[i] = i >= redFrom ? spec.highColor : spec.lowColor; + } + } +} + +led_frame::Rgb evalStatus(const StatusAction& a, StatusState& s, float value, + bool valid, uint32_t nowMs) { + if (a.source == Source::kNone || !valid) { + s.active = false; + return led_frame::kOff; + } + if (!s.active && value >= a.threshold) { + s.active = true; + } else if (s.active && value < a.clearBelow) { + s.active = false; + } + if (!s.active) { + return led_frame::kOff; + } + uint16_t const half = a.flashHalfPeriodMs; + if (half == 0) { + return a.color; // no flash configured: solid + } + return ((nowMs / half) & 1U) == 0 ? a.color : led_frame::kOff; +} + +} // namespace led_modes diff --git a/BirdsEye/led_modes.h b/BirdsEye/led_modes.h new file mode 100644 index 0000000..6c04041 --- /dev/null +++ b/BirdsEye/led_modes.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include "led_frame.h" + +/////////////////////////////////////////// +// LED STRIP MODES + STATUS LED ACTIONS +// The decision math behind what the 9-px strip and the two status LEDs +// show. Three pieces: +// +// - Pace pip: a single pixel that walks left/right of the centerline by +// the live pace delta (activeTimerPaceDifference(), ms per METER, +// positive = slower than best). Slower = LEFT of center in red, +// faster = RIGHT in green, on-pace = dim white centerline only. +// - Scale: a generic left-fill bar (ScaleSpec) — RPM against the rev +// limit today, temperatures later. Lit pixels past a fill fraction +// render the high color ("red past the halfway mark"). +// - Status actions: each status LED is driven by a StatusAction POD — +// source + threshold + hysteresis + flash rate. This table IS the +// phase-2 assignability hook: user settings will parse into the same +// PODs; evalStatus() never changes. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace led_modes { + +// ---- Pace pip ---------------------------------------------------------- + +// Pace delta (ms/m) that pins the pip to the end pixel. 4 steps per +// side, so one pixel step = 0.25 ms/m. On a ~1.2 km kart lap 1.0 ms/m +// is ~1.2 s/lap — a pin-the-needle delta. The OLED pace page treats +// -1.0 as "notably faster", consistent with this. +constexpr float kPaceFullScaleMsPerM = 1.0f; + +// Half a pixel step: inside this the driver is "on pace" and only the +// centerline shows. +constexpr float kPaceDeadbandMsPerM = 0.125f; + +// Centerline brightness (pre-cap): visible as a reference mark without +// competing with the pip. +constexpr uint8_t kPaceCenterLevel = 64; + +struct PacePip { + int stripIndex; // 0..8 strip-relative; kStripCenter = on pace + led_frame::Rgb color; +}; + +// Map a pace delta to pip position + color. Positive (slower) walks LEFT +// from center in red; negative (faster) walks RIGHT in green; |pace| <= +// deadband parks on the center in white. Clamps at the end pixels. +PacePip pacePip(float paceMsPerM); + +// Render the full 9-px strip: dim white centerline + the pip. +void renderPace(float paceMsPerM, led_frame::Rgb out[led_frame::kStripCount]); + +// ---- Generic scale (RPM now, temps later) ------------------------------ + +struct ScaleSpec { + float min; // fill starts here (0 lit) + float max; // full bar + // Lit pixels at/past this FILL FRACTION of the bar render highColor + // instead of lowColor. + float redFrac; + led_frame::Rgb lowColor; + led_frame::Rgb highColor; +}; + +// User spec for the RPM bar: "starts green, towards a defined revlimit +// starts turning red past the halfway mark". +constexpr float kRpmRedFrac = 0.5f; + +// Left-fill: lit count = round(fraction * 9), value clamped into +// [min, max]. Unlit pixels are off. +void renderScale(float value, const ScaleSpec& spec, + led_frame::Rgb out[led_frame::kStripCount]); + +// ---- Status LED actions ------------------------------------------------ + +// What feeds an action. Phase 2 grows this enum as sources appear; the +// sketch glue owns mapping Source -> live value + validity. +enum class Source : uint8_t { + kNone = 0, + kRpm, + kEgtC, +}; + +// One status LED's assignment. POD on purpose: the phase-2 settings +// parser fills these from SETTINGS.json; nothing else changes. +struct StatusAction { + Source source; + float threshold; // fires at/above + float clearBelow; // releases below (hysteresis; must be < threshold) + led_frame::Rgb color; + uint16_t flashHalfPeriodMs; // half-period of the on/off flash +}; + +// Per-LED latch state, owned by the caller. +struct StatusState { + bool active = false; +}; + +// EGT alert defaults: ~1200 F, a typical 2T kart EGT ceiling. Becomes a +// setting in phase 2; hysteresis keeps the flasher from chattering on +// sensor noise around the threshold. +constexpr float kEgtAlertC = 650.0f; +constexpr float kEgtClearC = 630.0f; + +// Rev flasher clears at this fraction of the threshold — deep enough +// that filter jitter at the limiter doesn't strobe the latch. +constexpr float kRevClearFrac = 0.97f; + +constexpr uint16_t kRevFlashHalfPeriodMs = 100; // urgent +constexpr uint16_t kEgtFlashHalfPeriodMs = 250; // noticeable, calmer + +// Evaluate one action. valid=false (stale/NaN input — the glue decides, +// isNanF-guarded) forces the LED off AND releases the latch: a stale +// source must never keep an alert flashing (house rule: never latch +// stale data). Flash phase derives from nowMs so all timing is +// host-testable. +led_frame::Rgb evalStatus(const StatusAction& a, StatusState& s, float value, + bool valid, uint32_t nowMs); + +} // namespace led_modes diff --git a/BirdsEye/neopixel.h b/BirdsEye/neopixel.h new file mode 100644 index 0000000..2b84514 --- /dev/null +++ b/BirdsEye/neopixel.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +// Arduino's auto-generated prototypes land near the top of the +// concatenated TU, and this module's internal npxPushFrame() takes a +// led_frame::Frame& — the type must therefore be visible from +// BirdsEye.ino's include block (which pulls this header), or the +// generated prototype fails to parse. Pure stdint header, safe +// everywhere. (See CLAUDE.md "Development Conventions" on the +// auto-prototype include-order trap.) +#include "led_frame.h" + +/////////////////////////////////////////// +// NEOPIXEL STRIP MODULE (plan 0006) +// 11 WS2812 pixels on the NFC pads (converted to GPIO): pixel 0 and +// pixel 10 are status indicators, pixels 1..9 are the strip with a +// centerline. A 5 V boost converter feeds the strip; its EN pin is +// driven so the rail is truly off in sleep (GPIO state is retained in +// System OFF — the "blue LED stays on" precedent). +// +// All decision math lives in the host-tested pure units (led_frame, +// led_modes, led_animations, sector_purple); this module is glue: it +// snapshots inputs each frame, composes by priority (boot animation > +// purple animation > parked/menu off > race mode + status actions), +// applies the ONE global brightness cap, and pushes the frame. +// +// Compiled out entirely unless BIRDSEYE_ENABLE_NEOPIXEL (project.h, +// beta channel) — a flag-off build never writes UICR and never drives +// these pins. First flag-on boot programs UICR->NFCPINS (one-way) and +// self-resets once; see NEOPIXEL_SETUP() and plan 0006. +// +// Adafruit_NeoPixel's nRF52 show() claims a free PWM instance +// (EasyDMA, interrupts ON, ~0.4 ms for 11 px) — this sketch uses no +// tone()/analogWrite(), so PWM0-2 are always free. If all PWMs were +// ever occupied the library falls back to a bit-bang WITH INTERRUPTS +// OFF — never let a future PWM user create that path. show() also +// mallocs/frees a ~560 B pattern buffer per call: same-size alloc/free +// is fragmentation-benign here, it is NOT a leak. +/////////////////////////////////////////// + +// Pins (Arduino numbering; the Seeed XIAO variant maps the NFC pads: +// 30 = P0.09 / NFC1, 31 = P0.10 / NFC2). The split between the two is +// arbitrary — swap these to match the actual wiring. +#define NEOPIXEL_PIN_BOOST_EN 30 // boost EN: HIGH = 5 V rail on, LOW = off +#define NEOPIXEL_PIN_DATA 31 // WS2812 data in (GRB, 800 kHz) + +// Runtime settings, read at boot in BirdsEye.ino's settings block. +extern uint8_t settingLedBrightness; // global cap 0-255; 0 = LEDs disabled +extern int settingRevLimit; // true RPM: scale ceiling + rev flasher + +// One-time UICR NFC->GPIO ensure (may self-reset ONCE on the first +// flag-on boot — must run before the SoftDevice is enabled and before +// wdtSetup()), then boost rail up, strip init, boot animation armed. +void NEOPIXEL_SETUP(); + +// Self-throttled ~30 Hz frame: gather -> compose -> cap -> show. Called +// from the main loop AND from the BLE/USB parked branches (there it +// renders all-off — a frozen mid-pattern strip looks crashed). +void NEOPIXEL_LOOP(); + +// Shutdown hook (enterShutdown, next to the IMU rail-off): blank the +// strip while 5 V is still up, then data LOW, then boost EN LOW. +// Unconditional — driven-LOW is retained through System OFF. +void NEOPIXEL_SLEEP(); + +// Charging-loop soft resume: boost EN back up, strip re-initialized. +void NEOPIXEL_WAKE(); + +// Fire (or restart) the purple-sector celebration. The module also +// self-detects via sector_purple; this is the external hook. +void neopixelNotifyPurpleSector(); diff --git a/BirdsEye/neopixel.ino b/BirdsEye/neopixel.ino new file mode 100644 index 0000000..09d3d75 --- /dev/null +++ b/BirdsEye/neopixel.ino @@ -0,0 +1,262 @@ +#include "neopixel.h" +#include "project.h" + +#if BIRDSEYE_ENABLE_NEOPIXEL + +#include + +#include "led_animations.h" +#include "led_frame.h" +#include "led_modes.h" +#include "nan_bits.h" +#include "sector_purple.h" +#include "sensoregg.h" +#include "tachometer.h" + +// 30 Hz: smooth for a moving pip and cheap (~0.4 ms show() ≈ 1% CPU). +// The display's 3 Hz gate is a different clock — never share it. +static const uint32_t NPX_FRAME_INTERVAL_MS = 33; + +// Boost converter settle before first data after EN rises. +static const uint32_t NPX_BOOST_SETTLE_MS = 5; + +static Adafruit_NeoPixel npxStrip(led_frame::kPixelCount, NEOPIXEL_PIN_DATA, + NEO_GRB + NEO_KHZ800); + +static bool npxReady = false; // strip powered + begun; LOOP is live +static uint32_t npxLastFrameMs = 0; + +// Animation clocks: active while *Active, rendered from (now - start). +static bool npxBootAnimActive = false; +static uint32_t npxBootAnimStartMs = 0; +static uint32_t npxBootAnimSeed = 0; +static bool npxPurpleActive = false; +static uint32_t npxPurpleStartMs = 0; +static uint32_t npxPurpleSeed = 0; + +// Purple-sector monitor + status LED latches. +static sector_purple::State npxPurpleMon; +static led_modes::StatusState npxRevState; +static led_modes::StatusState npxEgtState; + +// Status LED assignments. Hardcoded defaults for now — phase 2 parses +// user settings into these same PODs (led_modes.h). Thresholds that +// depend on settings are filled in at NEOPIXEL_SETUP(). +static led_modes::StatusAction npxRevAction = { + led_modes::Source::kRpm, 15000.0f, 14550.0f, led_frame::kRed, + led_modes::kRevFlashHalfPeriodMs}; +static led_modes::StatusAction npxEgtAction = { + led_modes::Source::kEgtC, led_modes::kEgtAlertC, led_modes::kEgtClearC, + led_frame::kOrange, led_modes::kEgtFlashHalfPeriodMs}; + +/** + * @brief One-time NFC->GPIO conversion. UICR->NFCPINS bit 0 set means + * the pads still belong to NFCT; program it to 0 (a 1->0-only word + * write, no erase needed) and reset — NFCPINS latches only at reset. + * ONE-WAY: undoing this needs a full chip erase (bootloader reflash). + * Direct NVMC access, so this MUST run before the SoftDevice is + * enabled (NEOPIXEL_SETUP is called before SENSOREGG_SETUP's + * bleCoreEnsureInit) and it runs before wdtSetup() arms the watchdog, + * so the reset can't race it. Every boot after the first skips this. + */ +static void npxEnsureNfcPinsAreGpio() { + if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == 0) { + return; // already GPIO + } + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos; + while (!NRF_NVMC->READY) {} + NRF_UICR->NFCPINS = 0xFFFFFFFE; // clear PROTECT, leave the rest erased + while (!NRF_NVMC->READY) {} + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos; + while (!NRF_NVMC->READY) {} + debugln(F("NFC pads converted to GPIO — one-time reset")); + delay(10); // let the debug byte drain + NVIC_SystemReset(); +} + +void NEOPIXEL_SETUP() { + npxEnsureNfcPinsAreGpio(); + + // Hold both pins in a defined LOW state first — EN low keeps the 5 V + // rail off, data low can't back-power an unpowered strip. + pinMode(NEOPIXEL_PIN_BOOST_EN, OUTPUT); + digitalWrite(NEOPIXEL_PIN_BOOST_EN, LOW); + pinMode(NEOPIXEL_PIN_DATA, OUTPUT); + digitalWrite(NEOPIXEL_PIN_DATA, LOW); + + if (settingLedBrightness == 0) { + debugln(F("NeoPixel: brightness 0 — LEDs disabled, boost held off")); + return; + } + + // Rev flasher tracks the configured limit; clear just below it so + // Kalman jitter at the limiter can't strobe the latch. + npxRevAction.threshold = (float)settingRevLimit; + npxRevAction.clearBelow = (float)settingRevLimit * led_modes::kRevClearFrac; + + digitalWrite(NEOPIXEL_PIN_BOOST_EN, HIGH); + delay(NPX_BOOST_SETTLE_MS); + npxStrip.begin(); + npxStrip.clear(); + npxStrip.show(); + npxReady = true; + + sector_purple::reset(npxPurpleMon); + npxBootAnimActive = true; + npxBootAnimStartMs = millis(); + npxBootAnimSeed = micros(); // house entropy rule: never analogRead() +} + +void neopixelNotifyPurpleSector() { + if (!npxReady) { + return; + } + npxPurpleActive = true; + npxPurpleStartMs = millis(); + npxPurpleSeed = micros(); +} + +/** + * @brief Push a composed frame: apply THE brightness cap (the single + * choke point — after this no channel exceeds settingLedBrightness), + * map logical left-to-right onto the physical wire (the chain is wired + * data-in on the RIGHT — led_frame::kChainReversed), and show. + */ +static void npxPushFrame(led_frame::Frame& frame) { + led_frame::applyCap(frame, settingLedBrightness); + for (int i = 0; i < led_frame::kPixelCount; i++) { + int const phys = led_frame::physicalIndex(i); + npxStrip.setPixelColor((uint16_t)phys, frame.px[i].r, frame.px[i].g, + frame.px[i].b); + } + npxStrip.show(); +} + +void NEOPIXEL_LOOP() { + if (!npxReady) { + return; + } + const uint32_t now = millis(); + if (now - npxLastFrameMs < NPX_FRAME_INTERVAL_MS) { + return; + } + npxLastFrameMs = now; + + // Purple-sector monitor: poll-and-diff against the active timer. + { + sector_purple::Sample smp; + smp.sectorsConfigured = activeTimerSectorsConfigured(); + smp.raceStarted = activeTimerRaceStarted(); + smp.currentSector = activeTimerCurrentSector(); + smp.laps = activeTimerLaps(); + smp.lastLapTime = activeTimerLastLapTime(); + for (int s = 0; s < 3; s++) { + smp.lapSectorTime[s] = activeTimerLapSectorTime(s + 1); + smp.bestSectorTime[s] = activeTimerBestSectorTime(s + 1); + } + if (sector_purple::update(npxPurpleMon, smp) != 0) { + neopixelNotifyPurpleSector(); + } + } + + led_frame::Frame frame; + bool rendered = false; + + // Priority 1: boot animation. + if (npxBootAnimActive) { + rendered = led_animations::renderBoot(now - npxBootAnimStartMs, + npxBootAnimSeed, frame); + npxBootAnimActive = rendered; + } + // Priority 2: purple celebration. + if (!rendered && npxPurpleActive) { + rendered = led_animations::renderPurple(now - npxPurpleStartMs, + npxPurpleSeed, frame); + npxPurpleActive = rendered; + } + // Priority 3/4: parked or menu -> off; racing -> mode + status. + if (!rendered) { + led_frame::clear(frame); + const bool parked = bleActive || usbMscActive; + if (!parked && raceActive) { + // Strip: pace pip once pace means something, RPM scale until then + // (and between sprint runs, where pace shows *waiting* on the + // OLED too). + const bool paceValid = + activeTimerRaceStarted() && activeTimerLaps() >= 1 && + !(sprintModeIsActive() && !activeTimerRunActive()); + led_frame::Rgb stripPx[led_frame::kStripCount]; + if (paceValid) { + led_modes::renderPace(activeTimerPaceDifference(), stripPx); + } else { + const led_modes::ScaleSpec rpmSpec = { + 0.0f, (float)settingRevLimit, led_modes::kRpmRedFrac, + led_frame::kGreen, led_frame::kRed}; + led_modes::renderScale((float)tachLastReported, rpmSpec, stripPx); + } + for (int i = 0; i < led_frame::kStripCount; i++) { + frame.px[led_frame::kStripFirst + i] = stripPx[i]; + } + // Status LEDs: RPM is always a live value (0 when stopped); EGT + // validity is the NaN gate — isNanF, never isnan (-Ofast folds + // isnan to false, see nan_bits.h). + frame.px[led_frame::kStatusLeft] = led_modes::evalStatus( + npxRevAction, npxRevState, (float)tachLastReported, true, now); + const float egtC = sensoreggEgtC(); + frame.px[led_frame::kStatusRight] = led_modes::evalStatus( + npxEgtAction, npxEgtState, egtC, !isNanF(egtC), now); + } else { + // Out of race: release the latches so a stale alert can't flash + // the instant the next session starts. + npxRevState.active = false; + npxEgtState.active = false; + } + } + + npxPushFrame(frame); +} + +void NEOPIXEL_SLEEP() { + // Unconditional, npxReady or not: driven-LOW GPIO is retained through + // System OFF, so this is what actually turns the 5 V rail off for the + // night (the "blue LED stays on after sleep" precedent). + if (npxReady) { + npxStrip.clear(); + npxStrip.show(); + delay(1); // let the last frame latch before the rail drops + npxReady = false; + } + pinMode(NEOPIXEL_PIN_DATA, OUTPUT); + digitalWrite(NEOPIXEL_PIN_DATA, LOW); + pinMode(NEOPIXEL_PIN_BOOST_EN, OUTPUT); + digitalWrite(NEOPIXEL_PIN_BOOST_EN, LOW); +} + +void NEOPIXEL_WAKE() { + if (settingLedBrightness == 0) { + return; // disabled: rail stays off + } + digitalWrite(NEOPIXEL_PIN_BOOST_EN, HIGH); + delay(NPX_BOOST_SETTLE_MS); + npxStrip.begin(); + npxStrip.clear(); + npxStrip.show(); + npxReady = true; +} + +#else // !BIRDSEYE_ENABLE_NEOPIXEL + +/////////////////////////////////////////// +// SUBSYSTEM COMPILED OUT (the master/release default — see project.h) +// +// No UICR write, no pin driving, no Adafruit_NeoPixel dependency in +// the image. The pads stay exactly as the chip shipped. +/////////////////////////////////////////// + +void NEOPIXEL_SETUP() {} +void NEOPIXEL_LOOP() {} +void NEOPIXEL_SLEEP() {} +void NEOPIXEL_WAKE() {} +void neopixelNotifyPurpleSector() {} + +#endif // BIRDSEYE_ENABLE_NEOPIXEL diff --git a/BirdsEye/project.h b/BirdsEye/project.h index 7c4bafb..9d8ec19 100644 --- a/BirdsEye/project.h +++ b/BirdsEye/project.h @@ -82,6 +82,25 @@ #define BIRDSEYE_ENABLE_SENSOREGG 0 #endif +// ---- NeoPixel strip (11 px: 2 status + 9-px pace/RPM strip) ---- +// +// 0 (default — master and release): the whole subsystem is compiled out. +// The module's entry points become no-ops and, critically, the firmware +// NEVER writes UICR->NFCPINS and never drives pins 30/31 (P0.09/P0.10, +// the NFC pads) — a flag-off build leaves the pads exactly as it found +// them. +// +// 1 (the beta channel passes -DBIRDSEYE_ENABLE_NEOPIXEL=1): on first +// boot NEOPIXEL_SETUP() converts the NFC pads to GPIO by programming +// UICR->NFCPINS (a ONE-WAY change — undoing it needs a full chip erase, +// i.e. a bootloader reflash; accepted, NFC is never used on this +// hardware) and self-resets once so the pin latch takes effect. After +// that: pin 30 = boost converter EN, pin 31 = WS2812 data. See plan +// 0006 and neopixel.h. +#ifndef BIRDSEYE_ENABLE_NEOPIXEL + #define BIRDSEYE_ENABLE_NEOPIXEL 0 +#endif + /////////////////////////////////////////// // BOARD VARIANT // diff --git a/BirdsEye/sector_purple.cpp b/BirdsEye/sector_purple.cpp new file mode 100644 index 0000000..077852b --- /dev/null +++ b/BirdsEye/sector_purple.cpp @@ -0,0 +1,87 @@ +#include "sector_purple.h" + +namespace sector_purple { + +void reset(State& s) { + s.lastSector = -1; + s.lastLaps = 0; + for (int i = 0; i < 3; i++) { + s.bestAtOpen[i] = 0; + } + s.s1 = 0; + s.s2 = 0; +} + +int update(State& s, const Sample& in) { + if (!in.sectorsConfigured || !in.raceStarted) { + reset(s); + return 0; + } + + if (s.lastSector < 0) { + // First sample of a live race: adopt the current position without + // firing. Snapshot the open sector's best now — mid-lap attach is + // safe because bests only change at the lap line, which we handle + // as an edge below from here on. + s.lastSector = in.currentSector; + s.lastLaps = in.laps; + if (in.currentSector >= 1 && in.currentSector <= 3) { + s.bestAtOpen[in.currentSector - 1] = + in.bestSectorTime[in.currentSector - 1]; + } + return 0; + } + + int fired = 0; + + // Lap edge = sector 3 closed at the start/finish line. The library may + // already have updated bests and rolled the current-lap times, so S3 + // is derived from the lap total minus our own recorded S1/S2 and + // compared against the snapshot taken when S3 opened. + if (in.laps > s.lastLaps) { + if (s.s1 > 0 && s.s2 > 0 && in.lastLapTime > s.s1 + s.s2) { + uint32_t const s3 = in.lastLapTime - s.s1 - s.s2; + if (s.bestAtOpen[2] != 0 && s3 < s.bestAtOpen[2]) { + fired = 3; + } + } + // Re-arm for the new lap: S1 is open now. + s.s1 = 0; + s.s2 = 0; + s.bestAtOpen[0] = in.bestSectorTime[0]; + s.lastLaps = in.laps; + s.lastSector = in.currentSector; + return fired; + } + + if (in.currentSector != s.lastSector) { + if (s.lastSector == 1 && in.currentSector == 2) { + // S1 closed: its current-lap time is now populated. + s.s1 = in.lapSectorTime[0]; + if (s.bestAtOpen[0] != 0 && s.s1 > 0 && s.s1 < s.bestAtOpen[0]) { + fired = 1; + } + s.bestAtOpen[1] = in.bestSectorTime[1]; + } else if (s.lastSector == 2 && in.currentSector == 3) { + s.s2 = in.lapSectorTime[1]; + if (s.bestAtOpen[1] != 0 && s.s2 > 0 && s.s2 < s.bestAtOpen[1]) { + fired = 2; + } + s.bestAtOpen[2] = in.bestSectorTime[2]; + } else if (in.currentSector == 1) { + // Race start / new sprint run: S1 just opened. + s.s1 = 0; + s.s2 = 0; + s.bestAtOpen[0] = in.bestSectorTime[0]; + } else if (in.currentSector == 0) { + // Between sprint runs: nothing in progress. + s.s1 = 0; + s.s2 = 0; + } + s.lastSector = in.currentSector; + } + + return fired; +} + +} // namespace sector_purple diff --git a/BirdsEye/sector_purple.h b/BirdsEye/sector_purple.h new file mode 100644 index 0000000..2d71f0b --- /dev/null +++ b/BirdsEye/sector_purple.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +/////////////////////////////////////////// +// PURPLE (SESSION-BEST) SECTOR DETECTION +// Decides when the driver just set a session-best sector so the LED +// strip can fire its purple celebration. The lap timer library exposes +// getCurrentSector() / getCurrentLapSectorNTime() / getBestSectorNTime() +// but no events, and updateBestSectors() runs AT THE START/FINISH +// CROSSING — not at each sector line — so at the exact moment sector 3 +// closes, the library may already have folded this lap into the "best" +// values and rolled the current-lap times over. This unit never +// compares against live library state at a close edge: +// +// - Best times are snapshotted when each sector OPENS (bestAtOpen[]). +// - S1/S2 close on getCurrentSector() transitions (1->2, 2->3) and +// compare the just-closed current-lap time against the snapshot. +// - S3 closes on the LAP-COUNT edge, and its time is DERIVED: +// lastLapTime - s1 - s2 — both inputs immune to the lap-line update. +// - Purple fires only when the beaten best was nonzero: no purple +// anywhere on lap 1 (celebrating every first-ever sector is noise). +// +// Poll-and-diff, the house pattern (sprintLastRunCount). The sketch +// builds a Sample from the activeTimer*() wrappers each LED frame. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace sector_purple { + +struct Sample { + // activeTimerSectorsConfigured(): Lap Anything / WaypointLapTimer has + // no sectors — false resets the monitor and nothing ever fires. + bool sectorsConfigured; + bool raceStarted; + // getCurrentSector(): 0 = race not started / between sprint runs, + // 1..3 = active sector. + int currentSector; + int laps; // completed laps — the S3 close edge + uint32_t lastLapTime; // ms + uint32_t lapSectorTime[3]; // getCurrentLapSector{1,2,3}Time(), ms + uint32_t bestSectorTime[3];// getBestSector{1,2,3}Time(), ms, 0 = none +}; + +struct State { + int lastSector; // -1 = uninitialized + int lastLaps; + uint32_t bestAtOpen[3]; // best time snapshotted when sector N opened + uint32_t s1; // this lap's closed sector times (0 = not yet) + uint32_t s2; +}; + +void reset(State& s); + +// Feed one sample; returns 1..3 when that sector just went +// session-purple, else 0. Equal times do not fire (strict improvement). +int update(State& s, const Sample& in); + +} // namespace sector_purple diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index a57ad34..498b6e0 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -123,6 +123,11 @@ static void ensureDefaultSettings() { // starts the rotation at the speed page — end users never see the // diagnostic counters; "show" restores them for development/tuning. { "debug_pages", "hide" }, + // NeoPixel strip (plan 0006). Written on every channel so + // SETTINGS.json has a uniform shape (SensorEgg DOVEX-column + // precedent); only a BIRDSEYE_ENABLE_NEOPIXEL build reads them. + { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled + { "rev_limit", "15000" }, // true RPM: LED scale ceiling + rev flasher }; char buf[48]; diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 3855603..7cdeae9 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -148,9 +148,13 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/idle_policy.cpp ${BIRDSEYE_DIR}/lap_format.cpp + ${BIRDSEYE_DIR}/led_animations.cpp + ${BIRDSEYE_DIR}/led_frame.cpp + ${BIRDSEYE_DIR}/led_modes.cpp ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sd_access_policy.cpp ${BIRDSEYE_DIR}/sd_format_page.cpp + ${BIRDSEYE_DIR}/sector_purple.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp ${BIRDSEYE_DIR}/sprint_select.cpp ${BIRDSEYE_DIR}/tach_filter.cpp diff --git a/BirdsEye/sim/sim_main.cpp b/BirdsEye/sim/sim_main.cpp index cc9f9c3..9abc73f 100644 --- a/BirdsEye/sim/sim_main.cpp +++ b/BirdsEye/sim/sim_main.cpp @@ -6,9 +6,10 @@ // translation unit, with sim_prototypes.h standing in for the IDE's // auto-generated function prototypes. // -// bluetooth.ino, camera_ble.ino, firmware_ota.ino and usb_msc.ino are -// DELIBERATELY ABSENT — no BLE / camera / USB-host in the demo scope. -// Their public surfaces are satisfied by stubs/module_stubs.cpp. +// bluetooth.ino, camera_ble.ino, firmware_ota.ino, usb_msc.ino and +// neopixel.ino are DELIBERATELY ABSENT — no BLE / camera / USB-host / +// LED strip in the demo scope. Their public surfaces are satisfied by +// stubs/module_stubs.cpp. // // The firmware sources compile UNMODIFIED (ground rule): everything // sim-specific lives behind the SIM flag inside them, or out here. diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index 333a6e1..def7590 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -55,6 +55,9 @@ float activeTimerPaceDifference(); float activeTimerTotalDistance(); unsigned long activeTimerOptimalLapTime(); bool activeTimerSectorsConfigured(); +int activeTimerCurrentSector(); +unsigned long activeTimerLapSectorTime(int sector); +unsigned long activeTimerBestSectorTime(int sector); SprintTimer* getActiveTimerSprint(); bool sprintModeIsActive(); bool activeTimerRunActive(); diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index c348562..93ff76b 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -1,10 +1,10 @@ /////////////////////////////////////////// // Stubs for the firmware modules deliberately excluded from the sim TU: // bluetooth.ino, camera_ble.ino, usb_msc.ino, firmware_ota.ino, -// sensoregg.ino. +// sensoregg.ino, neopixel.ino. // -// The sim has no BLE, no camera, no USB host (demo scope — see the -// handoff spec). These implement the excluded modules' public headers +// The sim has no BLE, no camera, no USB host, no LED strip (demo scope +// — see the handoff spec). These implement the excluded modules' public headers // so the compiled modules (menus, main loop) link and behave sanely: // every query reports "not present / not active", every action succeeds // silently, and nothing prints warnings into the UX. @@ -16,6 +16,7 @@ #include "bluetooth.h" #include "camera_ble.h" +#include "neopixel.h" #include "sensoregg.h" #include "usb_msc.h" @@ -112,6 +113,18 @@ uint8_t sensoreggBatteryPct() { return 0xFF; } bool sensoreggTcFault() { return false; } uint16_t sensoreggSequence() { return 0; } +// ---- neopixel.ino surface ---- + +// No LED strip in the sim (and no UICR to program). The pure units +// (led_frame/led_modes/led_animations/sector_purple) still build into +// the sim via SIM_CORE_SOURCES for any future host harness use. + +void NEOPIXEL_SETUP() {} +void NEOPIXEL_LOOP() {} +void NEOPIXEL_SLEEP() {} +void NEOPIXEL_WAKE() {} +void neopixelNotifyPurpleSector() {} + // ---- usb_msc.ino surface ---- bool usbMscActive = false; diff --git a/CHANGELOG.md b/CHANGELOG.md index 2116589..5fa36d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,24 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] -Slated to release as **4.0.1** (patch — bug fixes only) unless the scope -changes before the cut. +Slated to release as **4.1.0** (minor — new NeoPixel subsystem on the +beta channel, plus the fixes below). + +### Added +- **NeoPixel LED strip subsystem** (beta channel only, + `BIRDSEYE_ENABLE_NEOPIXEL`, plan 0006): 11 WS2812 pixels on the NFC + pads converted to GPIO — 2 status indicators + a 9-px strip with a + centerline. A global brightness cap (`led_brightness` setting, 0 + disables the LEDs entirely) that no LED can ever exceed; a pace pip + strip mode (left of center red = slower than best, right green = + faster) with an RPM scale (green filling to red past halfway toward + the new `rev_limit` setting) until pace is meaningful; assignable + status-LED alert actions (rev-limit flasher + EGT flasher for now); a + boot animation; and a purple celebration when a session-best sector is + set. The strip's 5 V boost converter is enabled by its own pin, so + sleep truly powers the LEDs off. **First boot of a flag-on build + performs a one-way NFC-pads-to-GPIO conversion (UICR write) and + resets once.** ### Fixed - **Exiting USB transfer mode no longer risks a hang + watchdog reset.** diff --git a/CLAUDE.md b/CLAUDE.md index d47398a..5b911ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ Core capabilities: DovesSensorEgg thermocouple pod's advertising broadcasts (`PW-ADV` v1 and v2), logs `Temp1`/`Junction1`/`Temp2` DOVEX columns + Temp1/Temp2 race pages (subsystem 14) +- **NeoPixel strip (beta)**: 11 WS2812 pixels on the NFC pads converted + to GPIO — 2 status alert LEDs + a 9-px pace-pip / RPM-scale strip with + a global brightness cap, boot animation, and a purple session-best + sector celebration (subsystem 16) --- @@ -92,6 +96,7 @@ All sketch sources live in `BirdsEye/` so the folder name matches the | `display_pages.{h,ino}` | All page rendering functions (`displayPage_*()`) | | `display_ui.{h,ino}` | Display init, button reading (multi-sample debounce), menu navigation, I2C bus recovery | | `gps_functions.{h,ino}` | GPS init (SparkFun UBX PVT), time conversion, DOVEX logging pipeline, TIMER3 serial buffer ISR, V_BCKP recovery | +| `neopixel.{h,ino}` | NeoPixel strip glue (subsystem 16): one-time UICR NFC→GPIO ensure, boost-EN power control, 30 Hz compose→cap→show frame loop, sleep/wake hooks; all decision math in the led_* / sector_purple pure units | | `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) | @@ -117,6 +122,10 @@ 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 | +| `led_frame.{h,cpp}` | NeoPixel pixel layout (11 px: 2 status + 9-px strip), `Rgb`/`Frame` PODs, and **`applyCap()` — the single global-brightness choke point** (post-condition: no channel exceeds the cap) | +| `led_modes.{h,cpp}` | Strip modes + status actions: pace pip math (ms/m, slower = left/red), generic `ScaleSpec` left-fill (RPM red past halfway; temps later), and the `StatusAction` threshold/hysteresis/flash table — the phase-2 assignability hook | +| `led_animations.{h,cpp}` | Boot + purple-sector animations as pure functions of `(tMs, seed)` — hash-based sparkles, no rand()/millis(), golden-testable | +| `sector_purple.{h,cpp}` | Session-best ("purple") sector detection: open-time best snapshots + a derived S3 defeat the library's lap-line `updateBestSectors()` race; no purple on lap 1 | | `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 | @@ -195,6 +204,8 @@ handoff spec. | D3 | Button 3 (Right) | INPUT_PULLUP, RC filter recommended | | D0 | Tachometer input | INPUT_PULLUP, falling-edge ISR | | PIN_VBAT / VBAT_ENABLE | Battery ADC | 1510/510 ohm divider, 3.6 V ref | +| Pin 30 (P0.09, NFC1 pad) | 5 V boost converter EN | HIGH = rail on; LOW retained through System OFF. Beta only — needs the one-way UICR NFC→GPIO conversion (subsystem 16) | +| Pin 31 (P0.10, NFC2 pad) | NeoPixel data | 11 px WS2812, GRB, 800 kHz. Same UICR requirement; swap with pin 30 in `neopixel.h` if wired the other way | --- @@ -214,6 +225,7 @@ loop() ~250 Hz ├─ checkAutoIdle() tach: 60s <2mph; manual/speed: 5min <5mph (+ camera stop) ├─ updateGpsLockHold() pin user to tach page until GPS time lock ├─ CAMERA_LOOP() step Insta360 auto-record FSM (GPS/tach fresh) + ├─ NEOPIXEL_LOOP() LED strip frame at 30 Hz (also called in parked branches) ├─ cameraConsumeAutoStop() camera 30s-engine-off stop → endRaceSession + menu ├─ calculateGPSFrameRate() 1-second PVT counter ├─ readButtons() multi-sample debounce + edge detection @@ -1200,6 +1212,102 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. - **Sim**: fully exercised — five golden fixtures walk the real menus, inject real PVT, run a real averaging hold, and lock the rendered pixels. +### 16. NeoPixel Strip (`neopixel.{h,ino}`, `led_frame/led_modes/led_animations/sector_purple.{h,cpp}`) + +- **BUILD FLAG — `BIRDSEYE_ENABLE_NEOPIXEL` (`project.h`)**: `0` + (master/release default) compiles the whole subsystem down to no-op + `NEOPIXEL_*` entry points — no UICR write, no pin driving, no + Adafruit NeoPixel dependency in the image. `1` (beta channel, passed + by `beta.yml` + BETA-targeted `compile-sketch.yml` runs) is + everything below. +- **Hardware**: 11 WS2812 pixels fed by an Adafruit 5 V boost converter. + Pixels 0 and 10 are status indicators; pixels 1–9 are the strip with + pixel 5 the centerline. Pin 30 (P0.09/NFC1) drives the boost EN + (HIGH = 5 V rail on), pin 31 (P0.10/NFC2) is the data line — both + `#define`s in `neopixel.h`, swap to match wiring. **The chain is + wired data-in on the physical RIGHT** (chain px 0 = rightmost LED): + everything renders in logical left-to-right space and + `led_frame::physicalIndex()` mirrors the whole chain — status LEDs + included — once at push time (`kChainReversed`, true for this build). +- **NFC→GPIO is a one-time runtime UICR write** (`NEOPIXEL_SETUP()`): + if `UICR->NFCPINS` still has the PROTECT bit, unlock NVMC, program + `0xFFFFFFFE`, relock, `NVIC_SystemReset()` — NFCPINS latches only at + reset. **ONE-WAY** (undo = full chip erase / bootloader reflash; + accepted, NFC is never used). Deliberately NOT the + `-DCONFIG_NFCT_PINS_AS_GPIOS` core flag: that's consumed by the + core's `system_nrf52840.c` (a `.c` file `compiler.cpp.extra_flags` + can't reach) and would silently not apply to IDE builds. Direct NVMC + access is illegal under the SoftDevice, so `NEOPIXEL_SETUP()` runs + **before `CAMERA_SETUP()`/`SENSOREGG_SETUP()`** (which + `bleCoreEnsureInit()` on beta) and before `wdtSetup()` arms the + watchdog. Every later boot skips the branch. +- **The global brightness cap is THE invariant**: modes and animations + author colors in full 0–255; `led_frame::applyCap()` scales every + channel by `led_brightness`/255 exactly once, at push time, in + `npxPushFrame()`. After it, no channel exceeds the cap — host-tested + as a post-condition. Never use `strip.setBrightness()` (lossy buffer + rewrite, spreads the invariant). `led_brightness` 0 = LEDs disabled: + the boost rail is never even enabled. +- **Frame loop** (`NEOPIXEL_LOOP()`, self-throttled 30 Hz, called after + `CAMERA_LOOP()` AND inside both parked branches so the strip blanks + rather than freezes): snapshot inputs → step the `sector_purple` + monitor → compose by priority — **boot animation > purple animation > + (parked ‖ !raceActive ‖ brightness 0 → off) > race rendering** — → + `applyCap` → show. +- **Strip policy**: off outside a race session (driving aid, not menu + bling). In race: **RPM scale** (green filling left→right, red past + halfway, ceiling = `rev_limit`) until pace is valid — `paceValid = + activeTimerRaceStarted() && laps >= 1 && !(sprint && between-runs)`, + mirroring the OLED pace page — then the **pace pip**: + `activeTimerPaceDifference()` is **ms per meter**, positive = slower; + full deflection ±1.0 ms/m (`kPaceFullScaleMsPerM`, 0.25/pixel), + ±0.125 deadband = dim-white centerline only. Slower = LEFT of center + in red, faster = RIGHT in green. +- **Status actions** (`led_modes::StatusAction` PODs — the phase-2 + assignability hook; settings will parse into the same structs): + pixel 0 = rev-limit flasher (red, ≥ `rev_limit`, clears at 97%, + 100 ms half-period), pixel 10 = EGT flasher (orange, ≥ 650 °C + `kEgtAlertC`, clears 630 °C, 250 ms). Validity gates the latch: a + NaN/stale EGT (checked with `isNanF()`, NEVER `isnan()` — `-Ofast`) + turns the LED off AND releases the latch. Latches also release when + leaving race mode. +- **Purple sector** (`sector_purple`, host-tested): the library updates + best-sector times **at the start/finish crossing**, not at sector + lines, so the monitor snapshots each sector's best when the sector + OPENS, closes S1/S2 on `getCurrentSector()` transitions, and derives + S3 on the lap edge as `lastLapTime − s1 − s2` — immune to the + lap-line race. Fires only against a nonzero prior best (no purple on + lap 1). Feeds `neopixelNotifyPurpleSector()` → 1.6 s purple + wave/hold/fade over ALL 11 px. New sprint-first wrappers: + `activeTimerCurrentSector()`, `activeTimerLapSectorTime(n)`, + `activeTimerBestSectorTime(n)` (WaypointLapTimer → 0, monitor stays + reset). +- **Animations** (`led_animations`): pure functions of `(tMs, seed)` — + sparkles hash `(seed, timeSlot, pixel)`, no `rand()`/`millis()` + inside, so frames are golden-testable. Boot: 2.6 s hue comet circling + the 11 px + fade-out + white sparkles, plays over the GPS status + page. Seeds come from `micros()` (house entropy rule). +- **Sleep/wake**: `NEOPIXEL_SLEEP()` (in `enterShutdown()` with the IMU + rail-off, before the charging branch — so the strip is dark while + charging too) blanks the strip while 5 V is up, then data LOW, then + boost EN LOW — driven-LOW is retained through System OFF (the "blue + LED stays on" precedent). `NEOPIXEL_WAKE()` (charging soft-resume) + re-raises EN, waits the 5 ms settle, re-inits the strip. +- **Radio/timing safety**: Adafruit_NeoPixel's nRF52 `show()` grabs a + FREE PWM instance (EasyDMA, interrupts ON, ~0.4 ms for 11 px ≈ 1% + CPU at 30 Hz). This sketch uses no `tone()`/`analogWrite()`, so + PWM0–2 are always free; TIMER3's GPS drain and the tach ISR are + unaffected. If all PWMs were ever occupied the library bit-bangs + **with interrupts off** — never create that path. `show()` also + mallocs/frees a ~560 B pattern buffer per call: same-size alloc/free + is fragmentation-benign, it is NOT a leak. +- **Sim**: `neopixel.ino` excluded from the sim TU (BLE-module + precedent), surface no-op'd in `module_stubs.cpp`; the four pure + units build into the sim via `SIM_CORE_SOURCES`. +- **Phase 2 (planned, not built)**: settings-driven mode selection and + per-status-LED action assignment (source/threshold/color), temp + scales through the same `ScaleSpec`, C/F preference, brightness UI. + --- ## Data Formats @@ -1294,7 +1402,9 @@ the one loaded). Sector lines stay optional — zero, one, or two. "driver_name": "Driver", "lap_detection_distance": "7", "waypoint_detection_distance": "30", - "waypoint_speed": "30" + "waypoint_speed": "30", + "led_brightness": "64", + "rev_limit": "15000" } ``` @@ -1313,6 +1423,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | `display_invert` | string | `"normal"` | Panel colours: `normal` = lit-on-black as shipped, `inverted` = black-on-lit. Anything other than an explicit `inverted` means normal | | `debug_pages` | string | `"hide"` | Race-rotation diagnostic pages (`GPS_DEBUG` + `GPS_STATS`): `hide` = rotation starts at the speed page (end-user default), `show` = diagnostics restored at the front. Anything other than an explicit `show` means hide. No-op under `ENDURANCE_MODE` (already starts at speed) | | `cylinder_count` | int | `1` | Cylinders the **pickup sees** — a clamp on one plug wire of a twin sees ONE. Only a shared coil / all-cylinder harness sees them all | +| `led_brightness` | int | `64` | NeoPixel global brightness cap 0–255 — no LED channel ever exceeds it (`led_frame::applyCap`). `0` disables the LEDs entirely (boost rail never enabled). Read only by `BIRDSEYE_ENABLE_NEOPIXEL` builds; clamp back to 64 on nonsense | +| `rev_limit` | int | `15000` | True RPM anchoring the LED subsystem: RPM-scale ceiling + the rev-limit status flasher threshold. Clamp 1000–20000 (tach filter's ceiling). LED-only for now — nothing else reads it | - Created automatically on first boot with random BLE values. - Missing keys auto-populated on boot via `ensureDefaultSettings()`. @@ -1403,6 +1515,15 @@ the one loaded). Sector lines stay optional — zero, one, or two. | SensorEgg scanner self-heal | 30 s no packet → stop+start kick | `sensoregg_protocol.h` | | SensorEgg RSSI floor | −90 dBm | `sensoregg_protocol.h` | | SensorEgg pairing MAC | `SENSOREGG_MAC` (all-zeros = any egg) | `sensoregg.h` | +| NeoPixel strip flag | `BIRDSEYE_ENABLE_NEOPIXEL`, default 0; 1 on the beta channel | `project.h` | +| NeoPixel pins | 30 = boost EN, 31 = data (NFC pads, post-UICR) | `neopixel.h` | +| NeoPixel layout | 11 px: status 0 + strip 1–9 (center px 5) + status 10 | `led_frame.h` | +| LED frame rate | 30 Hz (`NPX_FRAME_INTERVAL_MS` 33) | `neopixel.ino` | +| LED brightness default | 64 / 255 (`led_brightness`; 0 = disabled) | `settings.ino` | +| Pace pip full scale / deadband | ±1.0 ms/m (0.25 per pixel) / ±0.125 | `led_modes.h` | +| RPM scale red fraction | 0.5 (red past halfway) | `led_modes.h` | +| Rev flasher clear / EGT alert-clear | 97% of `rev_limit` / 650→630 °C | `led_modes.h` | +| Boot / purple animation | 2600 ms / 1600 ms | `led_animations.h` | --- @@ -1418,6 +1539,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | SdFat | SD card (FAT16/32) | | DovesLapTimer | Lap/sector timing (external: TheAngryRaven/DovesLapTimer). CI refs: `BETA`-targeted builds track the library's `BETA` branch; master/release builds pin `v4.3.0` (bump deliberately) | | Seeed Arduino LSM6DS3 | Onboard IMU accelerometer/gyro (Sense variant, ±16g) | +| Adafruit NeoPixel | WS2812 strip driver (subsystem 16; linked in but inert unless `BIRDSEYE_ENABLE_NEOPIXEL`) | | Bluefruit nRF52 | BLE (built into board package) | | Adafruit TinyUSB | USB Mass Storage (`Adafruit_USBD_MSC`); built into board package | @@ -1470,7 +1592,7 @@ This device operates in ignition-noise environments. Three layers of defense: `compiler.cpp.extra_flags` property — a second `--build-property` for one key replaces the first). Local setup: CONTRIBUTING.md "Local build flags". -- **Feature flags** (`project.h`, both default `0`, both tested with `#if` +- **Feature flags** (`project.h`, all default `0`, all 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 @@ -1479,6 +1601,10 @@ This device operates in ignition-noise environments. Three layers of defense: (`beta.yml`, plus `compile-sketch.yml` for PRs targeting `BETA` so the flag-on build is compile-checked before it reaches the publish workflow). See subsystem 14. + - `BIRDSEYE_ENABLE_NEOPIXEL` — off in master/release, **on in beta** + (same two workflows as SENSOREGG). First flag-on boot performs the + ONE-WAY UICR NFC→GPIO conversion and self-resets once. See + subsystem 16. When adding a flag: give it a `#ifndef` default in `project.h`, decide its per-channel value in the workflows, and document it here + in CONTRIBUTING.md's flag table. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 452a084..374b285 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,7 @@ so an explicit `-DFLAG=0` still turns the feature off. |---|---|---|---| | `BIRDSEYE_ENABLE_ONBOARD_CHARGING` | `0` | nothing — off in every channel | Holds the BQ25100 HICHG pin high for ~100 mA fast charge and runs the USB charging UX (VBUS wake shortcuts to the charge screen; the main menu drops into the charging loop after `USB_MENU_CHARGE_IDLE_MS`). Off, the firmware leaves HICHG alone and an external charging circuit owns the battery. The VBUS park at shutdown happens either way — see ARCHITECTURE.md. | | `BIRDSEYE_ENABLE_SENSOREGG` | `0` | `beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA` | Compiles in the wireless-EGT POC: passive BLE scanner, Temp1 race page, and BLE core up at boot. Off, the accessors return NaN, so `Temp1`/`Junction1` still log as `nan` and the log format is unchanged. | +| `BIRDSEYE_ENABLE_NEOPIXEL` | `0` | `beta.yml`, and `compile-sketch.yml` for PRs targeting `BETA` | Compiles in the NeoPixel strip subsystem (plan 0006): on first boot it programs `UICR->NFCPINS` to convert the NFC pads to GPIO (**one-way** — undoing it needs a full chip erase) and self-resets once, then drives pin 30 as the 5 V boost EN and pin 31 as WS2812 data. Off, the firmware never touches UICR or those pins. Needs the `Adafruit NeoPixel` library. | ### arduino-cli The exact invocation CI uses is in diff --git a/docs/plans/0006-neopixel-strip.md b/docs/plans/0006-neopixel-strip.md new file mode 100644 index 0000000..a385542 --- /dev/null +++ b/docs/plans/0006-neopixel-strip.md @@ -0,0 +1,275 @@ +# 0006 — NeoPixel strip subsystem (2 status LEDs + 9-px strip) + +## The hardware + +An 11-pixel WS2812 (NeoPixel) strip was added to the device, powered by an +Adafruit 5 V boost converter with an ENABLE pin. The two nRF52840 NFC pads +are converted to plain GPIO to drive it: + +| Pin | P-number | Role | +|---|---|---| +| Arduino 30 (`PIN_NFC1`) | P0.09 | Boost converter EN — HIGH = 5 V rail on, LOW = off | +| Arduino 31 (`PIN_NFC2`) | P0.10 | NeoPixel data (GRB, 800 kHz) | + +Both pads are mapped in the Seeed XIAO variant (`g_ADigitalPinMap[30] = 9`, +`[31] = 10`), so `pinMode`/`digitalWrite`/Adafruit_NeoPixel work on them +directly once NFC protection is off. The assignment between the two pads is +arbitrary — `neopixel.h` holds both as single-point `#define`s so they can be +swapped to match the actual wiring. + +Pixel layout: **pixel 0 and pixel 10 are status indicators**; **pixels 1–9 +are the strip**, with pixel 5 the centerline. The chain is wired with +data-in on the physical RIGHT end (chain pixel 0 = rightmost LED), so +the whole chain is mirrored relative to how the driver reads it: +renderers author in logical left-to-right space and +`led_frame::physicalIndex()` (`kChainReversed = true`) maps +logical→wire once, at push time. + +## NFC → GPIO: runtime UICR write, not a core build flag + +The nRF52840 ships with the NFC pads dedicated to NFCT; converting them to +GPIO means programming `UICR->NFCPINS` bit 0 to 0, which latches only at +reset. **This is one-way in practice** (undoing it needs a full chip erase = +bootloader reflash) — accepted for this project; NFC is never used. + +Two ways to do it, and we deliberately chose the second: + +1. `-DCONFIG_NFCT_PINS_AS_GPIOS`: consumed by the *core's* + `system_nrf52840.c` — a `.c` file, which the repo's established + `compiler.cpp.extra_flags` mechanism does not reach. It would need a + second `compiler.c.extra_flags` build property in every workflow AND + local IDE configuration nobody will remember. +2. **Runtime write in `NEOPIXEL_SETUP()`** (what we do): if + `NRF_UICR->NFCPINS` bit 0 is still set, unlock NVMC, write + `0xFFFFFFFE` (a 1→0-only word write — no erase needed), relock, and + `NVIC_SystemReset()`. One reset per device lifetime; every later boot + skips the branch entirely. Works identically for CI, IDE, and OTA + builds. + +Constraints that dictate the placement of `NEOPIXEL_SETUP()` in `setup()`: + +- **Before the SoftDevice comes up.** Direct NVMC access is illegal once the + SoftDevice is enabled; on the beta channel `SENSOREGG_SETUP()` calls + `bleCoreEnsureInit()` at boot, so the neopixel setup must run earlier. + It sits right after the settings block, before `CAMERA_SETUP()`. +- **Before `wdtSetup()`** (armed last in `setup()`), so the one-time reset + can't race the watchdog. +- After `SETTINGS_SETUP()`, so `led_brightness` is readable (brightness 0 = + LEDs disabled entirely — the boost is never even enabled). + +The reset lands in `captureBootWakeCause()` as a plain soft reset — a normal +boot, no special handling. + +**The flag-off build must never touch UICR or drive pins 30/31.** The entire +module body is inside `#if BIRDSEYE_ENABLE_NEOPIXEL`; the `#else` stubs do +nothing (sensoregg.ino pattern). + +## The global brightness cap — the core requirement + +One rule: **once set, no LED may ever exceed the cap.** Enforced in one +place — `led_frame::applyCap()`, called exactly once per frame in the push +path. Every mode and animation authors colors in full 0–255 and never +worries about brightness; the cap is a post-condition tested by sweeping +caps × saturated frames in the host tests. We deliberately do NOT use +`Adafruit_NeoPixel::setBrightness()` (it rewrites the pixel buffer lossily +and would spread the invariant across call sites). + +Default `led_brightness` = 64 (~25 %). 11 px full-white at 255 is ~660 mA +on the 5 V rail; at 64 it's ~165 mA — inside the boost's budget and sane on +battery, still clearly visible in daylight peripheral vision. + +## Architecture + +Pure logic (host-tested, Arduino-free) does everything except talk to pins: + +| Unit | Owns | +|---|---| +| `led_frame.{h,cpp}` | Pixel layout constants, `Rgb`/`Frame` PODs, integer color scaling, **`applyCap()`** | +| `led_modes.{h,cpp}` | Pace pip math, generic `ScaleSpec` fill (RPM now, temps later), the `StatusAction` table + `evalStatus()` hysteresis/flash | +| `led_animations.{h,cpp}` | Boot + purple-sector animations as pure functions of `(tMs, seed)` | +| `sector_purple.{h,cpp}` | Session-best ("purple") sector detection, poll-and-diff | + +The glue (`neopixel.{h,ino}`) snapshots inputs each frame, composes by +priority, applies the cap, and pushes to the strip: + +``` +NEOPIXEL_LOOP() (30 Hz self-throttle) + ├─ inputs: tachLastReported, activeTimerPaceDifference() + validity, + │ sensoreggEgtC() (isNanF-guarded), raceActive, parked flags + ├─ sector_purple::update() → nonzero: restart purple animation + ├─ compose (first match wins): + │ 1. boot animation running + │ 2. purple animation running + │ 3. parked (BLE/USB) ‖ !raceActive ‖ brightness==0 → all off + │ 4. strip: pace valid ? pace pip : RPM scale + │ status px0/px10: evalStatus(action table) + ├─ led_frame::applyCap(frame, settingLedBrightness) + └─ strip.setPixelColor()×11, strip.show() +``` + +### Strip mode policy + +Off outside a race session — the strip is a driving aid, menu glow burns +battery. In race: **RPM scale** until pace is valid, then the **pace pip**. +Pace validity mirrors the OLED pace page: +`activeTimerRaceStarted() && activeTimerLaps() >= 1` and not +sprint-between-runs. Sprint between runs falls back to the RPM scale. + +### Pace pip + +`activeTimerPaceDifference()` is **ms per meter**, positive = slower than +best. Full deflection at ±1.0 ms/m (`kPaceFullScaleMsPerM`) — on a ~1.2 km +kart lap that's ~1.2 s/lap, a pin-the-needle delta; 0.25 ms/m per pixel +step. Deadband ±0.125 (half a step) shows the dim-white center pixel = +"on pace". Slower = pip LEFT of center in red, faster = RIGHT in green. + +### Scale mode (RPM, later temps) + +Generic `ScaleSpec {min, max, redFrac, lowColor, highColor}` — fill from +the left, lit pixels at/past `redFrac` of the fill render the high color. +RPM uses `{0, rev_limit, 0.5, green, red}` — red past halfway, per spec. +The same renderer will serve EGT/temp scales when phase 2 makes the mode +selectable. + +### Status LED actions — the phase-2 assignability hook + +Each status LED is driven by a `StatusAction` POD: +`{source, threshold, clearBelow, color, flashHalfPeriodMs}` evaluated +against per-frame values with a hysteresis latch. Hardcoded defaults now: + +- **px 0**: rev-limit flasher — red, fires ≥ `rev_limit`, clears < 97 %, + 100 ms half-period. +- **px 10**: EGT flasher — orange, fires ≥ 650 °C (`kEgtAlertC`), clears + < 630 °C, 250 ms half-period. NaN/stale EGT (isNanF!) → LED off AND + latch released — never latch stale data (house rule). + +Phase 2 ("user controls these settings") parses settings into the same +POD table — the evaluation code doesn't change, only where the table comes +from. `Source` is an enum (`kNone/kRpm/kEgtC`) that grows entries as new +sources appear. + +### Animations — deterministic by construction + +No `rand()`, no `millis()` inside the units: a frame is a pure function of +`(tMs since start, seed)`. Sparkle positions come from an xorshift-style +hash of `(seed, timeSlot, pixel)`, so host tests golden-lock exact frames. +The glue supplies `millis()` deltas and a `micros()`-derived seed (house +entropy rule — never `analogRead`). + +- **Boot** (2600 ms): hue comet circling all 11 px as a ring (2 + revolutions with trailing fade, 0–1500 ms), global fade-out + (1500–2200 ms), white sparkle glints overlapping (900–2600 ms). + Non-blocking — plays over the GPS status page. +- **Purple sector** (1600 ms): purple wave expanding from the center px + outward across strip + status (0–400 ms), solid purple with sparkles + (400–1200 ms), fade out (1200–1600 ms). Overrides strip AND status; + normal mode resumes automatically; a new purple mid-animation restarts + it. + +### Purple detection — and the sector-3 / lap-line race + +The library (DovesLapTimer / SprintTimer) exposes `getCurrentSector()`, +`getCurrentLapSector{1,2,3}Time()`, `getBestSector{1,2,3}Time()` — but +`updateBestSectors()` runs **at the start/finish crossing**, not at each +sector line. So at the exact moment sector 3 closes, the library may have +already folded this lap into the "best" values and rolled the current-lap +times to the new lap. `sector_purple` defeats this by never comparing +against live state at a close edge: + +- **Best times are snapshotted when each sector OPENS** (`bestAtOpen[3]`). +- S1/S2 close on `getCurrentSector()` transitions (1→2, 2→3): compare the + just-closed `getCurrentLapSectorNTime()` against `bestAtOpen[n]`, then + snapshot the next sector's best. +- **S3 closes on the lap edge** (`laps` increment), and its time is + *derived*: `s3 = lastLapTime − s1 − s2` (guarded on both being present + and the difference positive) — both inputs immune to the library's + lap-line side effects. Compare vs `bestAtOpen[2]`, re-arm for the new + lap. +- Purple fires only when the beaten best was **nonzero** — no purple + anywhere on lap 1 (flashing every first-lap sector is noise). +- `sectorsConfigured == false` (Lap Anything / WaypointLapTimer has no + sectors) or race not started → full reset, never fires. + +Three new sprint-first wrappers join the `activeTimer*()` family: +`activeTimerCurrentSector()`, `activeTimerLapSectorTime(n)`, +`activeTimerBestSectorTime(n)` (WLT/null → 0). + +## Sleep / wake + +GPIO state is **retained in System OFF** (the "blue LED stays on after +sleep" field report). `NEOPIXEL_SLEEP()` — slotted in `enterShutdown()` +with the IMU rail-off, before the charging branch so the strip is dark +while charging too — therefore unconditionally: + +1. blanks the strip and `show()`s while 5 V is still up, +2. drives the data pin LOW (never leave data high into an unpowered strip + — it back-powers the pixels through the data diode), +3. drives boost EN LOW — the 5 V rail is truly off, retained through + System OFF. + +`NEOPIXEL_WAKE()` (charging-loop soft resume, beside `SENSOREGG_WAKE()`): +EN HIGH, ~5 ms boost settle, blank frame; composition resumes next loop. + +## Parked loop branches + +The BLE-transfer and USB-MSC branches early-return before the main frame. +`NEOPIXEL_LOOP()` is called inside both so composition sees "parked" and +blanks the strip — a frozen mid-pattern strip would look crashed. + +## Settings + +| Key | Default | Meaning | +|---|---|---| +| `led_brightness` | `"64"` | Global cap 0–255; 0 = LEDs disabled (boost never enabled) | +| `rev_limit` | `"15000"` | True RPM for the scale ceiling + rev flasher; clamp 1000–20000 | + +Both rows added unconditionally to `ensureDefaultSettings()` (uniform +SETTINGS.json across channels — the SensorEgg DOVEX-column precedent). +Phase 2 adds mode/action assignment keys. + +## Feature flag / channels + +`BIRDSEYE_ENABLE_NEOPIXEL` (`project.h`, `#ifndef` default 0, tested with +`#if`): **on** in beta (`beta.yml` + `compile-sketch.yml` for +BETA-targeted PRs), **off** in master/release. The `Adafruit NeoPixel` +library is installed in all three workflows either way (harmless when +unused; keeps the lists uniform). + +## Radio / timing safety + +Adafruit_NeoPixel's nRF52 backend claims a *free* PWM instance +(`ENABLE == 0`) per `show()`, drives it via EasyDMA and busy-waits SEQEND +with **interrupts on** (~0.4 ms for 11 px ≈ 1 % CPU at 30 Hz). This sketch +uses no `tone()`/`analogWrite()`/HwPWM, so PWM0–2 are always free; TIMER3's +GPS drain and the tach ISR are unaffected. Two things future maintainers +must not "fix": + +- If ever all PWMs are occupied, the library falls back to a + cycle-counted bit-bang **with interrupts disabled** for ~0.4 ms. Still + inside the 256 B serial ring's ~44 ms slack, but don't create that path. +- `show()` mallocs/frees a ~560 B PWM pattern buffer every call. Same-size + alloc/free is fragmentation-benign on this allocator — it is not a leak. + +## Sim + +`neopixel.ino` is excluded from the sim TU (BLE-module precedent) and its +surface no-op'd in `module_stubs.cpp`. The four pure units join +`SIM_CORE_SOURCES`. The three sector wrappers get sim_prototypes entries. + +## Hardware-only verification (open items) + +- One-time UICR write + single self-reset on first flag-on boot. +- Boost EN polarity (assumed HIGH = on, Adafruit precedent) + settle time. +- GRB color order and strip orientation (`kChainReversed` if mounted + data-end-right). +- Real current draw at cap 64; sleep current with EN low. +- 3.3 V data into 5 V pixels — user bench-proven on another nRF board. +- Cold power-on EN float: before `setup()` the EN pin floats; if the boost + board pulls EN up, the strip powers briefly with idle data — harmless + (pixels stay dark), noted for completeness. + +## Phase 2 (deliberately out of scope here) + +Settings-driven assignment: strip mode selection, per-status-LED action +source/threshold/color, temp scales via `ScaleSpec`, C/F preference, +brightness UI. The POD tables above are the interface that phase fills in. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1565175..4551f2f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,6 +34,10 @@ add_executable(birdseye_tests course_creator_test.cpp track_json_test.cpp course_prune_test.cpp + led_frame_test.cpp + led_modes_test.cpp + led_animations_test.cpp + sector_purple_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp @@ -57,6 +61,10 @@ add_executable(birdseye_tests ${BIRDSEYE_DIR}/crossing_pattern.cpp ${BIRDSEYE_DIR}/course_creator.cpp ${BIRDSEYE_DIR}/track_json.cpp + ${BIRDSEYE_DIR}/led_frame.cpp + ${BIRDSEYE_DIR}/led_modes.cpp + ${BIRDSEYE_DIR}/led_animations.cpp + ${BIRDSEYE_DIR}/sector_purple.cpp ) target_include_directories(birdseye_tests PRIVATE diff --git a/tests/led_animations_test.cpp b/tests/led_animations_test.cpp new file mode 100644 index 0000000..56425c4 --- /dev/null +++ b/tests/led_animations_test.cpp @@ -0,0 +1,159 @@ +#include + +#include "doctest.h" +#include "led_animations.h" + +using led_frame::Frame; +using led_frame::kPixelCount; +using led_frame::Rgb; + +static bool framesEqual(const Frame& a, const Frame& b) { + for (int i = 0; i < kPixelCount; i++) { + if (a.px[i].r != b.px[i].r || a.px[i].g != b.px[i].g || + a.px[i].b != b.px[i].b) { + return false; + } + } + return true; +} + +static bool frameLit(const Frame& f) { + for (int i = 0; i < kPixelCount; i++) { + if (f.px[i].r || f.px[i].g || f.px[i].b) { + return true; + } + } + return false; +} + +TEST_CASE("mix avalanches consecutive inputs apart") { + CHECK(led_animations::mix(1) != led_animations::mix(2)); + CHECK(led_animations::mix(0) != 0); + // Stateless: same input, same output. + CHECK(led_animations::mix(12345) == led_animations::mix(12345)); +} + +TEST_CASE("hueToRgb: wheel anchors") { + Rgb r = led_animations::hueToRgb(0); + CHECK(r.r == 255); + CHECK(r.g == 0); + CHECK(r.b == 0); + Rgb g = led_animations::hueToRgb(85); + CHECK(g.r == 0); + CHECK(g.g == 255); + CHECK(g.b == 0); + Rgb b = led_animations::hueToRgb(170); + CHECK(b.r == 0); + CHECK(b.g == 0); + CHECK(b.b == 255); +} + +TEST_CASE("renderBoot: deterministic in (t, seed)") { + const uint32_t seed = 0xC0FFEE; + for (uint32_t t : {0U, 137U, 800U, 1000U, 1499U, 1800U, 2300U, 2599U}) { + CAPTURE(t); + Frame a{}; + Frame b{}; + led_animations::renderBoot(t, seed, a); + led_animations::renderBoot(t, seed, b); + CHECK(framesEqual(a, b)); + } +} + +TEST_CASE("renderBoot: running while t < duration, done at duration") { + Frame f{}; + CHECK(led_animations::renderBoot(0, 1, f)); + CHECK(frameLit(f)); // comet visible immediately + CHECK(led_animations::renderBoot(led_animations::kBootDurationMs - 1, 1, f)); + CHECK(!led_animations::renderBoot(led_animations::kBootDurationMs, 1, f)); + CHECK(!frameLit(f)); // done clears the frame + CHECK(!led_animations::renderBoot(999999, 1, f)); +} + +TEST_CASE("renderBoot: comet moves over time") { + const uint32_t seed = 7; + Frame a{}; + Frame b{}; + led_animations::renderBoot(100, seed, a); + led_animations::renderBoot(400, seed, b); + CHECK(!framesEqual(a, b)); +} + +TEST_CASE("renderBoot: different seeds differ in the sparkle phase") { + // Pick a time in the sparkle window past the comet fade so only + // sparkles are on — seed is the only variable. + const uint32_t t = led_animations::kBootFadeEndMs + 100; + bool differed = false; + for (uint32_t s = 0; s < 8 && !differed; s++) { + Frame a{}; + Frame b{}; + led_animations::renderBoot(t + (uint32_t)(s * led_animations::kSparkleSlotMs), + 0x1111, a); + led_animations::renderBoot(t + (uint32_t)(s * led_animations::kSparkleSlotMs), + 0x2222, b); + if (!framesEqual(a, b)) { + differed = true; + } + } + CHECK(differed); +} + +TEST_CASE("renderPurple: deterministic, lifecycle, wave from center") { + const uint32_t seed = 42; + Frame a{}; + Frame b{}; + led_animations::renderPurple(200, seed, a); + led_animations::renderPurple(200, seed, b); + CHECK(framesEqual(a, b)); + + // Early wave: center chain pixel lit, chain ends not yet reached. + Frame w{}; + CHECK(led_animations::renderPurple(60, seed, w)); + const int center = led_frame::kStripFirst + led_frame::kStripCenter; + CHECK((w.px[center].r || w.px[center].b)); + CHECK(!(w.px[0].r || w.px[0].g || w.px[0].b)); + CHECK(!(w.px[kPixelCount - 1].r || w.px[kPixelCount - 1].g || + w.px[kPixelCount - 1].b)); + + // Hold phase: everything lit (status pixels included — celebration). + Frame h{}; + CHECK(led_animations::renderPurple(800, seed, h)); + for (int i = 0; i < kPixelCount; i++) { + CAPTURE(i); + CHECK((h.px[i].r || h.px[i].g || h.px[i].b)); + } + + // Fade phase dimmer than hold. + Frame fade{}; + CHECK(led_animations::renderPurple(1550, seed, fade)); + CHECK(fade.px[center].b < h.px[center].b); + + // Done at duration. + Frame d{}; + CHECK(!led_animations::renderPurple(led_animations::kPurpleDurationMs, seed, d)); + CHECK(!frameLit(d)); +} + +TEST_CASE("golden frames: fixed (t, seed) triplets stay stable") { + // Locks the deterministic contract — if an intentional visual tweak + // changes these, re-record the numbers in the same commit. + Frame f{}; + led_animations::renderBoot(0, 0xBEEF, f); + // t=0: comet head at px 0, full red (hue 0), tail wrapping behind. + CHECK(f.px[0].r == 255); + CHECK(f.px[0].g == 0); + CHECK(f.px[0].b == 0); + + Frame p{}; + led_animations::renderPurple(700, 0xBEEF, p); + // Hold phase base color is kPurple where no sparkle landed. At least + // the majority of pixels must be exactly base purple. + int base = 0; + for (int i = 0; i < kPixelCount; i++) { + if (p.px[i].r == led_frame::kPurple.r && p.px[i].g == led_frame::kPurple.g && + p.px[i].b == led_frame::kPurple.b) { + base++; + } + } + CHECK(base >= kPixelCount / 2); +} diff --git a/tests/led_frame_test.cpp b/tests/led_frame_test.cpp new file mode 100644 index 0000000..7a04fc6 --- /dev/null +++ b/tests/led_frame_test.cpp @@ -0,0 +1,125 @@ +#include + +#include "doctest.h" +#include "led_frame.h" + +using led_frame::Frame; +using led_frame::Rgb; + +TEST_CASE("layout constants describe the 11-px chain") { + CHECK(led_frame::kPixelCount == 11); + CHECK(led_frame::kStatusLeft == 0); + CHECK(led_frame::kStatusRight == 10); + CHECK(led_frame::kStripFirst == 1); + CHECK(led_frame::kStripCount == 9); + // Strip-relative center maps to chain pixel 5, the middle of 1..9. + CHECK(led_frame::kStripFirst + led_frame::kStripCenter == 5); +} + +TEST_CASE("clear turns every pixel off") { + Frame f{}; + for (int i = 0; i < led_frame::kPixelCount; i++) { + f.px[i] = Rgb{255, 128, 7}; + } + led_frame::clear(f); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(f.px[i].r == 0); + CHECK(f.px[i].g == 0); + CHECK(f.px[i].b == 0); + } +} + +TEST_CASE("scale: identity at 255, off at 0, rounds to nearest") { + Rgb c{255, 100, 1}; + Rgb id = led_frame::scale(c, 255); + CHECK(id.r == 255); + CHECK(id.g == 100); + CHECK(id.b == 1); + + Rgb off = led_frame::scale(c, 0); + CHECK(off.r == 0); + CHECK(off.g == 0); + CHECK(off.b == 0); + + // 255 * 128 / 255 = 128 exactly; 100 * 128 / 255 = 50.19 -> 50. + Rgb half = led_frame::scale(c, 128); + CHECK(half.r == 128); + CHECK(half.g == 50); + // 1 * 128 / 255 = 0.50 -> rounds to 1, not truncated to 0. + CHECK(half.b == 1); +} + +TEST_CASE("applyCap: no channel of any pixel ever exceeds the cap") { + const uint8_t caps[] = {0, 1, 64, 128, 254, 255}; + for (uint8_t cap : caps) { + CAPTURE((int)cap); + Frame f{}; + // Worst-case saturated frame plus a mixed gradient. + for (int i = 0; i < led_frame::kPixelCount; i++) { + f.px[i] = (i % 2 == 0) ? Rgb{255, 255, 255} + : Rgb{(uint8_t)(i * 23), 255, (uint8_t)(255 - i)}; + } + led_frame::applyCap(f, cap); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(f.px[i].r <= cap); + CHECK(f.px[i].g <= cap); + CHECK(f.px[i].b <= cap); + } + } +} + +TEST_CASE("applyCap: 255 is identity, 0 is all-off") { + Frame f{}; + for (int i = 0; i < led_frame::kPixelCount; i++) { + f.px[i] = Rgb{(uint8_t)(i * 20), (uint8_t)(255 - i * 10), 33}; + } + Frame copy = f; + led_frame::applyCap(f, 255); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(f.px[i].r == copy.px[i].r); + CHECK(f.px[i].g == copy.px[i].g); + CHECK(f.px[i].b == copy.px[i].b); + } + led_frame::applyCap(f, 0); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(f.px[i].r == 0); + CHECK(f.px[i].g == 0); + CHECK(f.px[i].b == 0); + } +} + +TEST_CASE("applyCap: full-white maps to exactly the cap") { + Frame f{}; + for (int i = 0; i < led_frame::kPixelCount; i++) { + f.px[i] = led_frame::kWhite; + } + led_frame::applyCap(f, 64); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(f.px[i].r == 64); + CHECK(f.px[i].g == 64); + CHECK(f.px[i].b == 64); + } +} + +TEST_CASE("physicalIndex mirrors the whole chain (data-in wired right)") { + // kChainReversed is true for the current hardware: logical left + // status (0) sits at the far end of the wire (10), the centerline + // stays centered, and the map is an involution (its own inverse). + static_assert(led_frame::kChainReversed, "test written for reversed wiring"); + CHECK(led_frame::physicalIndex(led_frame::kStatusLeft) == 10); + CHECK(led_frame::physicalIndex(led_frame::kStatusRight) == 0); + CHECK(led_frame::physicalIndex(led_frame::kStripFirst + led_frame::kStripCenter) == + led_frame::kStripFirst + led_frame::kStripCenter); + for (int i = 0; i < led_frame::kPixelCount; i++) { + CHECK(led_frame::physicalIndex(led_frame::physicalIndex(i)) == i); + } + // Logical leftmost strip pixel lands one in from the wire's far end. + CHECK(led_frame::physicalIndex(led_frame::kStripFirst) == 9); +} + +TEST_CASE("add saturates at 255") { + Rgb s = led_frame::add(Rgb{200, 100, 0}, Rgb{100, 100, 5}); + CHECK(s.r == 255); + CHECK(s.g == 200); + CHECK(s.b == 5); +} diff --git a/tests/led_modes_test.cpp b/tests/led_modes_test.cpp new file mode 100644 index 0000000..063dd87 --- /dev/null +++ b/tests/led_modes_test.cpp @@ -0,0 +1,179 @@ +#include + +#include "doctest.h" +#include "led_modes.h" + +using led_frame::kStripCenter; +using led_frame::kStripCount; +using led_frame::Rgb; +using led_modes::pacePip; +using led_modes::PacePip; +using led_modes::ScaleSpec; +using led_modes::Source; +using led_modes::StatusAction; +using led_modes::StatusState; + +static bool isRed(Rgb c) { return c.r > 0 && c.g == 0 && c.b == 0; } +static bool isGreen(Rgb c) { return c.g > 0 && c.r == 0 && c.b == 0; } +static bool isOff(Rgb c) { return c.r == 0 && c.g == 0 && c.b == 0; } + +TEST_CASE("pace pip: deadband parks on the white centerline") { + for (float pace : {0.0f, 0.125f, -0.125f, 0.05f}) { + CAPTURE(pace); + PacePip p = pacePip(pace); + CHECK(p.stripIndex == kStripCenter); + CHECK(p.color.r == 255); + CHECK(p.color.g == 255); + CHECK(p.color.b == 255); + } +} + +TEST_CASE("pace pip: slower goes LEFT in red, faster RIGHT in green") { + PacePip slow = pacePip(0.3f); + CHECK(slow.stripIndex < kStripCenter); + CHECK(isRed(slow.color)); + + PacePip fast = pacePip(-0.3f); + CHECK(fast.stripIndex > kStripCenter); + CHECK(isGreen(fast.color)); +} + +TEST_CASE("pace pip: full scale clamps to the end pixels") { + CHECK(pacePip(1.0f).stripIndex == 0); + CHECK(pacePip(5.0f).stripIndex == 0); + CHECK(pacePip(-1.0f).stripIndex == kStripCount - 1); + CHECK(pacePip(-99.0f).stripIndex == kStripCount - 1); +} + +TEST_CASE("pace pip: monotonic steps, one pixel per 0.25 ms/m") { + // Just past the deadband: first pixel out. + CHECK(pacePip(0.13f).stripIndex == kStripCenter - 1); + CHECK(pacePip(0.25f).stripIndex == kStripCenter - 1); + CHECK(pacePip(0.26f).stripIndex == kStripCenter - 2); + CHECK(pacePip(0.50f).stripIndex == kStripCenter - 2); + CHECK(pacePip(0.75f).stripIndex == kStripCenter - 3); + CHECK(pacePip(0.99f).stripIndex == kStripCenter - 4); + // Mirrored on the fast side. + CHECK(pacePip(-0.26f).stripIndex == kStripCenter + 2); + int prev = pacePip(0.0f).stripIndex; + for (float pace = 0.05f; pace < 1.3f; pace += 0.05f) { + int idx = pacePip(pace).stripIndex; + CHECK(idx <= prev); // more positive pace never moves right + prev = idx; + } +} + +TEST_CASE("renderPace: centerline dim white under an off-center pip") { + Rgb out[kStripCount]; + led_modes::renderPace(0.6f, out); + // Centerline present, dimmer than the pip. + CHECK(out[kStripCenter].r == out[kStripCenter].g); + CHECK(out[kStripCenter].r == out[kStripCenter].b); + CHECK(out[kStripCenter].r > 0); + CHECK(out[kStripCenter].r < 255); + int pipIdx = pacePip(0.6f).stripIndex; + CHECK(isRed(out[pipIdx])); + for (int i = 0; i < kStripCount; i++) { + if (i != pipIdx && i != kStripCenter) { + CHECK(isOff(out[i])); + } + } +} + +static const ScaleSpec kRpmSpec{0.0f, 10000.0f, led_modes::kRpmRedFrac, + led_frame::kGreen, led_frame::kRed}; + +TEST_CASE("renderScale: fill count across the range") { + Rgb out[kStripCount]; + + led_modes::renderScale(0.0f, kRpmSpec, out); + for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i])); + + led_modes::renderScale(10000.0f, kRpmSpec, out); + for (int i = 0; i < kStripCount; i++) CHECK(!isOff(out[i])); + + // Clamped outside the range. + led_modes::renderScale(-500.0f, kRpmSpec, out); + for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i])); + led_modes::renderScale(99999.0f, kRpmSpec, out); + for (int i = 0; i < kStripCount; i++) CHECK(!isOff(out[i])); + + // Half scale: round(0.5 * 9) = 5 pixels lit (fill is left-to-right, + // unlit tail stays off). + led_modes::renderScale(5000.0f, kRpmSpec, out); + for (int i = 0; i < 5; i++) CHECK(!isOff(out[i])); + for (int i = 5; i < kStripCount; i++) CHECK(isOff(out[i])); +} + +TEST_CASE("renderScale: red past the halfway mark, green below") { + Rgb out[kStripCount]; + led_modes::renderScale(10000.0f, kRpmSpec, out); + // redFrac 0.5 on 9 px: indices 0..4 green, 5..8 red. + for (int i = 0; i < 5; i++) CHECK(isGreen(out[i])); + for (int i = 5; i < kStripCount; i++) CHECK(isRed(out[i])); +} + +TEST_CASE("renderScale: degenerate span never divides by zero") { + Rgb out[kStripCount]; + ScaleSpec bad{100.0f, 100.0f, 0.5f, led_frame::kGreen, led_frame::kRed}; + led_modes::renderScale(100.0f, bad, out); + for (int i = 0; i < kStripCount; i++) CHECK(isOff(out[i])); +} + +static const StatusAction kRevAction{Source::kRpm, 10000.0f, 9700.0f, + led_frame::kRed, + led_modes::kRevFlashHalfPeriodMs}; + +TEST_CASE("evalStatus: threshold fires, hysteresis holds, clear releases") { + StatusState st; + // Below threshold: off, not latched. + CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9000.0f, true, 0))); + CHECK(!st.active); + // At threshold: latches. nowMs=0 is the ON flash phase. + CHECK(isRed(led_modes::evalStatus(kRevAction, st, 10000.0f, true, 0))); + CHECK(st.active); + // Dips into the hysteresis band: still latched (flash phase ON). + CHECK(isRed(led_modes::evalStatus(kRevAction, st, 9800.0f, true, 0))); + CHECK(st.active); + // Below clearBelow: releases. + CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9600.0f, true, 0))); + CHECK(!st.active); +} + +TEST_CASE("evalStatus: flash phase follows nowMs") { + StatusState st; + led_modes::evalStatus(kRevAction, st, 12000.0f, true, 0); + REQUIRE(st.active); + // Half-period 100 ms: 0..99 on, 100..199 off, 200..299 on... + CHECK(isRed(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 50))); + CHECK(isOff(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 150))); + CHECK(isRed(led_modes::evalStatus(kRevAction, st, 12000.0f, true, 250))); + CHECK(st.active); // off-phase is flash, not release +} + +TEST_CASE("evalStatus: invalid input forces off AND releases the latch") { + StatusState st; + led_modes::evalStatus(kRevAction, st, 12000.0f, true, 0); + REQUIRE(st.active); + CHECK(isOff(led_modes::evalStatus(kRevAction, st, 12000.0f, false, 0))); + CHECK(!st.active); + // Recovering validity below threshold stays off. + CHECK(isOff(led_modes::evalStatus(kRevAction, st, 9000.0f, true, 0))); + CHECK(!st.active); +} + +TEST_CASE("evalStatus: kNone source is always off") { + StatusState st; + StatusAction none{Source::kNone, 0.0f, 0.0f, led_frame::kRed, 100}; + CHECK(isOff(led_modes::evalStatus(none, st, 99999.0f, true, 0))); + CHECK(!st.active); +} + +TEST_CASE("evalStatus: zero half-period means solid, not divide-by-zero") { + StatusState st; + StatusAction solid{Source::kRpm, 100.0f, 90.0f, led_frame::kOrange, 0}; + Rgb c = led_modes::evalStatus(solid, st, 200.0f, true, 12345); + CHECK(c.r == led_frame::kOrange.r); + CHECK(c.g == led_frame::kOrange.g); + CHECK(c.b == led_frame::kOrange.b); +} diff --git a/tests/sector_purple_test.cpp b/tests/sector_purple_test.cpp new file mode 100644 index 0000000..1918325 --- /dev/null +++ b/tests/sector_purple_test.cpp @@ -0,0 +1,222 @@ +#include "doctest.h" +#include "sector_purple.h" + +using sector_purple::Sample; +using sector_purple::State; + +// A live circuit race sample with everything zeroed; tests mutate what +// they need. +static Sample base() { + Sample s{}; + s.sectorsConfigured = true; + s.raceStarted = true; + s.currentSector = 1; + s.laps = 0; + s.lastLapTime = 0; + for (int i = 0; i < 3; i++) { + s.lapSectorTime[i] = 0; + s.bestSectorTime[i] = 0; + } + return s; +} + +TEST_CASE("no sectors configured never fires and stays reset") { + State st; + sector_purple::reset(st); + Sample in = base(); + in.sectorsConfigured = false; + in.bestSectorTime[0] = 30000; + in.lapSectorTime[0] = 1; // absurdly fast — still must not fire + for (int i = 0; i < 5; i++) { + in.currentSector = (i % 3) + 1; + CHECK(sector_purple::update(st, in) == 0); + } +} + +TEST_CASE("lap 1: no purple anywhere (no prior bests)") { + State st; + sector_purple::reset(st); + Sample in = base(); + + CHECK(sector_purple::update(st, in) == 0); // adopt S1 open + + // S1 closes fast — but bestAtOpen was 0. + in.currentSector = 2; + in.lapSectorTime[0] = 25000; + CHECK(sector_purple::update(st, in) == 0); + + in.currentSector = 3; + in.lapSectorTime[1] = 26000; + CHECK(sector_purple::update(st, in) == 0); + + // Lap line: S3 derived, but bestAtOpen[2] was 0. + in.currentSector = 1; + in.laps = 1; + in.lastLapTime = 78000; + in.bestSectorTime[0] = 25000; + in.bestSectorTime[1] = 26000; + in.bestSectorTime[2] = 27000; + CHECK(sector_purple::update(st, in) == 0); +} + +// Drive one full lap: closes S1/S2 at the given times, then the lap +// line with the given total. Returns the OR of fired values per edge in +// a small struct for assertions. +struct LapResult { + int s1Fired, s2Fired, s3Fired; +}; +static LapResult runLap(State& st, Sample& in, uint32_t s1, uint32_t s2, + uint32_t lapTotal, int lapNum) { + LapResult r{0, 0, 0}; + in.currentSector = 2; + in.lapSectorTime[0] = s1; + r.s1Fired = sector_purple::update(st, in); + in.currentSector = 3; + in.lapSectorTime[1] = s2; + r.s2Fired = sector_purple::update(st, in); + // Lap line. The library may have already updated bests — simulate the + // worst case by folding this lap's times in BEFORE the edge is seen. + in.currentSector = 1; + in.laps = lapNum; + in.lastLapTime = lapTotal; + uint32_t s3 = lapTotal - s1 - s2; + if (in.bestSectorTime[0] == 0 || s1 < in.bestSectorTime[0]) + in.bestSectorTime[0] = s1; + if (in.bestSectorTime[1] == 0 || s2 < in.bestSectorTime[1]) + in.bestSectorTime[1] = s2; + if (in.bestSectorTime[2] == 0 || s3 < in.bestSectorTime[2]) + in.bestSectorTime[2] = s3; + // Current-lap times roll over to the new lap (0 = not yet completed). + in.lapSectorTime[0] = 0; + in.lapSectorTime[1] = 0; + in.lapSectorTime[2] = 0; + r.s3Fired = sector_purple::update(st, in); + return r; +} + +TEST_CASE("S1/S2 improvements fire once at the transition; equal does not") { + State st; + sector_purple::reset(st); + Sample in = base(); + sector_purple::update(st, in); + + // Lap 1 establishes bests: 25/26/27 (total 78). + LapResult l1 = runLap(st, in, 25000, 26000, 78000, 1); + CHECK(l1.s1Fired == 0); + CHECK(l1.s2Fired == 0); + CHECK(l1.s3Fired == 0); + + // Lap 2: faster S1, equal S2, slower S3. + LapResult l2 = runLap(st, in, 24000, 26000, 79000, 2); + CHECK(l2.s1Fired == 1); + CHECK(l2.s2Fired == 0); // equal time is not an improvement + CHECK(l2.s3Fired == 0); + + // Lap 3: S2 improvement fires as 2. + LapResult l3 = runLap(st, in, 24500, 25000, 80000, 3); + CHECK(l3.s1Fired == 0); + CHECK(l3.s2Fired == 2); + CHECK(l3.s3Fired == 0); +} + +TEST_CASE("S3 purple survives the library updating bests at the lap line") { + State st; + sector_purple::reset(st); + Sample in = base(); + sector_purple::update(st, in); + + // Lap 1: bests 25/26/27. + runLap(st, in, 25000, 26000, 78000, 1); + + // Lap 2: S3 = 80000 - 25500 - 26500 = 28000 (slower) — no fire. + LapResult l2 = runLap(st, in, 25500, 26500, 80000, 2); + CHECK(l2.s3Fired == 0); + + // Lap 3: S3 = 76000 - 25000 - 26000 = 25000, beats the 27000 best. + // runLap folds the new best into bestSectorTime[2] BEFORE the edge — + // the open-snapshot must still see the old 27000 and fire. + LapResult l3 = runLap(st, in, 25000, 26000, 76000, 3); + CHECK(l3.s3Fired == 3); + + // Lap 4: same S3 again (25000) — best is now 25000, no fire. + LapResult l4 = runLap(st, in, 25000, 26000, 76000, 4); + CHECK(l4.s3Fired == 0); +} + +TEST_CASE("degenerate lap (missing S1/S2 closes) fires nothing at the line") { + State st; + sector_purple::reset(st); + Sample in = base(); + sector_purple::update(st, in); + runLap(st, in, 25000, 26000, 78000, 1); + + // Lap 2 jumps straight from S1 to the lap line (missed transitions — + // e.g. GPS dropout): no s2 recorded, so S3 cannot be derived. + in.currentSector = 2; + in.lapSectorTime[0] = 24000; + CHECK(sector_purple::update(st, in) == 1); // S1 close still fires + in.currentSector = 1; + in.laps = 2; + in.lastLapTime = 70000; // would be a monster S3 if mis-derived + CHECK(sector_purple::update(st, in) == 0); +} + +TEST_CASE("lastLapTime smaller than s1+s2 cannot underflow") { + State st; + sector_purple::reset(st); + Sample in = base(); + sector_purple::update(st, in); + runLap(st, in, 25000, 26000, 78000, 1); + + in.currentSector = 2; + in.lapSectorTime[0] = 26000; + sector_purple::update(st, in); + in.currentSector = 3; + in.lapSectorTime[1] = 27000; + sector_purple::update(st, in); + in.currentSector = 1; + in.laps = 2; + in.lastLapTime = 40000; // < s1+s2 (inconsistent library state) + CHECK(sector_purple::update(st, in) == 0); +} + +TEST_CASE("sprint between-runs (sector 0) resets cleanly") { + State st; + sector_purple::reset(st); + Sample in = base(); + sector_purple::update(st, in); + runLap(st, in, 25000, 26000, 78000, 1); + + // Run ends: currentSector drops to 0 between sprint runs. + in.currentSector = 0; + CHECK(sector_purple::update(st, in) == 0); + + // New run opens S1; a fast S1 against the lap-1 best still fires. + in.currentSector = 1; + CHECK(sector_purple::update(st, in) == 0); + in.currentSector = 2; + in.lapSectorTime[0] = 24000; + CHECK(sector_purple::update(st, in) == 1); +} + +TEST_CASE("race not started resets; mid-race attach adopts without firing") { + State st; + sector_purple::reset(st); + Sample in = base(); + in.raceStarted = false; + CHECK(sector_purple::update(st, in) == 0); + + // Attach mid-lap in sector 2 with bests already on the board. + in.raceStarted = true; + in.currentSector = 2; + in.laps = 3; + in.bestSectorTime[0] = 25000; + in.bestSectorTime[1] = 26000; + in.bestSectorTime[2] = 27000; + CHECK(sector_purple::update(st, in) == 0); // adopt, no fire + + // S2 closes faster than best: fires (snapshot taken at attach). + in.currentSector = 3; + in.lapSectorTime[1] = 25500; + CHECK(sector_purple::update(st, in) == 2); +}