diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 81ae35d..a5eb855 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -817,6 +817,12 @@ const int PAGE_DRAG_DISTANCE = -17; // construction, which is exactly what a pinned page needs. const int PAGE_DRAG_MODE = -18; // Automatic / Manual / Back const int PAGE_DRAG_STAGING = -19; // tree countdown / run / results +// SensorEgg pairing UI (plan 0017). Ids defined unconditionally like +// PAGE_CAMERA_TEST; the pages are only reachable on a +// BIRDSEYE_ENABLE_SENSOREGG build (menu row, renderers and wiring are +// all gated). +const int PAGE_PAIR_EGG = -20; // egg pairing / paired-status screen +const int PAGE_EGG_TEST = -21; // egg bench/live-data page (never idle-sleeps) // running menu (these must be in order) #if BIRDSEYE_ENABLE_PROFILING diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 8657853..e35e3a8 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -22,6 +22,10 @@ void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); void displayPage_camera_serial_entry(); +#if BIRDSEYE_ENABLE_SENSOREGG +void displayPage_pair_egg(); // egg pairing / paired-status (plan 0017) +void displayPage_egg_test(); // egg bench/live-data page (plan 0017) +#endif void displayPage_internal_fault(); void displayPage_internal_warning(); void displayPage_sd_format(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 96e8e0d..1f2d240 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -136,7 +136,11 @@ void displayPage_main_menu() { // a size-1 scroll-hint line. Four full size-2 rows fill the panel's // nominal 64 px exactly, but the last row is cut off on real hardware // — so the window follows the selection instead. - static const char* const kMenuItems[] = {"Race", "Drag", "Review", "Transfer", "Create", "Camera"}; + static const char* const kMenuItems[] = {"Race", "Drag", "Review", "Transfer", "Create", "Camera" +#if BIRDSEYE_ENABLE_SENSOREGG + , "Egg" // pairing + bench test for the wireless EGT pod (plan 0017) +#endif + }; const int itemCount = (int)(sizeof(kMenuItems) / sizeof(kMenuItems[0])); const int visibleRows = 3; @@ -610,10 +614,15 @@ void displayPage_camera_test() { #if BIRDSEYE_ENABLE_SENSOREGG // SensorEgg readout (bottom line): live Temp1 or NA when the egg is - // silent (>1 s) / faulted. Makes this page the coexistence soak-test - // harness: camera linked above + egg streaming here, and the page never - // idle-sleeps (the idle-shutdown and USB-charging entries are - // main-menu-only), so it can sit on a desk indefinitely. + // silent (>1 s) / faulted. Entering this page latches the egg bench + // mode (sensoreggTestEnterMode, plan 0017) so the race-gated scanner + // actually runs here — the plan-0012 gate had silently broken the + // desk-soak behavior this line was built for (it read NA off-track). + // With the latch, this page is again the camera+egg coexistence soak + // harness: camera linked above + egg streaming here, and the page + // never idle-sleeps (idle-shutdown and USB-charging entries are + // main-menu-only), so it can sit on a desk indefinitely. The EGG TEST + // page (plan 0017) shows the full egg picture. display.print(F("egg: ")); const float soakEgtF = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); if (isNanF(soakEgtF)) { // isNanF: plain isnan() folds to false under -Ofast @@ -627,6 +636,167 @@ void displayPage_camera_test() { safeDisplayUpdate(); } +#if BIRDSEYE_ENABLE_SENSOREGG + +void displayPage_pair_egg() { + resetDisplay(); + + if (sensoreggIsPaired()) { + // Paired: stored MAC + Back/Test/Unpair. This branch also takes over + // the frame after a capture (insideMenu is derived per frame). Back + // first (index 0): a "Cancel" press landing one frame late must not + // hit Unpair and erase the just-captured MAC (camera precedent). + display.setTextSize(1); + display.println(F(" EGG")); + + char mac[sensoregg_protocol::kMacStrLen]; + sensoreggPairedMac(mac, sizeof(mac)); + display.println(mac); // 17 chars — a "Paired: " prefix would not fit + + display.setTextSize(2); + display.print(menuSelectionIndex == 0 ? "->" : " "); + display.println(F("Back")); + display.print(menuSelectionIndex == 1 ? "->" : " "); + display.println(F("Test")); + display.print(menuSelectionIndex == 2 ? "->" : " "); + display.println(F("Unpair")); + } else { + // Unpaired: window-gated capture status. The scanner is forced on + // while the window is open and observes EVERY egg in range; capture + // waits for one advertising ITS OWN pairing window (the egg-side + // long-press) — physical possession is the authorization. + display.setTextSize(1); + display.println(F(" PAIR EGG")); + display.println(); + + if (sensoreggPairingInProgress()) { + display.println(F("Hold button on egg")); + display.println(F("to start pairing...")); + display.print(F("Egg: ")); + if (sensoreggLinkUp()) { + display.print(F("heard v")); + display.println(sensoreggProtoVersion()); + } else { + display.println(F("---")); + } + display.print(F("Window: ")); + display.println(sensoreggPairingFlag() ? F("OPEN") : F("--")); + } else { + // Window closed without a capture (2-min timeout or a cancel that + // landed while the page was still up). + display.println(F("Pairing stopped")); + display.println(); + display.println(); + display.println(); + } + + display.println(); + display.println(F("B2:Cancel")); + } + + safeDisplayUpdate(); +} + +void displayPage_egg_test() { + resetDisplay(); + + // Eight size-1 rows, 21 chars each. Entering this page latched the + // bench scan (sensoreggTestEnterMode), so everything below is live on + // a desk — this is the egg's soak/diagnostic harness, and like the + // camera test page it never idle-sleeps. + display.setTextSize(1); + + // Row 0: title, link tri-state (HUNG outranks OK — packets arriving + // but the sequence frozen means the egg needs a power cycle), and the + // protocol version of the latest frame. + display.print(F("EGG TEST rf:")); + if (sensoreggAppHung()) { + display.print(F("HUNG")); + } else if (sensoreggLinkUp()) { + display.print(F("OK")); + } else { + display.print(F("--")); + } + display.print(F(" v")); + const uint8_t eggVer = sensoreggProtoVersion(); + if (eggVer == 0) { + display.println(F("-")); + } else { + display.println(eggVer); + } + + // Rows 1-2: temperatures (Fahrenheit at render — house rule; logging + // stays Celsius) and battery. Every NaN check is isNanF: plain + // isnan() folds to false under -Ofast. + const float egtF = sensoregg_protocol::celsiusToFahrenheit(sensoreggEgtC()); + const float cjF = + sensoregg_protocol::celsiusToFahrenheit(sensoreggJunctionC()); + const float auxF = sensoregg_protocol::celsiusToFahrenheit(sensoreggAuxC()); + display.print(F("EGT ")); + if (isNanF(egtF)) { + display.print(F("---")); + } else { + display.print(egtF, 1); + display.print(F("F")); + } + display.print(F(" CJ ")); + if (isNanF(cjF)) { + display.println(F("---")); + } else { + display.print((int)lroundf(cjF)); // rounded: keeps the row <= 21 chars + display.println(F("F")); + } + + display.print(F("AUX ")); + if (isNanF(auxF)) { + display.print(F("---")); // v1 egg, stale link, or divider sentinel + } else { + display.print(auxF, 1); + display.print(F("F")); + } + display.print(F(" BAT ")); + const uint8_t eggBatt = sensoreggBatteryPct(); + if (eggBatt == 0xFF) { + display.println(F("--%")); + } else { + display.print(eggBatt); + display.println(F("%")); + } + + // Row 3: raw sequence counter (first real consumer of + // sensoreggSequence()) + measured packet rate (~9-10 Hz healthy). + display.print(F("SEQ ")); + display.print(sensoreggSequence()); + display.print(F(" ")); + display.print(sensoreggPacketHz(), 1); + display.println(F("Hz")); + + // Row 4: live flags from the latest frame. + display.print(F("FLG")); + if (sensoreggPairingFlag()) display.print(F(" PAIR")); + if (sensoreggTcFault()) display.print(F(" FAULT")); + if (!sensoreggPairingFlag() && !sensoreggTcFault()) display.print(F(" -")); + display.println(); + + // Row 5: which egg the filter accepts. + char eggMac[sensoregg_protocol::kMacStrLen]; + if (sensoreggPairedMac(eggMac, sizeof(eggMac))) { + display.print(F("MAC ")); // 4 + 17 = 21 chars exactly + display.println(eggMac); + } else { + display.println(F("MAC any (unpaired)")); + } + + // Rows 6-7: spacer + the single menu row. + display.println(); + display.print(menuSelectionIndex == 0 ? F("->") : F(" ")); + display.println(F("Back")); + + safeDisplayUpdate(); +} + +#endif // BIRDSEYE_ENABLE_SENSOREGG + void displayPage_camera_serial_entry() { resetDisplay(); diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 3fcdd7b..4fc17da 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -361,13 +361,25 @@ void handleMenuPageSelection() { internalNotification[sizeof(internalNotification) - 1] = '\0'; switchToDisplayPage(PAGE_INTERNAL_WARNING); } - } else { + } else if (menuSelectionIndex == 5) { // Camera selected — paired shows status/unpair, unpaired starts pairing debugln(F("Main Menu: Camera selected")); if (!cameraIsPaired()) { cameraRequestPair(); // pairing begins as the page comes up } switchToDisplayPage(PAGE_PAIR_CAMERA); +#if BIRDSEYE_ENABLE_SENSOREGG + } else { + // Egg selected (index 6, plan 0017) — paired shows status/unpair, + // unpaired opens the capture window as the page comes up (camera + // pattern). On a stock build menuLimit caps the index at 5, so the + // flag-gated else is unreachable there and compiled out entirely. + debugln(F("Main Menu: Egg selected")); + if (!sensoreggIsPaired()) { + sensoreggRequestPair(); + } + switchToDisplayPage(PAGE_PAIR_EGG); +#endif } } else if (currentPage == PAGE_DRAG_DISTANCE) { // Five distances + Back (last row). A distance opens the mode page @@ -405,6 +417,10 @@ void handleMenuPageSelection() { } else if (menuSelectionIndex == 1) { debugln(F("Camera: Test selected")); cameraTestEnterMode(); + // Latch the egg bench scan too (no-op on stock builds): this page's + // egg soak line was dead on a desk since plan 0012 race-gated the + // scanner — see the comment at the readout in display_pages.ino. + sensoreggTestEnterMode(); switchToDisplayPage(PAGE_CAMERA_TEST); } else { debugln(F("Camera: Unpair selected")); @@ -452,14 +468,50 @@ void handleMenuPageSelection() { // mode first or cameraTestActive would stay latched (FSM // suppressed) with no way back to this menu's Back action. cameraTestExitMode(); + sensoreggTestExitMode(); // drop the egg soak latch with it switchToDisplayPage(PAGE_INTERNAL_WARNING); } } else { debugln(F("Camera Test: Back")); cameraTestExitMode(); + sensoreggTestExitMode(); // drop the egg soak latch with it switchToDisplayPage(PAGE_PAIR_CAMERA); } forceDisplayRefresh(); +#if BIRDSEYE_ENABLE_SENSOREGG + } else if (currentPage == PAGE_PAIR_EGG) { + // Menu only while paired (Back / Test / Unpair) — the unpaired + // capture screen handles its buttons in the custom branch in + // displayLoop(). Back is index 0 so a late "Cancel" press right + // after a capture can't land on Test or Unpair (camera precedent). + if (menuSelectionIndex == 0) { + debugln(F("Egg: Back selected")); + switchToDisplayPage(PAGE_MAIN_MENU); + } else if (menuSelectionIndex == 1) { + debugln(F("Egg: Test selected")); + sensoreggTestEnterMode(); // latch the scanner on outside races + switchToDisplayPage(PAGE_EGG_TEST); + } else { + debugln(F("Egg: Unpair selected")); + if (sensoreggUnpair()) { + switchToDisplayPage(PAGE_MAIN_MENU); + } else { + // Persist-first refused: the settings write failed (SD trouble) + // and nothing changed — warn instead of silently diverging. + strncpy(internalNotification, "Settings write\nfailed!", + sizeof(internalNotification) - 1); + internalNotification[sizeof(internalNotification) - 1] = '\0'; + switchToDisplayPage(PAGE_INTERNAL_WARNING); + } + } + } else if (currentPage == PAGE_EGG_TEST) { + // Single row: Back. Drop the bench latch so the race gate owns the + // scanner again. + debugln(F("Egg Test: Back")); + sensoreggTestExitMode(); + switchToDisplayPage(PAGE_PAIR_EGG); + forceDisplayRefresh(); +#endif } else if (currentPage == PAGE_TRANSFER_MENU) { if (menuSelectionIndex == 2) { // Back — the only non-rebooting way off this page. @@ -684,6 +736,12 @@ void displayLoop() { displayPage_pair_camera(); } else if (currentPage == PAGE_CAMERA_TEST) { displayPage_camera_test(); +#if BIRDSEYE_ENABLE_SENSOREGG + } else if (currentPage == PAGE_PAIR_EGG) { + displayPage_pair_egg(); + } else if (currentPage == PAGE_EGG_TEST) { + displayPage_egg_test(); +#endif } else if (currentPage == PAGE_COURSE_TRACK) { displayPage_course_track(); } else if (currentPage == PAGE_COURSE_TYPE) { @@ -790,11 +848,21 @@ void displayLoop() { // flips the page into menu mode the moment a serial is captured. (currentPage == PAGE_PAIR_CAMERA && cameraIsPaired()) || currentPage == PAGE_CAMERA_TEST || +#if BIRDSEYE_ENABLE_SENSOREGG + // Egg page mirrors the camera page: menu only while paired; the + // unpaired capture screen uses the custom branch below (plan 0017). + (currentPage == PAGE_PAIR_EGG && sensoreggIsPaired()) || + currentPage == PAGE_EGG_TEST || +#endif courseCreatorActive() ) { insideMenu = true; if (currentPage == PAGE_MAIN_MENU) { +#if BIRDSEYE_ENABLE_SENSOREGG + menuLimit = 7; // Race, Drag, Review, Transfer, Create Course, Camera, Egg +#else menuLimit = 6; // Race, Drag, Review, Transfer, Create Course, Camera +#endif } else if (currentPage == PAGE_DRAG_DISTANCE) { menuLimit = drag_timer::kDistanceCount + 1; // distances + Back } else if (currentPage == PAGE_DRAG_MODE) { @@ -813,6 +881,12 @@ void displayLoop() { menuLimit = 3; // Back, Test, Unpair } else if (currentPage == PAGE_CAMERA_TEST) { menuLimit = 4; // Wake, Record, Power Off, Back +#if BIRDSEYE_ENABLE_SENSOREGG + } else if (currentPage == PAGE_PAIR_EGG) { + menuLimit = 3; // Back, Test, Unpair + } else if (currentPage == PAGE_EGG_TEST) { + menuLimit = 1; // Back +#endif } else if ( currentPage == LOGGING_STOP_CONFIRM || currentPage == PAGE_COURSE_PRUNE || @@ -857,6 +931,12 @@ void displayLoop() { currentPage == PAGE_DRAG_MODE || currentPage == PAGE_PAIR_CAMERA || currentPage == PAGE_CAMERA_TEST || +#if BIRDSEYE_ENABLE_SENSOREGG + // Statically rendered top-to-bottom, exactly + // like the camera pages (bug #7 class). + currentPage == PAGE_PAIR_EGG || + currentPage == PAGE_EGG_TEST || +#endif currentPage == PAGE_COURSE_PRUNE || currentPage == PAGE_TRANSFER_MENU || courseCreatorActive()); @@ -940,6 +1020,17 @@ void displayLoop() { cameraCancelPair(); switchToDisplayPage(PAGE_MAIN_MENU); } +#if BIRDSEYE_ENABLE_SENSOREGG + } else if (currentPage == PAGE_PAIR_EGG) { + // Unpaired capture screen only (the paired variant is a menu above). + // B2 (Select) = cancel. B1 stays deliberately unbound — reserved for + // a future manual MAC entry page (plan 0017 deferred it). + if (btn2->pressed) { + debugln(F("Pair Egg: cancel")); + sensoreggCancelPair(); + switchToDisplayPage(PAGE_MAIN_MENU); + } +#endif } else if (currentPage == PAGE_CAMERA_SERIAL_ENTRY) { // Manual serial entry button map: // B1 (Left) = cycle char backward (cursor 0-5) / toggle OK-CANCEL diff --git a/BirdsEye/sensoregg.h b/BirdsEye/sensoregg.h index c46cc3b..cad11df 100644 --- a/BirdsEye/sensoregg.h +++ b/BirdsEye/sensoregg.h @@ -1,5 +1,6 @@ #pragma once +#include #include /////////////////////////////////////////// @@ -26,9 +27,15 @@ // broadcaster and accepts no connections. Do not "improve" this into a // connection — the camera link wins every tradeoff. // -// PAIRING (POC): hardcoded MAC via SENSOREGG_MAC below. All-zeros (the -// default) = accept any advertiser whose payload matches the PW magic — -// fine while exactly one egg exists. +// PAIRING (plan 0017): runtime MAC filter, persisted as the +// "sensoregg_mac" setting ("AA:BB:CC:DD:EE:FF"; empty = unpaired = +// accept any advertiser whose payload matches the PW magic). +// SENSOREGG_MAC below is only the fallback when the setting is unset or +// unparsable. Capture is window-gated: the Egg menu opens a 2-minute +// listen window, and the first egg heard with ITS pairing-window flag +// set (egg-side long-press, payload flags bit0) is persisted — physical +// possession is the authorization. Applied live on pair/unpair (no +// reboot), like the camera serial. // // THREADING (mirrors camera_ble.ino): the Bluefruit scan callback runs in // BLE task context and only filters, copies bytes into a RAM double @@ -50,9 +57,10 @@ // value draws a flat line indistinguishable from real data. /////////////////////////////////////////// -// Hardcoded egg MAC, in the human-readable order the egg prints at boot +// FALLBACK egg MAC (used only when the "sensoregg_mac" setting is unset +// or unparsable), in the human-readable order the egg prints at boot // (AA:BB:CC:DD:EE:FF -> {0xAA,0xBB,...}). All-zeros = magic-match any -// PW-ADV-1 broadcaster. (nRF ble_gap_addr_t stores bytes LSB-first; the +// PW-ADV broadcaster. (nRF ble_gap_addr_t stores bytes LSB-first; the // match helper handles the reversal — keep this define human-ordered.) #define SENSOREGG_MAC {0x00, 0x00, 0x00, 0x00, 0x00, 0x00} @@ -118,3 +126,53 @@ bool sensoreggTcFault(); // Free-running egg sequence counter from the latest payload (debug). uint16_t sensoreggSequence(); + +// ---- pairing surface (plan 0017; display_ui.ino / display_pages.ino) ---- + +// True while a specific egg MAC is stored (runtime filter non-wildcard). +bool sensoreggIsPaired(); + +// Open the 2-minute capture window (kPairingTimeoutMs): the scanner is +// forced on and EVERY magic-matching egg is observed — the first frame +// carrying the egg's own pairing-window flag wins and is persisted. +// Idempotent; re-requesting restarts the timeout clock. +void sensoreggRequestPair(); + +// Close the capture window without pairing (user cancel; the timeout +// closes it on its own otherwise). +void sensoreggCancelPair(); + +// True while the capture window is open. +bool sensoreggPairingInProgress(); + +// Forget the paired egg. PERSISTS FIRST: returns false when the settings +// write fails (SD trouble) and changes nothing, so the UI can warn +// instead of silently diverging from disk. True = unpaired, back to +// accept-any. +bool sensoreggUnpair(); + +// Paired MAC as "AA:BB:CC:DD:EE:FF". Returns false (buf = "") when +// unpaired or bufSize < sensoregg_protocol::kMacStrLen (18). +bool sensoreggPairedMac(char* buf, size_t bufSize); + +// ---- bench test page (mirrors cameraTestEnterMode/ExitMode) ---- + +// Latch the passive scanner on outside races so the EGG TEST page (and +// the camera test page's coexistence soak) stream live data on a desk. +// Exit drops the latch; the race gate then owns the scanner again. +void sensoreggTestEnterMode(); +void sensoreggTestExitMode(); + +// ---- test-page telemetry ---- + +// Protocol version byte of the latest frame (1/2); 0 when stale or no +// egg was ever heard. +uint8_t sensoreggProtoVersion(); + +// The egg's own pairing-window flag (payload flags bit0), gated fresh && +// !hung like tcFault so a frozen payload can't show a live window. +bool sensoreggPairingFlag(); + +// Measured accepted-parse rate over a 1 s tumbling window (~9-10 Hz on a +// healthy bench at the egg's 111.875 ms advertising interval). +float sensoreggPacketHz(); diff --git a/BirdsEye/sensoregg.ino b/BirdsEye/sensoregg.ino index 303156f..6056cf3 100644 --- a/BirdsEye/sensoregg.ino +++ b/BirdsEye/sensoregg.ino @@ -25,6 +25,7 @@ #include "bluetooth.h" // bleCoreEnsureInit() #include "sensoregg_protocol.h" +#include "settings.h" // getSetting/setSetting — the sensoregg_mac key // Forward declaration: the callback signature mentions a SoftDevice type, // and Arduino's auto-prototype generator inserts prototypes BEFORE @@ -36,9 +37,33 @@ static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report); // MODULE STATE /////////////////////////////////////////// -// Hardcoded egg MAC (human order, see sensoregg.h). All-zeros = accept -// any PW-ADV-1 broadcaster (POC: exactly one egg exists). -static const uint8_t kSensorEggMac[6] = SENSOREGG_MAC; +// Runtime egg-MAC filter (plan 0017), HUMAN byte order. Boots from the +// SENSOREGG_MAC fallback, then SENSOREGG_SETUP() overrides it from the +// "sensoregg_mac" setting when that parses. All-zeros = accept-any +// (unpaired). Written on the main loop (capture / unpair), read in BLE +// task context via sensoreggMacAccepted() — a torn 6-byte read is +// tolerable by construction: capture updates it while eggPairingActive +// still bypasses the filter entirely, and unpair's half-zero transient +// can only mis-route a single report (one-report tolerance, same story +// as the documented raceActive read below). +static uint8_t eggMacFilter[6] = SENSOREGG_MAC; + +// ---- pairing / bench state (plan 0017) ---- +// The two flags are read in BLE task context (scan gate + callback +// filter bypass): volatile, same care as eggSleeping. +static volatile bool eggPairingActive = false; // capture window open +static volatile bool eggTestActive = false; // EGG TEST bench latch +static uint32_t eggPairStartMs = 0; // main loop only +// Per-slot advertiser address (raw LSB-first), captured alongside the +// payload so the main-loop drain can pair without the callback ever +// learning any protocol. Plain RAM like eggBuf; the ready flags below +// are the synchronization points. +static uint8_t eggPeerMac[2][6]; +// Packet-rate meter for the test page (main loop only): accepted parses +// over a 1 s tumbling window. +static uint16_t eggRateCount = 0; +static uint32_t eggRateWindowMs = 0; +static float eggRateHz = 0.0f; // ---- RX double-buffer (BLE scan callback fills, SENSOREGG_LOOP drains; // mirrors camera_ble.ino's ce81 idiom: payload arrays are plain RAM, the @@ -82,8 +107,14 @@ static constexpr uint32_t kEggStartRetryMs = 1000; // Bluefruit above). Read from the scan callback (BLE task) too — a plain // bool read is atomic on this core, and a stale read only delays the // stop/start by one report (the main-loop reconcile owns the real state). +// Plan 0017 widened the gate: the pairing capture window and the EGG +// TEST bench latch also force the scan on — both are explicit user +// actions on the menu, bounded by the 2-minute pairing timeout / the +// test page's Back row, so the plan-0012 "never pay scan duty on the +// menu" rule still holds for every passive path (transfer, replay, +// idle menu). static bool eggScanWanted() { - return raceActive && !eggSleeping; + return (raceActive || eggPairingActive || eggTestActive) && !eggSleeping; } /////////////////////////////////////////// @@ -91,22 +122,11 @@ static bool eggScanWanted() { /////////////////////////////////////////// // True when the reporting advertiser is our egg. nRF ble_gap_addr_t -// stores the address LSB-first, the #define is human-ordered (MSB -// first) — compare reversed. All-zeros define = accept anyone (the -// payload magic already filtered). +// stores the address LSB-first, the filter is human-ordered (MSB +// first) — macAccepts() compares reversed (host-tested, plan 0017). +// Wildcard filter = accept anyone (the payload magic already filtered). static bool sensoreggMacAccepted(const uint8_t* peerAddrLsbFirst) { - bool filterActive = false; - for (int i = 0; i < 6; i++) { - if (kSensorEggMac[i] != 0x00) { - filterActive = true; - break; - } - } - if (!filterActive) return true; - for (int i = 0; i < 6; i++) { - if (peerAddrLsbFirst[i] != kSensorEggMac[5 - i]) return false; - } - return true; + return sensoregg_protocol::macAccepts(eggMacFilter, peerAddrLsbFirst); } static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report) { @@ -115,9 +135,12 @@ static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report) { uint8_t len = Bluefruit.Scanner.parseReportByType( report, BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA, buf, sizeof(buf)); + // During the pairing capture window the MAC filter is bypassed so a + // NEW egg (including a different one while another is paired) can be + // observed — the main-loop drain does the actual capture. if (len >= sensoregg_protocol::kPayloadLen && sensoregg_protocol::matchesMagic(buf, len) && - sensoreggMacAccepted(report->peer_addr.addr)) { + (sensoreggMacAccepted(report->peer_addr.addr) || eggPairingActive)) { const uint8_t w = eggWriteIdx; // Capture up to the largest known layout; the parser applies the // per-version length gate. (The old fixed-14 copy silently truncated @@ -127,6 +150,7 @@ static void sensoreggScanCallback(ble_gap_evt_adv_report_t* report) { ? len : (uint8_t)sensoregg_protocol::kPayloadLenMax; memcpy(eggBuf[w], buf, copyLen); + memcpy(eggPeerMac[w], report->peer_addr.addr, 6); eggLen[w] = copyLen; eggAtMs[w] = millis(); eggReady[w] = true; @@ -172,6 +196,22 @@ void SENSOREGG_SETUP() { Bluefruit.Scanner.filterMSD(sensoregg_protocol::kCompanyId); eggSetupDone = true; + // Runtime pairing filter (plan 0017): the "sensoregg_mac" setting + // overrides the SENSOREGG_MAC fallback when it parses; empty or + // invalid keeps the fallback (all-zeros default = accept-any). + // SETTINGS_SETUP() ran earlier in setup(), and the scanner cannot be + // running yet — no concurrency with the callback's filter read. + { + char macStr[sensoregg_protocol::kMacStrLen]; + if (getSetting("sensoregg_mac", macStr, sizeof(macStr))) { + uint8_t mac[6]; + if (sensoregg_protocol::parseMac(macStr, mac)) { + memcpy(eggMacFilter, mac, sizeof(eggMacFilter)); + debugln(F("SensorEgg: paired MAC loaded from settings")); + } + } + } + // Deliberately NOT started here (plan 0012). The scanner used to run // forever from boot; SENSOREGG_LOOP()'s reconcile now starts it when a // race session begins and stops it when the session ends, so the menu, @@ -211,7 +251,9 @@ void SENSOREGG_LOOP() { // Drain everything queued (usually 0 or 1 slots); the newest parse wins. while (eggReady[eggReadIdx]) { uint8_t local[sensoregg_protocol::kPayloadLenMax]; + uint8_t localPeer[6]; memcpy(local, eggBuf[eggReadIdx], sizeof(local)); + memcpy(localPeer, eggPeerMac[eggReadIdx], sizeof(localPeer)); const uint8_t localLen = eggLen[eggReadIdx]; const uint32_t atMs = eggAtMs[eggReadIdx]; eggReady[eggReadIdx] = false; @@ -223,6 +265,52 @@ void SENSOREGG_LOOP() { eggRxMs = atMs; eggHaveReading = true; sensoregg_protocol::seqMonitorFeed(eggSeqMon, r.sequence, atMs); + eggRateCount++; + + // Pairing capture (plan 0017): first frame whose egg advertises + // its own pairing-window flag wins. PERSIST FIRST (camera + // precedent, camera_ble.ino) — a failed SD write leaves the + // window open so the next frame retries. The RAM filter is + // updated while eggPairingActive still bypasses it in the + // callback, so a torn filter read can never reject the captured + // egg; the flag clears last. + if (eggPairingActive && r.pairingActive) { + uint8_t human[6]; + char macStr[sensoregg_protocol::kMacStrLen]; + sensoregg_protocol::macReverse(localPeer, human); + sensoregg_protocol::formatMac(human, macStr); + if (setSetting("sensoregg_mac", macStr)) { + memcpy(eggMacFilter, human, sizeof(eggMacFilter)); + eggPairingActive = false; + debugln(F("SensorEgg: paired")); + } else { + debugln(F("SensorEgg: pair capture — settings write failed, retrying")); + } + } + } + } + + // Pairing window timeout (plan 0017): give up after kPairingTimeoutMs + // without a capture, so a walked-away-from pairing screen doesn't hold + // the scanner on forever. + if (eggPairingActive && + (uint32_t)(millis() - eggPairStartMs) >= + sensoregg_protocol::kPairingTimeoutMs) { + eggPairingActive = false; + debugln(F("SensorEgg: pairing timed out")); + } + + // Packet-rate meter for the test page: 1 s tumbling window, so the + // figure decays to 0 within a second of the egg going quiet. + { + const uint32_t now = millis(); + if (eggRateWindowMs == 0) { + eggRateWindowMs = now; + } else if ((uint32_t)(now - eggRateWindowMs) >= 1000) { + eggRateHz = + (float)eggRateCount * 1000.0f / (float)(now - eggRateWindowMs); + eggRateCount = 0; + eggRateWindowMs = now; } } @@ -326,6 +414,74 @@ uint16_t sensoreggSequence() { return eggReading.sequence; } +/////////////////////////////////////////// +// PAIRING / BENCH SURFACE (plan 0017) +/////////////////////////////////////////// + +bool sensoreggIsPaired() { + return !sensoregg_protocol::macIsWildcard(eggMacFilter); +} + +void sensoreggRequestPair() { + // Idempotent; a re-request restarts the timeout clock. + eggPairStartMs = millis(); + eggPairingActive = true; +} + +void sensoreggCancelPair() { + eggPairingActive = false; +} + +bool sensoreggPairingInProgress() { + return eggPairingActive; +} + +bool sensoreggUnpair() { + // PERSIST FIRST: refuse the RAM change when the SD write fails so the + // UI warns instead of silently diverging from disk (camera precedent). + if (!setSetting("sensoregg_mac", "")) { + return false; + } + memset(eggMacFilter, 0, sizeof(eggMacFilter)); // -> accept-any + return true; +} + +bool sensoreggPairedMac(char* buf, size_t bufSize) { + if (buf == nullptr || bufSize < sensoregg_protocol::kMacStrLen) { + return false; + } + if (!sensoreggIsPaired()) { + buf[0] = '\0'; + return false; + } + sensoregg_protocol::formatMac(eggMacFilter, buf); + return true; +} + +void sensoreggTestEnterMode() { + eggTestActive = true; +} + +void sensoreggTestExitMode() { + eggTestActive = false; +} + +uint8_t sensoreggProtoVersion() { + // 0 = stale or never heard — the test page renders "v-". + if (!sensoreggLinkUp()) return 0; + return eggReading.protoVersion; +} + +bool sensoreggPairingFlag() { + // The egg's own 30 s window bit, gated like tcFault so a frozen + // payload's flag can't show as a live window. + return sensoreggLinkUp() && !sensoreggAppHung() && eggReading.pairingActive; +} + +float sensoreggPacketHz() { + return eggRateHz; +} + #else // !BIRDSEYE_ENABLE_SENSOREGG /////////////////////////////////////////// @@ -354,4 +510,21 @@ uint8_t sensoreggBatteryPct() { return 0xFF; } bool sensoreggTcFault() { return false; } uint16_t sensoreggSequence() { return 0; } +bool sensoreggIsPaired() { return false; } +void sensoreggRequestPair() {} +void sensoreggCancelPair() {} +bool sensoreggPairingInProgress() { return false; } +// True: the post-condition "not paired" already holds (removeSetting's +// contract philosophy). +bool sensoreggUnpair() { return true; } +bool sensoreggPairedMac(char* buf, size_t bufSize) { + if (buf != nullptr && bufSize > 0) buf[0] = '\0'; + return false; +} +void sensoreggTestEnterMode() {} +void sensoreggTestExitMode() {} +uint8_t sensoreggProtoVersion() { return 0; } +bool sensoreggPairingFlag() { return false; } +float sensoreggPacketHz() { return 0.0f; } + #endif // BIRDSEYE_ENABLE_SENSOREGG diff --git a/BirdsEye/sensoregg_protocol.cpp b/BirdsEye/sensoregg_protocol.cpp index 3141b28..d8d11c4 100644 --- a/BirdsEye/sensoregg_protocol.cpp +++ b/BirdsEye/sensoregg_protocol.cpp @@ -1,6 +1,7 @@ #include "sensoregg_protocol.h" #include +#include namespace sensoregg_protocol { @@ -17,6 +18,13 @@ float decodeDeciC(uint8_t lo, uint8_t hi) { return (float)raw / 10.0f; } +int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} + } // namespace bool matchesMagic(const uint8_t* data, size_t len) { @@ -83,4 +91,61 @@ bool seqMonitorLive(const SeqMonitor& m, uint32_t nowMs) { return m.haveSeq && isFresh(m.lastChangeMs, nowMs); } +bool parseMac(const char* s, uint8_t outHuman[6]) { + if (s == nullptr) { + return false; + } + uint8_t tmp[6]; + size_t i = 0; + for (int b = 0; b < 6; b++) { + // A NUL at s[i] fails the first nibble, so s[i+1] is never read past + // the terminator. + const int hi = hexNibble(s[i]); + if (hi < 0) return false; + const int lo = hexNibble(s[i + 1]); + if (lo < 0) return false; + tmp[b] = (uint8_t)((hi << 4) | lo); + i += 2; + if (b < 5) { + if (s[i] != ':') return false; + i++; + } + } + if (s[i] != '\0') return false; // trailing garbage / over-length + memcpy(outHuman, tmp, 6); + return true; +} + +void formatMac(const uint8_t human[6], char out[kMacStrLen]) { + static const char kHex[] = "0123456789ABCDEF"; + size_t o = 0; + for (int b = 0; b < 6; b++) { + out[o++] = kHex[human[b] >> 4]; + out[o++] = kHex[human[b] & 0x0F]; + if (b < 5) out[o++] = ':'; + } + out[o] = '\0'; +} + +void macReverse(const uint8_t in[6], uint8_t out[6]) { + uint8_t tmp[6]; + for (int i = 0; i < 6; i++) tmp[i] = in[5 - i]; + memcpy(out, tmp, 6); +} + +bool macIsWildcard(const uint8_t mac[6]) { + for (int i = 0; i < 6; i++) { + if (mac[i] != 0x00) return false; + } + return true; +} + +bool macAccepts(const uint8_t filterHuman[6], const uint8_t peerLsbFirst[6]) { + if (macIsWildcard(filterHuman)) return true; + for (int i = 0; i < 6; i++) { + if (peerLsbFirst[i] != filterHuman[5 - i]) return false; + } + return true; +} + } // namespace sensoregg_protocol diff --git a/BirdsEye/sensoregg_protocol.h b/BirdsEye/sensoregg_protocol.h index d751e51..2a84527 100644 --- a/BirdsEye/sensoregg_protocol.h +++ b/BirdsEye/sensoregg_protocol.h @@ -157,4 +157,40 @@ void seqMonitorFeed(SeqMonitor& m, uint16_t seq, uint32_t nowMs); // change has been seen within kStalenessMs (wrap-safe timing). bool seqMonitorLive(const SeqMonitor& m, uint32_t nowMs); +// ---- Pairing MAC helpers (plan 0017 — runtime pairing filter) ----------- +// The egg-MAC filter lives in settings as text ("AA:BB:CC:DD:EE:FF", "" = +// unpaired/accept-any); these pure helpers own the parse/format and the +// byte-order rules so they are host-tested. "Human" order is MSB-first — +// the order the egg prints at boot and SENSOREGG_MAC uses; the radio's +// ble_gap_addr_t reports LSB-first. + +// Pairing capture window: how long the logger listens for an egg +// advertising its pairing-window flag (egg long-press = 30 s of flag) +// before giving up. Matches the camera's pairing timeout. +constexpr uint32_t kPairingTimeoutMs = 120000; + +// "AA:BB:CC:DD:EE:FF" + NUL. +constexpr size_t kMacStrLen = 18; + +// Strict parse: exactly 17 chars, hex pairs separated by ':', case- +// insensitive. Returns false (out untouched) on any deviation. Note that +// "00:00:00:00:00:00" parses successfully to the wildcard — callers must +// treat that as unpaired (macIsWildcard), same as an empty setting. +bool parseMac(const char* s, uint8_t outHuman[6]); + +// Format to uppercase "AA:BB:CC:DD:EE:FF", NUL-terminated at out[17]. +void formatMac(const uint8_t human[6], char out[kMacStrLen]); + +// Reverse byte order (human MSB-first <-> ble_gap_addr_t LSB-first). +// Involution: reversing twice restores the input. Aliasing-safe. +void macReverse(const uint8_t in[6], uint8_t out[6]); + +// All-zeros = "accept any egg" (unpaired). +bool macIsWildcard(const uint8_t mac[6]); + +// Filter decision for the scan callback: wildcard accepts anyone; +// otherwise the peer address (as the radio reports it, LSB-first) must +// equal the human-ordered filter reversed. +bool macAccepts(const uint8_t filterHuman[6], const uint8_t peerLsbFirst[6]); + } // namespace sensoregg_protocol diff --git a/BirdsEye/settings.h b/BirdsEye/settings.h index f40519a..79ce704 100644 --- a/BirdsEye/settings.h +++ b/BirdsEye/settings.h @@ -32,11 +32,12 @@ // document returns NoMemory at 22 pairs regardless of how much was read). // // Adding settings keys is not free — see the measureJson() guard in -// setSettingInner(), and subsystem 8 in CLAUDE.md. As of plan 0013 the -// default file measures 570 bytes on a stock build (23 keys) and 592 on -// a SensorEgg one (24), rising to 599/621 with the longest values every -// key accepts, against a 1023-byte read cap. That is roughly sixteen -// average keys of headroom. Each key costs len(key) + len(value) + 6. +// setSettingInner(), and subsystem 8 in CLAUDE.md. As of plan 0017 the +// default file measures 570 bytes on a stock build (23 keys) and 611 on +// a SensorEgg one (25 — sensoregg_mac added), rising to 599/657 with the +// longest values every key accepts, against a 1023-byte read cap. That +// is roughly fifteen average keys of headroom. Each key costs +// len(key) + len(value) + 6. /////////////////////////////////////////// #define SETTINGS_JSON_CAPACITY 1024 diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index 841ebf5..dec46f0 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -181,6 +181,11 @@ static void ensureDefaultSettings() { // threshold for, and the key would just spend settings-file bytes // on every stock device. { "temp1_alert_c", "650" }, + // Paired egg MAC "AA:BB:CC:DD:EE:FF"; empty = unpaired = accept any + // PW-ADV egg (plan 0017). Written by the Egg menu's window-gated + // capture (persist-first); applied LIVE on pair/unpair — one of the + // two settings exempt from the next-boot rule, like camera_serial. + { "sensoregg_mac", "" }, #endif // Plan 0013: what each status LED shows, and the speed the 9-px bar // scales against on a session with no tachometer. Mode tokens are diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index b2bbe7d..02654e7 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -134,6 +134,8 @@ void displayPage_gps_best_lap(); void displayPage_tachometer(); void displayPage_sensorTemp(); void displayPage_sensorTemp2(); +void displayPage_pair_egg(); // plan 0017 (flag-gated in the firmware) +void displayPage_egg_test(); // plan 0017 void displayPage_optimal_lap(); void displayPage_gps_lap_list(); void displayPage_stop_logging(); diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index 96ea16d..518d8b9 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -123,6 +123,25 @@ uint8_t sensoreggBatteryPct() { return 0xFF; } bool sensoreggTcFault() { return false; } uint16_t sensoreggSequence() { return 0; } +// Pairing/bench surface (plan 0017). The flag-0 sim never renders the +// Egg pages, but the stub block mirrors the module's full surface so a +// future flag-1 sim build links. Same return values as the .ino's +// compiled-out twins ("never paired, nothing heard"). +bool sensoreggIsPaired() { return false; } +void sensoreggRequestPair() {} +void sensoreggCancelPair() {} +bool sensoreggPairingInProgress() { return false; } +bool sensoreggUnpair() { return true; } +bool sensoreggPairedMac(char* buf, size_t bufSize) { + if (buf != nullptr && bufSize > 0) buf[0] = '\0'; + return false; +} +void sensoreggTestEnterMode() {} +void sensoreggTestExitMode() {} +uint8_t sensoreggProtoVersion() { return 0; } +bool sensoreggPairingFlag() { return false; } +float sensoreggPacketHz() { return 0.0f; } + // ---- neopixel.ino surface ---- // No LED strip in the sim (and no UICR to program). The pure units diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa6b21..0e385f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### Added +- **SensorEgg pairing menu + live-data test page** (plan 0017): a new + **Egg** row on the main menu (SensorEgg builds only). Pairing is + window-gated: open the logger's 2-minute capture window, long-press + the egg's button, and the first egg heard advertising its own pairing + window is stored — to the new `sensoregg_mac` setting, applied live, + with the paired page offering Back / Test / Unpair (persist-first, + exactly like the camera). Unpaired = accept-any, as before. The new + **EGG TEST** page latches the race-gated scanner on at the desk and + shows the full live picture: rf link tri-state, protocol version, + EGT/CJ/AUX/battery, sequence + measured packet rate, PAIR/FAULT + flags, and the active MAC filter. The camera test page's egg soak + line works on a desk again (it had silently died when plan 0012 + race-gated the scanner) — entering it latches the egg bench mode too. + The MAC parse/format/byte-order helpers live in the host-tested + `sensoregg_protocol` unit. - **Manual drag mode — the christmas tree** (plan 0016): Drag now asks **Automatic or Manual** after the distance. Manual stages like a strip: stop, and the LED bar lights a white staging pip, then three yellows at diff --git a/CLAUDE.md b/CLAUDE.md index 87b5c72..f6cac70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ All sketch sources live in `BirdsEye/` so the folder name matches the | `profiling.{h,ino}` | Main-loop CPU profiling glue (subsystem 18, beta only): DWT/micros timebase probe, section brackets, the pin-30 scope output, once-a-second rollup. Also the reason a beta build never drives the 5 V boost EN | | `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) | +| `sensoregg.{h,ino}` | SensorEgg wireless EGT: passive BLE scan (observer), scan-callback→loop double buffer, settings-backed MAC pairing + bench latch (plan 0017), Temp1/Junction1 data surface (see subsystem 14) | | `settings.{h,ino}` | Persistent JSON settings on SD (`/SETTINGS.json`), `getSetting()`/`setSetting()` | | `tachometer.{h,ino}` | Falling-edge ISR on D0, Kalman-filtered RPM calculation | | `usb_msc.{h,ino}` | USB Mass Storage (TinyUSB MSC): SD card as a drag-and-drop drive (see subsystem 12) | @@ -558,6 +558,14 @@ loop() ~250 Hz - Camera: `PAGE_PAIR_CAMERA` (-6) pairing / paired-status management, `PAGE_CAMERA_SERIAL_ENTRY` (-7) manual 6-char serial entry fallback, `PAGE_CAMERA_TEST` (-10) bench test menu (paired-only manual controls). + - Egg (plan 0017, `BIRDSEYE_ENABLE_SENSOREGG` builds only): + `PAGE_PAIR_EGG` (-20) pairing / paired-status management (the `Egg` + main-menu row is appended after Camera and gated, so stock menus keep + 6 rows and existing golden fixtures are untouched), `PAGE_EGG_TEST` + (-21) bench live-data page. Both latch the race-gated scanner on + while open (the pairing window / bench mode are OR-ed into + `eggScanWanted()`), and both carry their Back/Cancel row per the rule + above. - Course creator (subsystem 15): `PAGE_COURSE_TRACK` (-11) track prompt, `PAGE_COURSE_TYPE` (-12) circuit/sprint, `PAGE_COURSE_LINES` (-13) line menu, `PAGE_COURSE_LINE` (-14) per-line points, `PAGE_COURSE_POINT` @@ -1300,12 +1308,16 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. `egt` status mode still parses and round-trips on any build; only its rendering is gated (`led_status::Inputs.eggSupported`). Keep any new egg code behind the flag. -- **What (POC)**: a wireless thermocouple pod (DovesSensorEgg repo) reads a - K-type EGT probe via MCP9600 and broadcasts EGT + cold junction in BLE - **advertising packets** — protocol `PW-ADV-1`: 14-byte Manufacturer - Specific Data (`FF FF` company ID + `50 57` magic *inside* the array, - version, flags, int16 LE deci-°C ×2 with `0x8000` = invalid sentinel, - raw MCP9600 STATUS, battery stub, uint16 sequence), ~10 Hz. +- **What**: a wireless thermocouple pod (DovesSensorEgg repo) reads a + K-type EGT probe via MCP9600 and broadcasts its readings in BLE + **advertising packets** — protocol `PW-ADV` v1 (14 bytes) and v2 + (16 bytes): `FF FF` company ID + `50 57` magic *inside* the array, + version byte (0x01/0x02), flags (bit0 = egg pairing window, bit1 = TC + fault), int16 LE deci-°C EGT + cold junction with `0x8000` = invalid + sentinel, raw MCP9600 STATUS, battery percent (real on v2; 0xFF = + unknown), uint16 sequence, and on v2 an aux intake-air thermistor + (int16 LE deci-°C, "Temp2"), ~10 Hz. v1 eggs still parse — their aux + reads NaN — so a mixed-age fleet never blinds the logger. - **Radio role — do not "improve" this**: the logger is a pure passive OBSERVER (`Bluefruit.Scanner`, `useActiveScan(false)`, 90 ms interval / 40 ms window ≈ 44% duty, RSSI ≥ −90). No SCAN_REQ, no connection, no @@ -1328,16 +1340,39 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. the spec's 40 ms.) (3) `SENSOREGG_LOOP()` kicks stop+start after 30 s with no accepted packet — a lost deferred callback otherwise halts the scanner silently forever. -- **Pairing (POC)**: `SENSOREGG_MAC` #define in `sensoregg.h`, human byte - order; all-zeros (default) = accept any advertiser matching the payload - magic. The scan callback filters length + magic + MAC, copies the raw 14 - bytes into a double buffer (camera ce81 idiom), stamps `millis()`, and - calls `Scanner.resume()` — **mandatory**, or the scanner halts after one - report. No Serial/SD/display in the callback (BLE task context). +- **Pairing (plan 0017)**: runtime MAC filter persisted as the + `sensoregg_mac` setting (`"AA:BB:CC:DD:EE:FF"`; empty = unpaired = + accept any advertiser matching the payload magic), loaded in + `SENSOREGG_SETUP()`; the `SENSOREGG_MAC` #define in `sensoregg.h` + (human byte order) is only the fallback for unset/invalid. Capture is + **window-gated** from the Egg menu: the 2-minute window + (`kPairingTimeoutMs`) forces the scanner on and bypasses the MAC + filter (so a different egg can be captured while one is paired), and + the first parsed frame advertising the egg's OWN pairing-window flag + (egg-side long-press, flags bit0) wins — persist-first (a failed SD + write keeps the window open to retry), then the RAM filter, then the + window closes. Unpair = persist-first `""` → accept-any. Applied LIVE + on pair/unpair — no reboot, same exception as `camera_serial`. The + scan callback filters length + magic + MAC, copies **up to the + largest known layout** (`kPayloadLenMax` — the old fixed-14 copy + silently truncated v2 frames) plus the advertiser address into a + double buffer (camera ce81 idiom), stamps `millis()`, and calls + `Scanner.resume()` — **mandatory**, or the scanner halts after one + report. No Serial/SD/display in the callback (BLE task context); the + capture decision runs in the main-loop drain on host-tested logic + (`macAccepts` and friends in `sensoregg_protocol`). - **Consumption**: `SENSOREGG_LOOP()` (main loop) drains + parses via the host-tested `sensoregg_protocol` unit. Accessors: `sensoreggEgtC()` / - `sensoreggJunctionC()` (NaN when stale, egg-invalid, or app-hung), - `sensoreggLinkUp()`, `sensoreggTcFault()`, `sensoreggAppHung()`. + `sensoreggJunctionC()` / `sensoreggAuxC()` (NaN when stale, + egg-invalid, or app-hung), `sensoreggBatteryPct()`, + `sensoreggLinkUp()`, `sensoreggTcFault()`, `sensoreggAppHung()`; plus + the plan-0017 pairing/bench surface — `sensoreggIsPaired` / + `RequestPair` / `CancelPair` / `PairingInProgress` / `Unpair` / + `PairedMac`, `sensoreggTestEnterMode/ExitMode` (the bench latch that + joins `raceActive` and the pairing window in `eggScanWanted()`, also + latched by the camera test page for the desk soak), and + `sensoreggProtoVersion` / `PairingFlag` / `PacketHz` / `Sequence` for + the EGG TEST page. - **Zombie-egg detection**: BLE radios rebroadcast the last-set advert buffer autonomously, so an egg whose *application* hangs (suspected blocking MCP9600 I2C read under ignition EMI; 2026-07-19 field incident, @@ -1360,7 +1395,10 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. fallback note (spec §7.2.3) rather than touching the shared `begin(1, 0)`. - **Sim**: `sensoregg.ino` is excluded from the sim TU like the other BLE modules; `module_stubs.cpp` returns NaN/false so the page renders `---` - and rows log `nan`. + and rows log `nan` (the pairing-surface stubs mirror the compiled-out + twins: never paired, nothing heard). The sim builds flag-0, so the Egg + menu row and pages don't exist there — golden hashes double as the + gating-leak check: if a hash moves after egg work, a `#if` escaped. ### 15. On-device Course Creator (`course_creator.{h,cpp}`, `track_json.{h,cpp}`) @@ -1878,6 +1916,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. "led_status_left": "rpm", "led_status_right": "egt", "temp1_alert_c": "650", + "sensoregg_mac": "", "utc_offset_min": "0", "led_brightness_night": "16", "led_day_start_hour": "7", @@ -1905,6 +1944,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | `target_rpm` | int | `15000` | True RPM SHIFT/warning point: RPM-scale ceiling and the `rpm` status-LED flasher threshold. Clamp 1000–20000 (tach filter's ceiling). Was `rev_limit` before plan 0013 — a device carrying the old key has its value migrated into this one on first boot and the old key removed | | `overrev_limit` | int | `0` (disabled) | True RPM PROBLEM limit (plan 0007): past it the whole 11-px chain flashes red (outranks the purple celebration) and the tach page shows `*OVER REV*`; latch clears below `target_rpm × 0.97`. 0 = off (no chain flash, no header); else clamp 1000–20000 | | `temp1_alert_c` | int | `650` | **SensorEgg builds only** since plan 0013 — a stock image neither writes nor reads it. Temp1 (EGT) alert threshold in **Celsius** for the `egt` status mode: red flash at/above, clears 20 °C below, solid blue when the probe signal is NaN/stale. Clamp 50–1200 | +| `sensoregg_mac` | string | `""` (empty = unpaired) | **SensorEgg builds only** (plan 0017). Paired egg MAC `"AA:BB:CC:DD:EE:FF"`, written by the Egg menu's window-gated capture (persist-first); empty or unparsable = accept any PW-ADV egg (the `SENSOREGG_MAC` fallback). **Applied LIVE on pair/unpair** — an exception to the next-boot rule below, like the camera serial | | `utc_offset_min` | int | `0` | Minutes east of UTC (US Central standard `-360`, India `330`, Newfoundland `-210`). Clamp ±840; out of band keeps 0 (= UTC). **Presentation only** — nothing logged is converted (subsystem 17) | | `led_brightness_night` | int | `16` | NeoPixel cap 0–255 used inside the night window. `0` blanks the strip but leaves the 5 V rail UP — only `led_brightness` 0 cuts the rail | | `led_day_start_hour` | int | `7` | **Local** hour the day cap takes over. Clamp 0–23 | @@ -1922,7 +1962,9 @@ the one loaded). Sector lines stay optional — zero, one, or two. error (single retry against the regenerated file). An *empty* file is not corrupt — the default-population paths rebuild it in place. - Editable on a computer or via BLE `SSET` command — changes take effect - on next reboot (BLE disconnect triggers auto-reboot). + on next reboot (BLE disconnect triggers auto-reboot). Exceptions: + `camera_serial` and `sensoregg_mac` are applied live by their pairing + flows (both persist first, then update RAM state). - Read on-demand via `getSetting()`, written via `setSetting()`. --- @@ -2018,7 +2060,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | SensorEgg scan interval / window | 90 ms / 40 ms (≈44% duty, test-capped ≤45%), passive | `sensoregg_protocol.h` | | SensorEgg scanner self-heal | 30 s no packet → stop+start kick | `sensoregg_protocol.h` | | SensorEgg RSSI floor | −90 dBm | `sensoregg_protocol.h` | -| SensorEgg pairing MAC | `SENSOREGG_MAC` (all-zeros = any egg) | `sensoregg.h` | +| SensorEgg pairing MAC | `sensoregg_mac` setting (plan 0017); `SENSOREGG_MAC` is the fallback (all-zeros = any egg) | `sensoregg.h` / `settings.ino` | +| SensorEgg pairing timeout | 120 s capture window (`kPairingTimeoutMs`, camera parity) | `sensoregg_protocol.h` | | NeoPixel strip flag | `BIRDSEYE_ENABLE_NEOPIXEL`, default **1** on every channel since 4.1.0 | `project.h` | | Loop profiling flag | `BIRDSEYE_ENABLE_PROFILING`, default 0; 1 on the beta channel | `project.h` | | Profiling pin / span | 30 (`PROFILING_PIN`, = boost EN) / whole loop (`PROFILING_PIN_SECTION`) | `profiling.h` | diff --git a/docs/plans/0017-sensoregg-pairing-menu.md b/docs/plans/0017-sensoregg-pairing-menu.md new file mode 100644 index 0000000..25e88a8 --- /dev/null +++ b/docs/plans/0017-sensoregg-pairing-menu.md @@ -0,0 +1,117 @@ +# 0017 — SensorEgg pairing menu + live-data test page + +The egg subsystem's data path is done (v2 parsing, Temp2 page/column, +zombie detection) but its pairing story is still the POC: a compile-time +`#define SENSOREGG_MAC`, all-zeros meaning "accept any egg". That is +wrong the moment a second egg exists at the track — anyone's pod lands +in your log. There is also no on-device way to see the egg's live stream +outside a race session (the scanner is race-gated since plan 0012), so +bench work means a phone running nRF Connect. + +This plan adds a main-menu **Egg** entry with a camera-style pairing +page, runtime MAC pairing persisted to settings, and a bench **EGG +TEST** page that latches the scanner on outside races. + +## Approach & key decisions + +- **Window-gated capture.** The egg's long-press opens a 30 s pairing + window that sets flags bit0 — parsed as `Reading.pairingActive` since + the v2 round but never consumed. Pairing = the first frame seen with + that bit set wins; its advertiser MAC is persisted. Physical + possession is the authorization ("that's *my* egg"). + - *Rejected:* pair-to-first-egg-seen (grabs a neighbor's pod), + egg-side BLE bonding (the egg is a pure broadcaster; the + "do not improve this into a connection" rule stands — the future + GATT migration is the egg repo's roadmap, not this plan). +- **Settings-backed MAC, `#define` demoted to fallback.** New key + `sensoregg_mac` = `"AA:BB:CC:DD:EE:FF"` or `""` (unpaired = + accept-any, today's behavior). Loaded in `SENSOREGG_SETUP()` (settings + init precedes it); `SENSOREGG_MAC` covers unset/invalid. Applied + live on pair/unpair — an exception to the settings-need-reboot rule, + mirroring the camera serial. + - *Rejected:* manual MAC entry page (12 hex chars on a 3-button + wheel; deferred — B1 on the unpaired screen stays unbound for it). +- **The scan callback stays protocol-blind.** It filters + (len/magic/MAC), copies, resumes — unchanged contract. For pairing it + additionally memcpys the reporting `peer_addr` into a per-slot field + of the existing double buffer (ready-flag-last ordering preserved). + The capture decision runs in `SENSOREGG_LOOP()`'s drain on the parsed + `pairingActive` bit — main-loop context, host-testable logic. + - *Rejected:* peeking `buf[5]` bit0 in the callback (spreads protocol + knowledge into BLE task context for no gain). +- **Accept-any during the window.** While pairing, the MAC filter is + bypassed (`|| eggPairingActive`) so a *different* egg can be captured + while one is already paired (re-pair without unpairing first). +- **Persist-first ordering** (camera precedent): `setSetting` succeeds + → update the RAM filter → close the window. A failed SD write leaves + the window open to retry on the next frame. The RAM filter is written + while the window flag still bypasses it, so a torn 6-byte read can + never reject the right egg; the one-report tolerance of a stale + plain-bool read matches the documented `raceActive` read. +- **Bench latch.** `eggScanWanted()` grows two volatile flags: + `raceActive || eggPairingActive || eggTestActive`. The reconcile, + retry-throttle, and self-heal paths need zero changes. The EGG TEST + page latches `eggTestActive` on entry and drops it on Back. The + camera test page latches it too — restoring the camera+egg desk-soak + behavior its comment promised (stale since plan 0012's race gate). +- **Egg row appended after Camera (index 6).** No churn to existing + select-handler indices; pairing is a rare setup action. +- **Flag-0 sim invisibility = zero golden churn, zero golden coverage.** + Everything sits behind `BIRDSEYE_ENABLE_SENSOREGG`; the sim builds + flag-off, so the menu row and pages don't exist there. Golden hashes + must pass **unchanged** — a diff is a gating leak to fix, never a + regeneration. Accepted cost: the new pages get no sim coverage + (an egg-frame injection hook for a flag-1 sim variant is a follow-up). + +## State machine + +| State | Predicate | Behavior | +|---|---|---| +| UNPAIRED | wildcard filter, window closed | accept-any (POC behavior) | +| PAIRING | `eggPairingActive` | scanner forced on; callback observes every magic egg; first parsed frame with `pairingActive` captures | +| PAIRED | non-wildcard filter, window closed | only the stored egg heard | + +Transitions (main loop unless noted): `sensoreggRequestPair()` opens the +window (+ 120 s timeout, `kPairingTimeoutMs`); capture = persist → +filter → close; `sensoreggCancelPair()` closes; `sensoreggUnpair()` = +persist-first `""` → wildcard filter (false on SD failure, UI warns). +Threading: the two window/bench flags are `volatile` (written main loop, +read BLE task), same care as `eggSleeping`. + +## Touch points + +- `sensoregg_protocol.{h,cpp}` + `tests/sensoregg_protocol_test.cpp` — + pure MAC helpers (`parseMac`, `formatMac`, `macReverse`, + `macIsWildcard`, `macAccepts`, `kMacStrLen`, `kPairingTimeoutMs`); + `sensoreggMacAccepted()` becomes a delegate, putting the LSB-first + reversal under host test. +- `sensoregg.{h,ino}` — state machine, callback peer-MAC capture, + packet-rate meter, 12 new public accessors + no-op twins. +- `settings.ino` defaults row (flag-gated), `settings.h` byte budget. +- `BirdsEye.ino` — `PAGE_PAIR_EGG = -20`, `PAGE_EGG_TEST = -21`. +- `display_pages.{h,ino}` — menu row "Egg", `displayPage_pair_egg()` + (dual-mode), `displayPage_egg_test()`; camera-test soak comment fix. +- `display_ui.ino` — five coupling points (dispatch, insideMenu + + menuLimit, reverseDirection, select handler, unpaired custom branch); + camera-test enter/exit latches the egg bench mode. +- `sim/stubs/module_stubs.cpp`, `sim/sim_prototypes.h`. +- `CLAUDE.md` (page ids, subsystem 14 — also fixes two stale blocks + still describing PW-ADV-1/14-byte-copy — settings + tuning tables), + `CHANGELOG.md`. + +## Verification + +- Host: `ctest --test-dir tests/build` (MAC helper matrix, LSB-first + pin, wildcard semantics, `"00:...:00"` parses to wildcard). +- Sim: build + golden run — hashes unchanged proves the flag gate holds. +- Arduino: compile BOTH flag arms locally (CI only builds flag-on). +- Hardware (owner checklist): pair from the egg's 30 s window; re-pair + to a second egg while paired; unpair → accept-any; race pages + unchanged; desk soak on EGG TEST (rate ~9-10 Hz at 111.875 ms adv); + settings-write-failure path (SD removed) keeps the window open. + +## Status / follow-ups + +- Status: shipped with this plan's PR into BETA. +- Deferred: manual MAC entry page (B1 reserved); DOVEX egg-battery + column; sim egg-frame injection + flag-1 golden variant. diff --git a/tests/sensoregg_protocol_test.cpp b/tests/sensoregg_protocol_test.cpp index 0dd1119..fa4321c 100644 --- a/tests/sensoregg_protocol_test.cpp +++ b/tests/sensoregg_protocol_test.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include "sensoregg_protocol.h" @@ -316,3 +318,82 @@ TEST_CASE("sensoregg_protocol - scan tuning: invariants") { : kEggAdvUnits - kScanIntervalUnits; CHECK(diff >= 8); // >= 5 ms of per-cycle phase sweep } + +// --------------------------------------------------------------------------- +// MAC helpers (plan 0017 — runtime pairing filter) +// --------------------------------------------------------------------------- + +TEST_CASE("sensoregg_protocol - parseMac accepts and round-trips") { + uint8_t mac[6]; + CHECK(parseMac("AA:BB:CC:DD:EE:FF", mac)); + CHECK(mac[0] == 0xAA); + CHECK(mac[5] == 0xFF); + CHECK(parseMac("aa:bb:cc:dd:ee:0f", mac)); // case-insensitive + CHECK(mac[0] == 0xAA); + CHECK(mac[5] == 0x0F); + + // Round trip: parse -> format yields the canonical uppercase form. + char out[kMacStrLen]; + CHECK(parseMac("f4:12:fa:7d:0b:1c", mac)); + formatMac(mac, out); + CHECK(std::string(out) == "F4:12:FA:7D:0B:1C"); + CHECK(out[17] == '\0'); +} + +TEST_CASE("sensoregg_protocol - parseMac rejects malformed strings") { + uint8_t mac[6] = {1, 2, 3, 4, 5, 6}; + const uint8_t before[6] = {1, 2, 3, 4, 5, 6}; + CHECK(!parseMac(nullptr, mac)); + CHECK(!parseMac("", mac)); + CHECK(!parseMac("AA:BB:CC:DD:EE", mac)); // one byte short + CHECK(!parseMac("AA:BB:CC:DD:EE:F", mac)); // 16 chars + CHECK(!parseMac("AA:BB:CC:DD:EE:FF:", mac)); // trailing separator + CHECK(!parseMac("AA:BB:CC:DD:EE:FF00", mac)); // over-length + CHECK(!parseMac("AA-BB-CC-DD-EE-FF", mac)); // wrong separator + CHECK(!parseMac("AG:BB:CC:DD:EE:FF", mac)); // non-hex digit + // out untouched on every failure above. + CHECK(memcmp(mac, before, 6) == 0); +} + +TEST_CASE("sensoregg_protocol - macReverse is an involution") { + const uint8_t human[6] = {0xF4, 0x12, 0xFA, 0x7D, 0x0B, 0x1C}; + uint8_t lsb[6]; + uint8_t back[6]; + macReverse(human, lsb); + CHECK(lsb[0] == 0x1C); + CHECK(lsb[5] == 0xF4); + macReverse(lsb, back); + CHECK(memcmp(back, human, 6) == 0); + + // Aliasing-safe: in-place reversal works. + uint8_t inPlace[6] = {0xF4, 0x12, 0xFA, 0x7D, 0x0B, 0x1C}; + macReverse(inPlace, inPlace); + CHECK(memcmp(inPlace, lsb, 6) == 0); +} + +TEST_CASE("sensoregg_protocol - wildcard semantics") { + const uint8_t zeros[6] = {0, 0, 0, 0, 0, 0}; + const uint8_t one[6] = {0, 0, 0, 0, 0, 1}; + CHECK(macIsWildcard(zeros)); + CHECK(!macIsWildcard(one)); + + // The all-zeros STRING parses fine but yields the wildcard — the + // module must treat that as unpaired, same as an empty setting. + uint8_t parsed[6]; + CHECK(parseMac("00:00:00:00:00:00", parsed)); + CHECK(macIsWildcard(parsed)); +} + +TEST_CASE("sensoregg_protocol - macAccepts pins the LSB-first convention") { + const uint8_t zeros[6] = {0, 0, 0, 0, 0, 0}; + const uint8_t filterHuman[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + // The radio reports the same device's address LSB-first. + const uint8_t peerLsb[6] = {0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA}; + const uint8_t wrongPeer[6] = {0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAB}; + CHECK(macAccepts(zeros, peerLsb)); // wildcard accepts anyone + CHECK(macAccepts(filterHuman, peerLsb)); + CHECK(!macAccepts(filterHuman, wrongPeer)); + // A same-order (human vs human) compare must REJECT — this is the + // exact bug class the reversal exists to prevent. + CHECK(!macAccepts(filterHuman, filterHuman)); +}