Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/clang-tidy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 6 additions & 4 deletions .github/workflows/compile-sketch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions BirdsEye/BirdsEye.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand Down
144 changes: 144 additions & 0 deletions BirdsEye/led_animations.cpp
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading