From b4a5e853c60366acf72cc2c5374c7e7e75662d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:42:01 +0000 Subject: [PATCH 1/7] plan 0019: remote transfer with a paired PIN Design record for starting a Bluetooth transfer from the app while the logger idles on its main menu, gated by a challenge-response on the existing bluetooth_pin, with the camera always taking priority and an on-device PIN view for soldered-SD units. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- docs/plans/0019-remote-transfer-pin.md | 154 +++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/plans/0019-remote-transfer-pin.md diff --git a/docs/plans/0019-remote-transfer-pin.md b/docs/plans/0019-remote-transfer-pin.md new file mode 100644 index 0000000..684339e --- /dev/null +++ b/docs/plans/0019-remote-transfer-pin.md @@ -0,0 +1,154 @@ +# 0019 — Remote transfer with a paired PIN + +Status: **in progress** (logger side). Companion plans: DovesDataViewer +`docs/plans/0031-remote-transfer-pin.md`; LapWing implements the same +handshake in `loggers/doveslogger/` (no plan folder there). + +## Goal + +Let the webapp (and LapWing) start a Bluetooth transfer while the logger sits +on its **main menu**, without anyone pressing Transfer → Bluetooth — gated by +the `bluetooth_pin` every logger has generated since first boot but that +nothing ever checked. Not a wake-from-sleep: System OFF has no radio, so the +logger must already be awake on the menu. + +The SD card is soldered in on current hardware, so "pull the card and read +`SETTINGS.json`" is gone as a recovery path. The PIN has to be viewable on +the device, and recoverable when lost, without being shown on the transfer +screen all the time. + +## Where things stood + +- `bluetooth_pin` came from `random(1000, 10000)` seeded with `micros()` — + not a strong random number. +- No code read it. The file-service characteristics are `SECMODE_OPEN` and + pairing is Just Works with MITM off. +- `SLIST` / `SGET:bluetooth_pin` handed the PIN to whoever was connected, and + `SRESET` regenerated it remotely. **A PIN gate is meaningless while the + protocol reads the PIN out to an unauthenticated peer**, so redaction is + step one. +- The only access control was standing next to the device and pressing the + button. + +## Two session modes + +- **Open (local start)** — Transfer → Bluetooth on the device. Unchanged: + every command works. This is the *pairing moment*: the app reads the PIN + with `PINGET` and stores it. +- **Locked (remote start)** — the logger advertises 0x1820 from the main + menu. A connected peer may only send `AUTH?`, `AUTH:` and `BATT` (DIS + reads stay available). Everything else answers `AUTH:REQUIRED`. `AUTH:OK` + switches the logger into the normal transfer page and code path, labelled + as a remote session. + +## Handshake (challenge-response) + +``` +app → AUTH? +log → AUTH:NONCE:<32 hex> (locked) | AUTH:OPEN (open) + | AUTH:LOCKED: | AUTH:CAMERA +app → AUTH: +log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE +``` + +- The PIN never crosses the air; a recorded exchange can't be replayed + because the nonce is single-use (consumed on any answer, right or wrong) + and a new `AUTH?` discards the previous one. +- Nonce = 16 bytes from the SoftDevice RNG (`sd_rand_application_vector_get`). +- `bluetooth_name` in the MAC binds the answer to this logger. +- Lowercase hex on the wire; the logger compares case-insensitively and in + constant time. + +### Other wire changes + +| App sends | Logger replies | When | +|---|---|---| +| `PINGET` | `PIN:` / `AUTH:REQUIRED` | Open mode only | +| `SLIST` | as before, **minus `bluetooth_pin`** | All modes | +| `SGET:bluetooth_pin` | `SERR:PROTECTED` | All modes — use `PINGET` | +| `SSET:bluetooth_pin=…`, `SRESET` | as before | Open, or locked after `AUTH:OK`. PIN must be exactly 4 digits (`SERR:BAD_VALUE`) | +| (unsolicited) | `AUTH:CAMERA` | Right before the logger drops a link because the camera needs the radio | + +## Logger rules + +- **When it advertises:** `PAGE_MAIN_MENU`, radio owner `NONE`, camera + inactive, no race session, `remote_transfer` setting on. Leaving the menu + stops the advert. Auto-race and the 5-minute menu idle shutdown are + unchanged, so standby lasts exactly as long as the menu does. +- **Squatting:** a peer without `AUTH:OK` after 20 s is disconnected. +- **Guessing:** 5 wrong answers lock auth for 60 s, doubling per lockout up + to 15 min. RAM-only — a reboot clears it, which needs physical access. +- **PIN generation** moves to the hardware RNG. + +## The camera always wins + +One peripheral link is shared by the transfer service and the camera remote +(`bleOwner`). Remote transfer only ever gets it when the camera has no use +for it. + +- "Camera active" = FSM anywhere except `kUnpaired`/`kIdle`, OR the camera + test page open, OR `bleOwner == CAMERA`. No standby advert while active. +- Camera turns active during standby → advert stops that iteration; an + unauthenticated peer gets `AUTH:CAMERA` and is dropped. +- Engine starts during a **remote** transfer with a paired camera → RPM above + 500 (the camera wake threshold) ends the session (`AUTH:CAMERA`, then the + usual reboot). The transfer branch parks `loop()`, so the remote branch + runs `TACH_LOOP()` to see it. +- Remote start **never** calls `CAMERA_FORCE_RELEASE()`. Only a person at + the logger choosing Transfer → Bluetooth bumps the camera (today's rule). +- Consequence: after a camera session the camera stays in `WATCHING` until + shutdown, so remote transfer is unavailable until the next power-up. + +## Seeing and recovering the PIN + +Never on the transfer screen. Every path needs the device in hand. + +- **Transfer → PIN** (new row between USB and Back): hold Select 3 s to + reveal for 15 s, then it hides. A **New PIN** row (hold-to-confirm) + regenerates it; apps forget the old one on their next `AUTH:FAIL`. +- **Local Bluetooth transfer:** the app re-learns it via `PINGET`. +- **USB drive:** Transfer → USB exposes `SETTINGS.json`. + +## Mixed versions + +- New app, old firmware: no reply to `AUTH?` in 2 s → legacy, local start only. +- Old app, new firmware: local start works; remote start answers + `AUTH:REQUIRED`; the settings screen no longer shows the PIN. + +## What the PIN does and doesn't protect + +It keeps the pits out (weeks of over-the-air guessing with the lockout). It +does **not** stop a sniffer: the link is unencrypted Just Works, so data +after `AUTH:OK` is readable and one captured handshake brute-forces offline +instantly (6 digits would not change that). Real protection needs an +encrypted, bonded link (LE Secure Connections) — a separate project. + +## Decisions + +- **4-digit PIN** (Dove, 2026-10-07): existing loggers keep their PIN; 6 + digits does not change the sniffer case. +- **`remote_transfer` setting, default on** (`on`/`off`; anything but an + explicit `off` means on). + +## Touch points + +- Pure units + host tests: `sha256` (FIPS 180-4 + RFC 4231 HMAC vectors), + `remote_auth` (nonce/answer/lockout/timeout state machine, command gating, + protected-key and PIN-format rules, standby/camera predicates). +- `bluetooth.ino`: AUTH dispatch, locked-mode gate, settings redaction, + standby advert + teardown, remote session promotion. +- `settings.ino`: hardware-RNG PIN, `remote_transfer` default. +- `BirdsEye.ino`: standby step in `loop()`, remote parked-branch camera check. +- `display_ui.ino` / `display_pages.ino`: PIN page. +- `camera_ble.{h,ino}`: camera-active accessor (+ sim stub). +- Docs: `CLAUDE.md`, `ARCHITECTURE.md`, `CHANGELOG.md`. + +## Status + +- [ ] Plan committed +- [ ] PIN redaction, `PINGET`, write gates, hardware RNG +- [ ] Auth pure units + tests +- [ ] Standby advertising, locked mode, camera priority +- [ ] PIN page +- [ ] Docs + changelog From 1520aa99cbf4a89212db512deac6a563c860437d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:45:22 +0000 Subject: [PATCH 2/7] plan 0019: SHA-256/HMAC and remote-auth pure units The PIN handshake has to agree byte-for-byte with WebCrypto in the viewer and the hmac/sha2 crates in LapWing, so the primitive is pinned to FIPS 180-4 and RFC 4231 vectors, and the answer to a golden vector shared with both apps. The nonce, lockout, command gating, protected-key and camera priority rules live in remote_auth so the glue only owns the radio. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- .github/workflows/clang-tidy.yml | 4 +- BirdsEye/remote_auth.cpp | 177 ++++++++++++++++++++++ BirdsEye/remote_auth.h | 150 +++++++++++++++++++ BirdsEye/sha256.cpp | 146 ++++++++++++++++++ BirdsEye/sha256.h | 42 ++++++ BirdsEye/sim/CMakeLists.txt | 2 + tests/CMakeLists.txt | 4 + tests/remote_auth_test.cpp | 244 +++++++++++++++++++++++++++++++ tests/sha256_test.cpp | 77 ++++++++++ 9 files changed, 845 insertions(+), 1 deletion(-) create mode 100644 BirdsEye/remote_auth.cpp create mode 100644 BirdsEye/remote_auth.h create mode 100644 BirdsEye/sha256.cpp create mode 100644 BirdsEye/sha256.h create mode 100644 tests/remote_auth_test.cpp create mode 100644 tests/sha256_test.cpp diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 92087d5..9099ced 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -50,4 +50,6 @@ jobs: BirdsEye/local_time.cpp \ BirdsEye/setting_parse.cpp \ BirdsEye/ble_stream.cpp \ - BirdsEye/loop_profile.cpp + BirdsEye/loop_profile.cpp \ + BirdsEye/sha256.cpp \ + BirdsEye/remote_auth.cpp diff --git a/BirdsEye/remote_auth.cpp b/BirdsEye/remote_auth.cpp new file mode 100644 index 0000000..ccbac9a --- /dev/null +++ b/BirdsEye/remote_auth.cpp @@ -0,0 +1,177 @@ +#include "remote_auth.h" + +#include + +#include "sha256.h" + +namespace remote_auth { + +namespace { + +const char kHexDigits[] = "0123456789abcdef"; + +inline char lowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; } + +inline bool isHex(char c) { + c = lowerAscii(c); + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); +} + +bool startsWith(const char* s, const char* prefix) { + return strncmp(s, prefix, strlen(prefix)) == 0; +} + +} // namespace + +bool isValidPin(const char* pin) { + if (pin == nullptr) return false; + for (size_t i = 0; i < kPinLen; ++i) { + if (pin[i] < '0' || pin[i] > '9') return false; + } + return pin[kPinLen] == '\0'; +} + +void pinFromRandom(uint32_t r, char* out) { + uint32_t v = 1000u + (r % 9000u); + for (int i = int(kPinLen) - 1; i >= 0; --i) { + out[i] = char('0' + (v % 10u)); + v /= 10u; + } + out[kPinLen] = '\0'; +} + +void toHex(const uint8_t* bytes, size_t len, char* out) { + for (size_t i = 0; i < len; ++i) { + out[i * 2] = kHexDigits[bytes[i] >> 4]; + out[i * 2 + 1] = kHexDigits[bytes[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +void computeAnswer(const char* pin, const char* nonceHex, const char* deviceName, + char* out) { + // "BEAUTH1|" + 32 + "|" + name. Names are capped well below this by the + // settings and advert limits; truncating would only produce a mismatch. + char msg[8 + kNonceHexLen + 1 + 64 + 1]; + size_t n = 0; + const char* parts[] = {"BEAUTH1|", nonceHex, "|", deviceName}; + for (const char* part : parts) { + for (size_t i = 0; part[i] != '\0' && n < sizeof(msg) - 1; ++i) msg[n++] = part[i]; + } + msg[n] = '\0'; + + uint8_t mac[sha256::kDigestLen]; + sha256::hmac(pin, strlen(pin), msg, n, mac); + toHex(mac, kAnswerHexLen / 2, out); +} + +bool hexEqualConstTime(const char* a, const char* b, size_t n) { + if (a == nullptr || b == nullptr) return false; + if (strlen(a) != n || strlen(b) != n) return false; + uint8_t diff = 0; + for (size_t i = 0; i < n; ++i) { + diff |= uint8_t(lowerAscii(a[i]) ^ lowerAscii(b[i])); + } + return diff == 0; +} + +uint32_t lockoutLengthMs(uint8_t lockoutsSoFar) { + uint32_t len = kLockoutBaseMs; + for (uint8_t i = 0; i < lockoutsSoFar && len < kLockoutMaxMs; ++i) len *= 2u; + return len > kLockoutMaxMs ? kLockoutMaxMs : len; +} + +bool isLocked(State& s, uint32_t nowMs) { + if (!s.lockActive) return false; + if (uint32_t(nowMs - s.lockStartMs) >= s.lockLenMs) { + s.lockActive = false; + s.fails = 0; + return false; + } + return true; +} + +uint32_t lockSecondsLeft(State& s, uint32_t nowMs) { + if (!isLocked(s, nowMs)) return 0; + const uint32_t left = s.lockLenMs - uint32_t(nowMs - s.lockStartMs); + return (left + 999u) / 1000u; +} + +void issueNonce(State& s, const uint8_t* bytes) { + toHex(bytes, kNonceLen, s.nonceHex); + s.nonceValid = true; +} + +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, + const char* deviceName, uint32_t nowMs) { + if (isLocked(s, nowMs)) { + s.nonceValid = false; + return Verdict::kLocked; + } + if (!s.nonceValid) return Verdict::kNoNonce; + + // Single use, right or wrong. + s.nonceValid = false; + + bool ok = false; + if (isValidPin(pin) && answerHex != nullptr && strlen(answerHex) == kAnswerHexLen) { + bool wellFormed = true; + for (size_t i = 0; i < kAnswerHexLen; ++i) wellFormed = wellFormed && isHex(answerHex[i]); + if (wellFormed) { + char expected[kAnswerHexLen + 1]; + computeAnswer(pin, s.nonceHex, deviceName, expected); + ok = hexEqualConstTime(answerHex, expected, kAnswerHexLen); + } + } + memset(s.nonceHex, 0, sizeof(s.nonceHex)); + + if (ok) { + s.authed = true; + s.fails = 0; + return Verdict::kOk; + } + + if (s.fails < kMaxFails) ++s.fails; + if (s.fails >= kMaxFails) { + s.lockActive = true; + s.lockStartMs = nowMs; + s.lockLenMs = lockoutLengthMs(s.lockouts); + if (s.lockouts < 0xff) ++s.lockouts; + return Verdict::kLocked; + } + return Verdict::kFail; +} + +void onDisconnect(State& s) { + s.authed = false; + s.nonceValid = false; + memset(s.nonceHex, 0, sizeof(s.nonceHex)); +} + +bool commandAllowed(Mode mode, bool authed, const char* cmd) { + if (cmd == nullptr) return false; + const bool isPinGet = strcmp(cmd, "PINGET") == 0; + if (mode == Mode::kOpen) return true; + if (isPinGet) return false; + if (authed) return true; + return strcmp(cmd, "AUTH?") == 0 || startsWith(cmd, "AUTH:") || strcmp(cmd, "BATT") == 0; +} + +bool isProtectedSettingKey(const char* key) { + return key != nullptr && strcmp(key, "bluetooth_pin") == 0; +} + +bool standbyWanted(const StandbyInputs& in) { + return in.settingEnabled && in.onMainMenu && in.radioFree && !in.cameraBusy && + !in.raceActive; +} + +bool cameraBusy(bool fsmIdleOrUnpaired, bool testPageOpen, bool ownsRadio) { + return !fsmIdleOrUnpaired || testPageOpen || ownsRadio; +} + +bool cameraEndsRemoteSession(bool cameraBusyNow, bool cameraPaired, uint16_t rpm) { + return cameraBusyNow || (cameraPaired && rpm > kCameraWakeRpm); +} + +} // namespace remote_auth diff --git a/BirdsEye/remote_auth.h b/BirdsEye/remote_auth.h new file mode 100644 index 0000000..c31c58f --- /dev/null +++ b/BirdsEye/remote_auth.h @@ -0,0 +1,150 @@ +#pragma once + +#include +#include + +/////////////////////////////////////////// +// REMOTE TRANSFER AUTH (plan 0019) +// +// Pure, Arduino-free rules for the PIN-gated remote transfer: the +// challenge-response answer, the nonce + lockout state machine, which +// commands a not-yet-authenticated peer may send, which settings are +// protected, and when the camera takes the radio back. bluetooth.ino owns +// only the radio and the clock; every decision lives here so it is +// host-tested. +// +// THE ANSWER (shared byte-for-byte with DovesDataViewer and LapWing): +// mac = HMAC-SHA256(key = PIN as ASCII, +// msg = "BEAUTH1|" + nonceHex + "|" + bluetooth_name) +// answer = lowercase hex of the first 16 bytes of mac (32 chars) +// The nonce is 16 random bytes, sent as 32 lowercase hex chars. +// +// What this does NOT protect against: the link is unencrypted Just Works, +// so a sniffer that records one handshake can brute-force a 4-digit PIN +// offline. The PIN keeps other people in the pits out; see the plan. +/////////////////////////////////////////// + +namespace remote_auth { + +constexpr size_t kNonceLen = 16; +constexpr size_t kNonceHexLen = kNonceLen * 2; +constexpr size_t kAnswerHexLen = 32; +constexpr size_t kPinLen = 4; + +// Wrong answers allowed before a lockout. +constexpr uint8_t kMaxFails = 5; +// First lockout; each further lockout doubles it, up to kLockoutMaxMs. +constexpr uint32_t kLockoutBaseMs = 60000; +constexpr uint32_t kLockoutMaxMs = 15UL * 60UL * 1000UL; +// A locked-mode peer that hasn't authenticated by now is dropped, so a +// stranger can't squat on the only peripheral connection slot. +constexpr uint32_t kAuthTimeoutMs = 20000; +// Camera wake threshold (camera_fsm kRpmOnThreshold). A remote transfer +// ends above it when a camera is paired, so the camera can wake. +constexpr uint16_t kCameraWakeRpm = 500; + +// Exactly kPinLen ASCII digits, nothing else. +bool isValidPin(const char* pin); + +// 32-bit random -> a 4-digit PIN in 1000-9999 (the range every logger has +// used since first boot). `out` holds at least kPinLen + 1 bytes. +void pinFromRandom(uint32_t r, char* out); + +// Lowercase hex of `len` bytes into `out` (2*len + 1 bytes). +void toHex(const uint8_t* bytes, size_t len, char* out); + +// The expected answer for (pin, nonceHex, deviceName) into `out` +// (kAnswerHexLen + 1 bytes). +void computeAnswer(const char* pin, const char* nonceHex, const char* deviceName, + char* out); + +// Case-insensitive compare of two hex strings of exactly `n` chars, in time +// independent of where they differ. False if either is shorter than `n` or +// longer than `n`. +bool hexEqualConstTime(const char* a, const char* b, size_t n); + +// Per-boot auth state (RAM only — a reboot clears the lockout, which needs +// the device in hand anyway). +struct State { + bool nonceValid = false; + char nonceHex[kNonceHexLen + 1] = {0}; + uint8_t fails = 0; // wrong answers since the last lockout + uint8_t lockouts = 0; // lockouts so far this boot (sets the length) + bool lockActive = false; + uint32_t lockStartMs = 0; + uint32_t lockLenMs = 0; + bool authed = false; // AUTH:OK seen on the current connection +}; + +// Length of the next lockout given how many have already happened. +uint32_t lockoutLengthMs(uint8_t lockoutsSoFar); + +// True while a lockout is running. Clears an expired lockout (and the fail +// count that caused it) as a side effect. Wrap-safe on nowMs. +bool isLocked(State& s, uint32_t nowMs); + +// Whole seconds left in the current lockout, rounded up; 0 if none. +uint32_t lockSecondsLeft(State& s, uint32_t nowMs); + +// Wrong answers left before a lockout. +inline uint8_t triesLeft(const State& s) { + return s.fails >= kMaxFails ? 0 : uint8_t(kMaxFails - s.fails); +} + +// Install a fresh nonce from `bytes` (kNonceLen random bytes). Any previous +// nonce is discarded. +void issueNonce(State& s, const uint8_t* bytes); + +enum class Verdict : uint8_t { + kOk, // AUTH:OK + kFail, // AUTH:FAIL: + kLocked, // AUTH:LOCKED: (already locked, or this fail locked it) + kNoNonce, // AUTH:NO_NONCE — no AUTH? since the last answer +}; + +// Check `answerHex` against the expected answer for the current nonce. The +// nonce is consumed on every verdict except kLocked-before-checking and +// kNoNonce. A malformed answer counts as a wrong one. +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, + const char* deviceName, uint32_t nowMs); + +// Reset per-connection state (authed flag, nonce). The lockout survives. +void onDisconnect(State& s); + +// Session modes. Open = someone started transfer on the device; locked = +// the logger is advertising from its main menu. +enum class Mode : uint8_t { kOpen, kLocked }; + +// May this command run? `cmd` is the trimmed text written to the request +// characteristic. Open: everything. Locked before AUTH:OK: only AUTH?, +// AUTH:<…> and BATT. PINGET is open-mode only — it is how an app learns the +// PIN, so it is never available to a peer that started remotely. +bool commandAllowed(Mode mode, bool authed, const char* cmd); + +// Settings that SLIST must omit and SGET must refuse (SERR:PROTECTED). +bool isProtectedSettingKey(const char* key); + +// When the menu standby advert should be up. +struct StandbyInputs { + bool onMainMenu = false; + bool radioFree = false; // bleOwner == NONE + bool cameraBusy = false; // see cameraBusy() + bool raceActive = false; + bool settingEnabled = false; // remote_transfer setting +}; +bool standbyWanted(const StandbyInputs& in); + +// The camera "has a use for the radio": its state machine is anywhere but +// UNPAIRED/IDLE, the camera bench page is open, or it owns the radio. +bool cameraBusy(bool fsmIdleOrUnpaired, bool testPageOpen, bool ownsRadio); + +// A REMOTE transfer session must hand the radio back now: the camera is +// busy, or a paired camera's engine just passed the wake threshold. +bool cameraEndsRemoteSession(bool cameraBusyNow, bool cameraPaired, uint16_t rpm); + +// The 20 s squatting limit, wrap-safe. +inline bool authTimedOut(uint32_t connectedAtMs, uint32_t nowMs) { + return uint32_t(nowMs - connectedAtMs) >= kAuthTimeoutMs; +} + +} // namespace remote_auth diff --git a/BirdsEye/sha256.cpp b/BirdsEye/sha256.cpp new file mode 100644 index 0000000..10248bf --- /dev/null +++ b/BirdsEye/sha256.cpp @@ -0,0 +1,146 @@ +#include "sha256.h" + +#include + +namespace sha256 { + +namespace { + +const uint32_t kK[64] = { + 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, + 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u, 0xd807aa98u, 0x12835b01u, + 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, + 0xc19bf174u, 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, + 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau, 0x983e5152u, + 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, + 0x06ca6351u, 0x14292967u, 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, + 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u, + 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, + 0xd6990624u, 0xf40e3585u, 0x106aa070u, 0x19a4c116u, 0x1e376c08u, + 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, + 0x682e6ff3u, 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, + 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u}; + +inline uint32_t rotr(uint32_t x, unsigned n) { return (x >> n) | (x << (32 - n)); } + +void compress(Ctx& ctx, const uint8_t* p) { + uint32_t w[64]; + for (size_t i = 0; i < 16; ++i) { + w[i] = (uint32_t(p[i * 4]) << 24) | (uint32_t(p[i * 4 + 1]) << 16) | + (uint32_t(p[i * 4 + 2]) << 8) | uint32_t(p[i * 4 + 3]); + } + for (int i = 16; i < 64; ++i) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + uint32_t a = ctx.h[0], b = ctx.h[1], c = ctx.h[2], d = ctx.h[3]; + uint32_t e = ctx.h[4], f = ctx.h[5], g = ctx.h[6], h = ctx.h[7]; + for (int i = 0; i < 64; ++i) { + const uint32_t s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const uint32_t ch = (e & f) ^ (~e & g); + const uint32_t t1 = h + s1 + ch + kK[i] + w[i]; + const uint32_t s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const uint32_t t2 = s0 + maj; + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + ctx.h[0] += a; + ctx.h[1] += b; + ctx.h[2] += c; + ctx.h[3] += d; + ctx.h[4] += e; + ctx.h[5] += f; + ctx.h[6] += g; + ctx.h[7] += h; +} + +} // namespace + +void init(Ctx& ctx) { + static const uint32_t kInit[8] = {0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au, + 0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u}; + memcpy(ctx.h, kInit, sizeof(kInit)); + ctx.bitLen = 0; + ctx.blockLen = 0; +} + +void update(Ctx& ctx, const void* data, size_t len) { + const uint8_t* p = static_cast(data); + ctx.bitLen += uint64_t(len) * 8u; + while (len > 0) { + size_t take = kBlockLen - ctx.blockLen; + if (take > len) take = len; + memcpy(ctx.block + ctx.blockLen, p, take); + ctx.blockLen += take; + p += take; + len -= take; + if (ctx.blockLen == kBlockLen) { + compress(ctx, ctx.block); + ctx.blockLen = 0; + } + } +} + +void finish(Ctx& ctx, uint8_t out[kDigestLen]) { + const uint64_t bitLen = ctx.bitLen; + ctx.block[ctx.blockLen++] = 0x80; + if (ctx.blockLen > kBlockLen - 8) { + memset(ctx.block + ctx.blockLen, 0, kBlockLen - ctx.blockLen); + compress(ctx, ctx.block); + ctx.blockLen = 0; + } + memset(ctx.block + ctx.blockLen, 0, kBlockLen - 8 - ctx.blockLen); + for (int i = 0; i < 8; ++i) { + ctx.block[kBlockLen - 1 - i] = uint8_t(bitLen >> (8 * i)); + } + compress(ctx, ctx.block); + for (size_t i = 0; i < 8; ++i) { + out[i * 4] = uint8_t(ctx.h[i] >> 24); + out[i * 4 + 1] = uint8_t(ctx.h[i] >> 16); + out[i * 4 + 2] = uint8_t(ctx.h[i] >> 8); + out[i * 4 + 3] = uint8_t(ctx.h[i]); + } +} + +void hash(const void* data, size_t len, uint8_t out[kDigestLen]) { + Ctx ctx; + init(ctx); + update(ctx, data, len); + finish(ctx, out); +} + +void hmac(const void* key, size_t keyLen, const void* msg, size_t msgLen, + uint8_t out[kDigestLen]) { + uint8_t k[kBlockLen]; + memset(k, 0, sizeof(k)); + if (keyLen > kBlockLen) { + hash(key, keyLen, k); + } else if (keyLen > 0) { + memcpy(k, key, keyLen); + } + + uint8_t pad[kBlockLen]; + for (size_t i = 0; i < kBlockLen; ++i) pad[i] = k[i] ^ 0x36u; + uint8_t inner[kDigestLen]; + Ctx ctx; + init(ctx); + update(ctx, pad, kBlockLen); + update(ctx, msg, msgLen); + finish(ctx, inner); + + for (size_t i = 0; i < kBlockLen; ++i) pad[i] = k[i] ^ 0x5cu; + init(ctx); + update(ctx, pad, kBlockLen); + update(ctx, inner, kDigestLen); + finish(ctx, out); +} + +} // namespace sha256 diff --git a/BirdsEye/sha256.h b/BirdsEye/sha256.h new file mode 100644 index 0000000..d7fc32a --- /dev/null +++ b/BirdsEye/sha256.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +/////////////////////////////////////////// +// SHA-256 + HMAC-SHA256 (FIPS 180-4, RFC 2104) +// +// Pure, Arduino-free. Exists for the remote-transfer PIN handshake +// (plan 0019): the logger and both companion apps (WebCrypto in the +// viewer, the hmac/sha2 crates in LapWing) must agree on the answer +// byte-for-byte, so this unit is pinned to the FIPS 180-4 and RFC 4231 +// test vectors in tests/sha256_test.cpp. +// +// Small and slow on purpose: it runs once per authentication attempt, +// never in a hot path, so code size wins over speed. +/////////////////////////////////////////// + +namespace sha256 { + +constexpr size_t kDigestLen = 32; +constexpr size_t kBlockLen = 64; + +struct Ctx { + uint32_t h[8]; + uint64_t bitLen; + uint8_t block[kBlockLen]; + size_t blockLen; +}; + +void init(Ctx& ctx); +void update(Ctx& ctx, const void* data, size_t len); +void finish(Ctx& ctx, uint8_t out[kDigestLen]); + +// One-shot digest. +void hash(const void* data, size_t len, uint8_t out[kDigestLen]); + +// HMAC-SHA256. Keys longer than one block are hashed first, per RFC 2104. +void hmac(const void* key, size_t keyLen, const void* msg, size_t msgLen, + uint8_t out[kDigestLen]); + +} // namespace sha256 diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 4b9fbd3..0696e37 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -160,9 +160,11 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sd_access_policy.cpp ${BIRDSEYE_DIR}/sd_format_page.cpp + ${BIRDSEYE_DIR}/remote_auth.cpp ${BIRDSEYE_DIR}/sd_probe.cpp ${BIRDSEYE_DIR}/sector_purple.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp + ${BIRDSEYE_DIR}/sha256.cpp ${BIRDSEYE_DIR}/sprint_select.cpp ${BIRDSEYE_DIR}/tach_filter.cpp ${BIRDSEYE_DIR}/track_json.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4db3cf8..182a1b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -47,7 +47,11 @@ add_executable(birdseye_tests loop_profile_test.cpp drag_timer_test.cpp drag_tree_test.cpp + sha256_test.cpp + remote_auth_test.cpp ${BIRDSEYE_DIR}/haversine.cpp + ${BIRDSEYE_DIR}/sha256.cpp + ${BIRDSEYE_DIR}/remote_auth.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp ${BIRDSEYE_DIR}/gps_validation.cpp diff --git a/tests/remote_auth_test.cpp b/tests/remote_auth_test.cpp new file mode 100644 index 0000000..5ffc90d --- /dev/null +++ b/tests/remote_auth_test.cpp @@ -0,0 +1,244 @@ +#include "doctest.h" + +#include + +#include "remote_auth.h" + +using namespace remote_auth; + +namespace { + +const uint8_t kNonceA[kNonceLen] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}; + +// The answer for kNonceA / "4821" / "DovesDataLogger-042". The SAME vector is +// pinned in DovesDataViewer (src/lib/ble/auth.test.ts) and LapWing +// (loggers/doveslogger/auth.rs) — change all three together or none. +const char kGoldenAnswer[] = "2cc61d4ff111ba3236b50217443c3daa"; + +void answerFor(const State& s, const char* pin, char* out) { + computeAnswer(pin, s.nonceHex, "DovesDataLogger-042", out); +} + +} // namespace + +TEST_CASE("answer matches the cross-implementation golden vectors") { + char out[kAnswerHexLen + 1]; + computeAnswer("4821", "00112233445566778899aabbccddeeff", "DovesDataLogger-042", out); + CHECK(std::strcmp(out, kGoldenAnswer) == 0); + computeAnswer("1000", "ffffffffffffffffffffffffffffffff", "BirdsEye", out); + CHECK(std::strcmp(out, "1918b8ddb0591dd12fd31eb2ef04ba33") == 0); +} + +TEST_CASE("PIN format") { + CHECK(isValidPin("4821")); + CHECK(isValidPin("0000")); + CHECK_FALSE(isValidPin("482")); + CHECK_FALSE(isValidPin("48210")); + CHECK_FALSE(isValidPin("48a1")); + CHECK_FALSE(isValidPin(" 482")); + CHECK_FALSE(isValidPin("")); + CHECK_FALSE(isValidPin(nullptr)); +} + +TEST_CASE("pinFromRandom stays in 1000-9999") { + char pin[kPinLen + 1]; + pinFromRandom(0, pin); + CHECK(std::strcmp(pin, "1000") == 0); + pinFromRandom(8999, pin); + CHECK(std::strcmp(pin, "9999") == 0); + pinFromRandom(9000, pin); + CHECK(std::strcmp(pin, "1000") == 0); + pinFromRandom(0xffffffffu, pin); + CHECK(isValidPin(pin)); + CHECK(pin[0] != '0'); +} + +TEST_CASE("constant-time hex compare is case-insensitive and length-strict") { + CHECK(hexEqualConstTime("abcd", "ABCD", 4)); + CHECK_FALSE(hexEqualConstTime("abce", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime("abc", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime("abcde", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime(nullptr, "abcd", 4)); +} + +TEST_CASE("right answer authenticates and consumes the nonce") { + State s; + issueNonce(s, kNonceA); + CHECK(std::strcmp(s.nonceHex, "00112233445566778899aabbccddeeff") == 0); + CHECK(checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0) == Verdict::kOk); + CHECK(s.authed); + // Replay of the same answer: the nonce is gone. + CHECK(checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0) == Verdict::kNoNonce); +} + +TEST_CASE("uppercase answer is accepted") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "2CC61D4FF111BA3236B50217443C3DAA", "4821", "DovesDataLogger-042", 0) == + Verdict::kOk); +} + +TEST_CASE("answer is bound to the device name") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, kGoldenAnswer, "4821", "SomeOtherLogger", 0) == Verdict::kFail); +} + +TEST_CASE("wrong answer consumes the nonce and counts down") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", 0) == Verdict::kFail); + CHECK(triesLeft(s) == kMaxFails - 1); + CHECK_FALSE(s.nonceValid); + CHECK_FALSE(s.authed); +} + +TEST_CASE("malformed answers count as wrong") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "short", "4821", "x", 0) == Verdict::kFail); + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "zzc61d4ff111ba3236b50217443c3daa", "4821", "x", 0) == Verdict::kFail); + CHECK(triesLeft(s) == kMaxFails - 2); +} + +TEST_CASE("a stored PIN that is not 4 digits never authenticates") { + State s; + issueNonce(s, kNonceA); + char ans[kAnswerHexLen + 1]; + computeAnswer("", s.nonceHex, "x", ans); + CHECK(checkAnswer(s, ans, "", "x", 0) == Verdict::kFail); +} + +TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { + State s; + uint32_t now = 1000; + for (uint8_t i = 0; i + 1 < kMaxFails; ++i) { + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now) == Verdict::kFail); + } + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now) == Verdict::kLocked); + CHECK(isLocked(s, now)); + CHECK(lockSecondsLeft(s, now) == 60); + CHECK(lockSecondsLeft(s, now + 59001) == 1); + + // Even the right answer is refused while locked, and the nonce is burnt. + issueNonce(s, kNonceA); + char ans[kAnswerHexLen + 1]; + answerFor(s, "4821", ans); + CHECK(checkAnswer(s, ans, "4821", "DovesDataLogger-042", now + 1000) == Verdict::kLocked); + CHECK_FALSE(s.nonceValid); + + now += 60000; + CHECK_FALSE(isLocked(s, now)); + CHECK(triesLeft(s) == kMaxFails); + + CHECK(lockoutLengthMs(0) == 60000); + CHECK(lockoutLengthMs(1) == 120000); + CHECK(lockoutLengthMs(3) == 480000); + CHECK(lockoutLengthMs(4) == kLockoutMaxMs); + CHECK(lockoutLengthMs(200) == kLockoutMaxMs); +} + +TEST_CASE("lockout survives millis() wrap") { + State s; + const uint32_t now = 0xffffffffu - 10000u; + for (uint8_t i = 0; i < kMaxFails; ++i) { + issueNonce(s, kNonceA); + checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now); + } + CHECK(isLocked(s, now + 30000u)); // wrapped past zero, still inside 60 s + CHECK_FALSE(isLocked(s, now + 60000u)); +} + +TEST_CASE("disconnect clears auth but not the lockout") { + State s; + issueNonce(s, kNonceA); + checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0); + onDisconnect(s); + CHECK_FALSE(s.authed); + CHECK_FALSE(s.nonceValid); + + for (uint8_t i = 0; i < kMaxFails; ++i) { + issueNonce(s, kNonceA); + checkAnswer(s, "00000000000000000000000000000000", "4821", "x", 0); + } + onDisconnect(s); + CHECK(isLocked(s, 1)); +} + +TEST_CASE("command gating") { + // Open (local start): everything, including PINGET. + CHECK(commandAllowed(Mode::kOpen, false, "LIST")); + CHECK(commandAllowed(Mode::kOpen, false, "PINGET")); + CHECK(commandAllowed(Mode::kOpen, false, "SRESET")); + + // Locked before auth: only the handshake and battery. + CHECK(commandAllowed(Mode::kLocked, false, "AUTH?")); + CHECK(commandAllowed(Mode::kLocked, false, "AUTH:abc")); + CHECK(commandAllowed(Mode::kLocked, false, "BATT")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "LIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "SLIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "FWDFU")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "AUTHX")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "PINGET")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, nullptr)); + + // Locked after auth: everything but PINGET. + CHECK(commandAllowed(Mode::kLocked, true, "LIST")); + CHECK(commandAllowed(Mode::kLocked, true, "SSET:bluetooth_pin=1234")); + CHECK_FALSE(commandAllowed(Mode::kLocked, true, "PINGET")); +} + +TEST_CASE("protected settings") { + CHECK(isProtectedSettingKey("bluetooth_pin")); + CHECK_FALSE(isProtectedSettingKey("bluetooth_name")); + CHECK_FALSE(isProtectedSettingKey("bluetooth_pin2")); + CHECK_FALSE(isProtectedSettingKey(nullptr)); +} + +TEST_CASE("standby advert only on a free menu with the camera idle") { + StandbyInputs in; + in.onMainMenu = true; + in.radioFree = true; + in.settingEnabled = true; + CHECK(standbyWanted(in)); + + StandbyInputs off = in; + off.settingEnabled = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.onMainMenu = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.radioFree = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.cameraBusy = true; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.raceActive = true; + CHECK_FALSE(standbyWanted(off)); +} + +TEST_CASE("the camera always wins") { + CHECK_FALSE(cameraBusy(true, false, false)); + CHECK(cameraBusy(false, false, false)); // waking / recording / watching / pairing + CHECK(cameraBusy(true, true, false)); // bench page open + CHECK(cameraBusy(true, false, true)); // owns the radio + + CHECK(cameraEndsRemoteSession(true, false, 0)); + CHECK_FALSE(cameraEndsRemoteSession(false, true, kCameraWakeRpm)); + CHECK(cameraEndsRemoteSession(false, true, kCameraWakeRpm + 1)); + // No paired camera: the engine starting doesn't matter. + CHECK_FALSE(cameraEndsRemoteSession(false, false, 9000)); +} + +TEST_CASE("auth timeout is 20 s and wrap-safe") { + CHECK_FALSE(authTimedOut(1000, 20999)); + CHECK(authTimedOut(1000, 21000)); + CHECK_FALSE(authTimedOut(0xffffff00u, 100)); + CHECK(authTimedOut(0xffffff00u, 0xffffff00u + kAuthTimeoutMs)); +} diff --git a/tests/sha256_test.cpp b/tests/sha256_test.cpp new file mode 100644 index 0000000..8b926c4 --- /dev/null +++ b/tests/sha256_test.cpp @@ -0,0 +1,77 @@ +#include "doctest.h" + +#include +#include + +#include "remote_auth.h" +#include "sha256.h" + +// The remote-transfer handshake (plan 0019) must agree byte-for-byte with +// WebCrypto in the viewer and the hmac/sha2 crates in LapWing, so the +// primitives are pinned to the published vectors. + +namespace { + +std::string hex(const uint8_t* d, size_t n) { + char buf[2 * sha256::kDigestLen + 1]; + remote_auth::toHex(d, n, buf); + return buf; +} + +std::string digestOf(const std::string& s) { + uint8_t out[sha256::kDigestLen]; + sha256::hash(s.data(), s.size(), out); + return hex(out, sizeof(out)); +} + +std::string hmacOf(const std::string& key, const std::string& msg) { + uint8_t out[sha256::kDigestLen]; + sha256::hmac(key.data(), key.size(), msg.data(), msg.size(), out); + return hex(out, sizeof(out)); +} + +} // namespace + +TEST_CASE("sha256 FIPS 180-4 vectors") { + CHECK(digestOf("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(digestOf("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + CHECK(digestOf("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") == + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); +} + +TEST_CASE("sha256 one million 'a' through incremental updates") { + sha256::Ctx ctx; + sha256::init(ctx); + const std::string chunk(1000, 'a'); + for (int i = 0; i < 1000; ++i) sha256::update(ctx, chunk.data(), chunk.size()); + uint8_t out[sha256::kDigestLen]; + sha256::finish(ctx, out); + CHECK(hex(out, sizeof(out)) == + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); +} + +TEST_CASE("sha256 padding boundaries (55/56/64 bytes)") { + // 55 bytes fits the length in the same block; 56 spills into a second. + CHECK(digestOf(std::string(55, 'a')) == + "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"); + CHECK(digestOf(std::string(56, 'a')) == + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"); + CHECK(digestOf(std::string(64, 'a')) == + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"); +} + +TEST_CASE("hmac-sha256 RFC 4231 test cases") { + // Case 1 + CHECK(hmacOf(std::string(20, '\x0b'), "Hi There") == + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); + // Case 2 + CHECK(hmacOf("Jefe", "what do ya want for nothing?") == + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"); + // Case 3 + CHECK(hmacOf(std::string(20, '\xaa'), std::string(50, '\xdd')) == + "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"); + // Case 6: key longer than one block is hashed first + CHECK(hmacOf(std::string(131, '\xaa'), + "Test Using Larger Than Block-Size Key - Hash Key First") == + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"); +} From e8290ebecbb7155b33c018357e457e9ec66ef2d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:55:43 +0000 Subject: [PATCH 3/7] plan 0019: PIN-gated remote transfer from the main menu The logger now advertises the transfer service while it idles on the main menu, locked until the app answers a one-time challenge with an HMAC of the PIN. The PIN no longer leaks through SLIST/SGET, a local start hands it to the app with PINGET, and new PINs come from the hardware RNG. The camera always wins the radio: standby stops (and a remote session ends) before a paired camera's wake debounce. Transfer gains a PIN page that reveals or replaces the PIN behind a 3 s hold, since soldered SD cards took away the old recovery path. The answer is not bound to bluetooth_name as the design artifact had it: the nonce already makes it unique, and a name truncated on air would make correct implementations disagree. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- .github/workflows/clang-tidy.yml | 3 +- ARCHITECTURE.md | 23 + BirdsEye/BirdsEye.ino | 69 +- BirdsEye/bluetooth.h | 24 + BirdsEye/bluetooth.ino | 306 ++++++- BirdsEye/camera_ble.h | 4 + BirdsEye/camera_ble.ino | 2 + BirdsEye/display_pages.h | 1 + BirdsEye/display_pages.ino | 79 +- BirdsEye/display_ui.ino | 26 +- BirdsEye/pin_page.cpp | 54 ++ BirdsEye/pin_page.h | 76 ++ BirdsEye/remote_auth.cpp | 29 +- BirdsEye/remote_auth.h | 48 +- BirdsEye/settings.h | 13 +- BirdsEye/settings.ino | 1161 +++++++++++++----------- BirdsEye/sim/CMakeLists.txt | 1 + BirdsEye/sim/golden/golden_hashes.txt | 5 +- BirdsEye/sim/golden_main.cpp | 22 +- BirdsEye/sim/sim_prototypes.h | 2 + BirdsEye/sim/stubs/module_stubs.cpp | 6 + CHANGELOG.md | 32 + CLAUDE.md | 46 +- README.md | 5 +- docs/plans/0019-remote-transfer-pin.md | 60 +- tests/CMakeLists.txt | 2 + tests/pin_page_test.cpp | 99 ++ tests/remote_auth_test.cpp | 80 +- 28 files changed, 1610 insertions(+), 668 deletions(-) create mode 100644 BirdsEye/pin_page.cpp create mode 100644 BirdsEye/pin_page.h create mode 100644 tests/pin_page_test.cpp diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 9099ced..3d6adf0 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -52,4 +52,5 @@ jobs: BirdsEye/ble_stream.cpp \ BirdsEye/loop_profile.cpp \ BirdsEye/sha256.cpp \ - BirdsEye/remote_auth.cpp + BirdsEye/remote_auth.cpp \ + BirdsEye/pin_page.cpp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd93905..a01a2d3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -439,6 +439,29 @@ moving the dates) and tzdata is ~100 KB shipped to a sealed device. `DateTime` carries a 4-digit year even though the sketch's `gpsData.year` is 2-digit. +### Remote transfer is PIN-gated, and the camera always wins + +From the main menu the logger advertises the transfer service in a +**locked** mode (plan 0019): a connected peer may run only the PIN +handshake and `BATT` until it answers a one-time 16-byte challenge with the +first half of `HMAC-SHA256(PIN, "BEAUTH1|" + nonce)`. A correct answer +promotes the link into the ordinary transfer page; a local Transfer → +Bluetooth start stays **open**, exactly as before, and is the only session +that may read the PIN back (`PINGET`), which is how an app pairs. The +lockout (5 tries, then 60 s doubling to 15 min) and the 20 s squatting limit +keep the pits out; they do not stop a sniffer, because the link is +unencrypted Just Works — a captured handshake brute-forces a 4-digit PIN +offline. That is accepted and written down in the plan. + +There is one peripheral link, shared with the Insta360 remote, and the +camera has priority at every stage. Standby only advertises while the +camera has no use for the radio, and it lets go when a paired camera's +engine passes 500 rpm — before the camera's own 2 s wake debounce, so the +camera never finds the radio taken. Remote standby therefore never calls +`CAMERA_FORCE_RELEASE()`; only a person at the logger can bump the camera. +All of these rules are the `remote_auth` pure unit, and the 3 s hold that +reveals or replaces the PIN on the device is `pin_page`. + ### The settings file has a hard size ceiling Every settings read path caps at `sizeof(settingsFileBuffer) - 1`. A file diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 7b2e04e..db9d347 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -109,6 +109,8 @@ #include "replay.h" #include "sat_bars.h" #include "sd_format_page.h" +#include "pin_page.h" +#include "remote_auth.h" #include "sd_functions.h" #include "sensoregg.h" #include "settings.h" @@ -840,6 +842,15 @@ const int PAGE_DRAG_STAGING = -19; // tree countdown / run / results // 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) +// Transfer → PIN (plan 0019): hold-to-reveal / hold-to-replace the remote +// transfer PIN. Driven by transferPinPageLoop() between readButtons() and +// displayLoop(); Back is an ordinary menu press. +const int PAGE_TRANSFER_PIN = -22; +pin_page::State transferPinState; +// The digits, only while the page is showing them — cleared on hide. +char transferPinDigits[remote_auth::kPinLen + 1] = ""; +// Last New PIN attempt could not be written (shown on the page). +bool transferPinWriteFailed = false; // running menu (these must be in order) #if BIRDSEYE_ENABLE_PROFILING @@ -2289,6 +2300,53 @@ void gpsStatusPageLoop() { } } +/** + * @brief Drive the Transfer → PIN page (plan 0019): the hold-to-reveal and + * hold-to-replace state machine is the host-tested pin_page unit; + * this owns the settings read/write and drops the digits from RAM + * the moment the reveal window closes. + */ +void transferPinPageLoop() { + if (currentPage != PAGE_TRANSFER_PIN) return; + + pin_page::Inputs in; + in.selectHeld = isButtonHeld(2, 0); + // A held side button is the Select+side reboot combo, never a confirm. + in.otherButtonHeld = isButtonHeld(1, 0) || isButtonHeld(3, 0); + in.row = menuSelectionIndex; + in.nowMs = millis(); + + switch (pin_page::step(transferPinState, in)) { + case pin_page::Action::kReveal: + transferPinWriteFailed = false; + if (getSetting("bluetooth_pin", transferPinDigits, sizeof(transferPinDigits))) { + pin_page::reveal(transferPinState, in.nowMs); + } + forceDisplayRefresh(); + break; + case pin_page::Action::kNewPin: { + char pin[remote_auth::kPinLen + 1]; + generateBlePin(pin); + // Persist-first: show the new PIN only once it is the stored one. + transferPinWriteFailed = !setSetting("bluetooth_pin", pin); + if (!transferPinWriteFailed) { + strncpy(transferPinDigits, pin, sizeof(transferPinDigits)); + transferPinDigits[sizeof(transferPinDigits) - 1] = '\0'; + pin_page::reveal(transferPinState, in.nowMs); + } + forceDisplayRefresh(); + break; + } + case pin_page::Action::kNone: + break; + } + + if (!pin_page::isRevealed(transferPinState, in.nowMs) && transferPinDigits[0] != '\0') { + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + forceDisplayRefresh(); + } +} + /** * @brief Drive the SD format-confirm boot page: step the hold-to-confirm * state machine (host-tested sd_format_page unit) and act on its @@ -2971,6 +3029,9 @@ void loop() { // When BLE is active, skip GPS/tach/lap processing for better throughput if (bleActive) { PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP()); + // A remotely started session hands the radio back the moment a paired + // camera wants it (plan 0019). Runs the tach itself; no-op otherwise. + PROFILE_SECTION(loop_profile::kTach, bleRemoteSessionGuard()); // Keep battery voltage fresh for BLE BATT command and display if (millis() - lastBatteryCheck > batteryUpdateInterval) { @@ -3040,7 +3101,10 @@ void loop() { PROFILE_SECTION(loop_profile::kGps, GPS_LOOP()); PROFILE_SECTION(loop_profile::kTach, TACH_LOOP()); PROFILE_SECTION(loop_profile::kAccel, ACCEL_LOOP()); - PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP()); + // Menu standby advert for remote transfer (plan 0019). Before + // CAMERA_LOOP() on purpose: standby lets go of the radio the moment the + // camera wants it, so the camera never finds it taken. + PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP(); BLE_STANDBY_LOOP()); // drain SensorEgg scan buffer (Temp1 fresh for logging) PROFILE_SECTION(loop_profile::kEgg, SENSOREGG_LOOP()); @@ -3157,9 +3221,10 @@ void loop() { // slot the LOOP PROFILE page needed for SLP — see loop_profile.h. // gpsStatusPageLoop: consume presses, hold/auto-close // sdFormatPageLoop: hold Select 3s to format + // transferPinPageLoop: hold Select 3s to show / replace the BLE PIN // courseCreatorLoop: feed GPS into an averaging hold PROFILE_SECTION(loop_profile::kDisplay, gpsStatusPageLoop(); - sdFormatPageLoop(); courseCreatorLoop(); + sdFormatPageLoop(); transferPinPageLoop(); courseCreatorLoop(); dragStagingLoop(); displayLoop()); resetButtons(); diff --git a/BirdsEye/bluetooth.h b/BirdsEye/bluetooth.h index 2dd9fe5..2e58410 100644 --- a/BirdsEye/bluetooth.h +++ b/BirdsEye/bluetooth.h @@ -85,6 +85,30 @@ void bleShutdownQuiesce(); // burst-send chunk pipeline for any active file transfer. void BLUETOOTH_LOOP(); +// --- Remote transfer (plan 0019) ------------------------------------------- +// While the logger idles on its main menu it advertises the transfer +// service in LOCKED mode: a peer may only run the PIN handshake (AUTH?, +// AUTH:) and BATT until it answers correctly, at which point the +// session is promoted into the normal transfer page. A local Transfer → +// Bluetooth start stays OPEN, as before. The camera always wins the radio: +// standby never advertises while it wants it, and a remote session ends +// when it starts to. All the rules live in the host-tested remote_auth unit. + +// Run the menu standby advert: start/stop it as the page, the camera, a +// race and the remote_transfer setting allow; service the handshake; drop +// a peer that hasn't authenticated within 20 s; promote on AUTH:OK. Called +// every main-loop iteration, before CAMERA_LOOP() so the radio is already +// free whenever the camera reaches for it. +void BLE_STANDBY_LOOP(); + +// Called from the parked transfer branch. On a REMOTE session only, keeps +// the tachometer running and ends the session (AUTH:CAMERA, then the usual +// reboot) the moment a paired camera wants the radio back. +void bleRemoteSessionGuard(); + +// True while the transfer page is serving a session that started remotely. +bool bleRemoteSessionActive(); + // --- Transfer diagnostics (read by the Bluetooth page) --------------------- // These exist so a download-speed regression is visible on the device instead // of being inferred from a progress bar. See diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino index e2e7921..dacaba1 100644 --- a/BirdsEye/bluetooth.ino +++ b/BirdsEye/bluetooth.ino @@ -8,6 +8,7 @@ #include "camera_ble.h" #include "filename_validator.h" #include "firmware_ota.h" +#include "remote_auth.h" // For SENSOREGG_SLEEP() — BLE_SETUP() quiesces the egg scanner so the // transfer link never shares the radio with a scan window (plan 0012). #include "sensoregg.h" @@ -102,6 +103,30 @@ static bool deferFileCommand(const char* cmd) { return true; } +// ---- Remote transfer (plan 0019) ---- +// Lockout + nonce + per-connection auth. RAM only, so a reboot clears a +// lockout — which takes the device in hand anyway. +static remote_auth::State bleAuth; +// Open = transfer started on the device (everything allowed, PINGET too); +// locked = the menu standby advert (handshake first). Read by the callback. +static volatile remote_auth::Mode bleSessionMode = remote_auth::Mode::kOpen; +// The menu standby advert is up (bleOwner == TRANSFER, bleActive false). +static bool bleStandbyActive = false; +// The transfer page is serving a session promoted from standby. +static bool bleRemoteSession = false; +// When the current peripheral peer connected, for the 20 s auth limit. +static volatile uint32_t bleConnectAtMs = 0; +// Our own peripheral link. Bluefruit.connHandle() is the LAST connection of +// any role, which on a SensorEgg build can be the egg's central link. +static volatile uint16_t bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; +// remote_transfer setting, read once (it only changes over BLE, and every +// BLE session ends in a reboot): -1 unread, 0 off, 1 on. +static int8_t bleRemoteTransferSetting = -1; +// Deferred handshake command (needs the RNG and the PIN off the card, so it +// runs on the main loop like every other SD-touching command). +static volatile bool authCmdPending = false; +static char authCmdBuffer[65]; + // Set by the disconnect callback; BLUETOOTH_LOOP() performs the SD teardown // (close transfer/staging file, release SD, abort OTA) and the auto-reboot // on the main loop, so SdFat is only ever touched by one task. @@ -128,6 +153,9 @@ void bleConnectCallback(uint16_t conn_handle) { debugln(F("BLE: Device connected!")); bleConnected = true; bleTransferEngaged = false; // this peer hasn't used the service yet + bleTransferConnHandle = conn_handle; + bleConnectAtMs = millis(); + remote_auth::onDisconnect(bleAuth); // a new peer starts unauthenticated BLEConnection* connection = Bluefruit.Connection(conn_handle); @@ -245,6 +273,10 @@ void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) { debugln(F("BLE: Disconnected!")); bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + // The next peer must authenticate from scratch. The lockout survives. + remote_auth::onDisconnect(bleAuth); + authCmdPending = false; bleNegotiatedMtu = 23; // Reset to default bleLinkTuneStage = 0; // abandon any pending link tuning for this peer bleLinkDataLen = 27; // pre-DLE default @@ -638,6 +670,30 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* debug(buffer); debugln(F("]")); + // Plan 0019: a remotely started (locked) session may only run the PIN + // handshake and BATT until it has answered correctly. + if (!remote_auth::commandAllowed(bleSessionMode, bleAuth.authed, buffer)) { + fileStatusChar.notify((uint8_t*)"AUTH:REQUIRED", 13); + return; + } + + if (strcmp(buffer, "AUTH?") == 0 || strncmp(buffer, "AUTH:", 5) == 0) { + // Nothing to prove on a local start, or after a correct answer. + if (bleSessionMode == remote_auth::Mode::kOpen) { + fileStatusChar.notify((uint8_t*)"AUTH:OPEN", 9); + } else if (bleAuth.authed) { + fileStatusChar.notify((uint8_t*)"AUTH:OK", 7); + } else if (authCmdPending) { + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + } else { + // Needs the RNG and the PIN off the card: main loop. + strncpy(authCmdBuffer, buffer, sizeof(authCmdBuffer) - 1); + authCmdBuffer[sizeof(authCmdBuffer) - 1] = '\0'; + authCmdPending = true; + } + return; + } + // File commands (LIST/GET/DELETE/TLIST/TGET) all touch SD, so they are // DEFERRED to BLUETOOTH_LOOP() via deferFileCommand() — SdFat must never // run in this Bluefruit callback task. Filename validation is RAM-only @@ -674,7 +730,8 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* } else if (strcmp(buffer, "SLIST") == 0 || strncmp(buffer, "SGET:", 5) == 0 || strncmp(buffer, "SSET:", 5) == 0 || - strcmp(buffer, "SRESET") == 0) { + strcmp(buffer, "SRESET") == 0 || + strcmp(buffer, "PINGET") == 0) { // Settings commands — defer to main loop for thread-safe SD access if (settingsCmdPending) { fileStatusChar.notify((uint8_t*)"SBUSY", 5); @@ -857,6 +914,197 @@ void bleCoreEnsureInit() { // owner (transfer page or camera module) applies its own advert set. } +/////////////////////////////////////////// +// REMOTE TRANSFER STANDBY (plan 0019) +/////////////////////////////////////////// + +static bool bleRemoteTransferEnabled() { + if (bleRemoteTransferSetting < 0) { + char v[8]; + // No card means no PIN to check against, so nothing to offer. + bleRemoteTransferSetting = + (sdSetupSuccess && + !(getSetting("remote_transfer", v, sizeof(v)) && strcmp(v, "off") == 0)) + ? 1 + : 0; + } + return bleRemoteTransferSetting == 1; +} + +static bool bleCameraWantsRadio() { + remote_auth::CameraInputs cam; + const camera_fsm::State st = cameraFsmState(); + cam.fsmIdleOrUnpaired = + st == camera_fsm::State::kUnpaired || st == camera_fsm::State::kIdle; + cam.testPageOpen = cameraTestModeActive(); + cam.ownsRadio = bleOwner == BLE_OWNER_CAMERA; + cam.paired = cameraIsPaired(); + cam.rpm = (uint16_t)(tachLastReported > 65535 ? 65535 : tachLastReported); + return remote_auth::cameraWantsRadio(cam); +} + +static void bleStandbyStart() { + bleCoreEnsureInit(); + bleOwner = BLE_OWNER_TRANSFER; + bleSessionMode = remote_auth::Mode::kLocked; + bleRemoteSession = false; + remote_auth::onDisconnect(bleAuth); + authCmdPending = false; + settingsCmdPending = false; + fileCmdPending = false; + bleApplyTransferAdvertising(); + // No blue LED for an idle menu advert — that LED means "transfer page". + bleConnLedOff(); + bleStandbyActive = true; + debugln(F("BLE: remote standby advertising")); +} + +static void bleStandbyStop(bool forCamera) { + bleStandbyActive = false; + // Disarm BEFORE the async disconnect, or Bluefruit restarts an ownerless + // advert behind us (see BLE_STOP()). + Bluefruit.Advertising.restartOnDisconnect(false); + const uint16_t handle = bleTransferConnHandle; + if (handle != BLE_CONN_HANDLE_INVALID) { + if (forCamera) { + fileStatusChar.notify((uint8_t*)"AUTH:CAMERA", 11); + delay(50); // let the reason reach the app before the link drops + } + Bluefruit.disconnect(handle); + } + Bluefruit.Advertising.stop(); + authCmdPending = false; + settingsCmdPending = false; + fileCmdPending = false; + remote_auth::onDisconnect(bleAuth); + bleSessionMode = remote_auth::Mode::kOpen; + bleOwner = BLE_OWNER_NONE; + debugln(forCamera ? F("BLE: remote standby off (camera)") : F("BLE: remote standby off")); +} + +// AUTH:OK on the menu advert: become the transfer page, exactly as a local +// start would, except the session stays locked-and-authenticated (so +// PINGET stays off) and is labelled remote. +static void bleStandbyPromote() { + if (!bleConnected) return; // peer left between the answer and here + bleStandbyActive = false; + bleRemoteSession = true; + sdSetTransferSpeed(true); + SENSOREGG_SLEEP(); // same guarantee BLE_SETUP() gives the transfer link + Bluefruit.autoConnLed(true); + Bluefruit.setConnLedInterval(250); + bleActive = true; + switchToDisplayPage(PAGE_BLUETOOTH); + debugln(F("BLE: remote transfer authenticated")); +} + +// Handshake commands, deferred from the callback (main-loop context). +static void bleProcessAuthCommand() { + if (!authCmdPending) return; + char cmd[sizeof(authCmdBuffer)]; + strncpy(cmd, authCmdBuffer, sizeof(cmd)); + cmd[sizeof(cmd) - 1] = '\0'; + authCmdPending = false; + + // Promoted already (or never locked): nothing to prove. + if (bleSessionMode != remote_auth::Mode::kLocked || bleAuth.authed) return; + + const uint32_t now = millis(); + char reply[48]; + if (remote_auth::isLocked(bleAuth, now)) { + snprintf(reply, sizeof(reply), "AUTH:LOCKED:%lu", + (unsigned long)remote_auth::lockSecondsLeft(bleAuth, now)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + return; + } + + if (strcmp(cmd, "AUTH?") == 0) { + uint8_t nonce[remote_auth::kNonceLen]; + if (!hwRandomBytes(nonce, sizeof(nonce))) { + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + return; + } + remote_auth::issueNonce(bleAuth, nonce); + snprintf(reply, sizeof(reply), "AUTH:NONCE:%s", bleAuth.nonceHex); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + return; + } + + // AUTH: + char pin[16]; + if (!getSetting("bluetooth_pin", pin, sizeof(pin))) pin[0] = '\0'; + const remote_auth::Verdict v = remote_auth::checkAnswer(bleAuth, cmd + 5, pin, now); + switch (v) { + case remote_auth::Verdict::kOk: + fileStatusChar.notify((uint8_t*)"AUTH:OK", 7); + bleStandbyPromote(); + break; + case remote_auth::Verdict::kFail: + snprintf(reply, sizeof(reply), "AUTH:FAIL:%u", (unsigned)remote_auth::triesLeft(bleAuth)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + break; + case remote_auth::Verdict::kLocked: + snprintf(reply, sizeof(reply), "AUTH:LOCKED:%lu", + (unsigned long)remote_auth::lockSecondsLeft(bleAuth, now)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + break; + case remote_auth::Verdict::kNoNonce: + fileStatusChar.notify((uint8_t*)"AUTH:NO_NONCE", 13); + break; + } +} + +void BLE_STANDBY_LOOP() { + const bool onMenu = currentPage == PAGE_MAIN_MENU; + const bool cameraWants = bleCameraWantsRadio(); + + remote_auth::StandbyInputs in; + in.onMainMenu = onMenu; + in.radioFree = bleOwner == BLE_OWNER_NONE || + (bleStandbyActive && bleOwner == BLE_OWNER_TRANSFER); + in.cameraWantsRadio = cameraWants; + in.raceActive = raceActive; + // Only read the setting (an SD read, once) when it could matter. + in.settingEnabled = onMenu && bleRemoteTransferEnabled(); + + const bool wanted = remote_auth::standbyWanted(in); + if (!bleStandbyActive) { + if (wanted) bleStandbyStart(); + return; + } + if (!wanted) { + bleStandbyStop(cameraWants); + return; + } + + bleProcessAuthCommand(); + if (!bleStandbyActive) return; // promoted + + // Nobody gets to sit on the only peripheral slot without the PIN. + const uint16_t handle = bleTransferConnHandle; + if (handle != BLE_CONN_HANDLE_INVALID && !bleAuth.authed && + remote_auth::authTimedOut(bleConnectAtMs, millis())) { + debugln(F("BLE: remote peer never authenticated, dropping")); + fileStatusChar.notify((uint8_t*)"AUTH:TIMEOUT", 12); + Bluefruit.disconnect(handle); + bleConnectAtMs = millis(); // don't re-fire while the drop completes + } +} + +void bleRemoteSessionGuard() { + if (!bleRemoteSession) return; + // The parked branch skips the normal pipeline, tach included — and the + // engine starting is exactly what the camera wakes on. + TACH_LOOP(); + if (!bleCameraWantsRadio()) return; + debugln(F("BLE: camera needs the radio, ending remote transfer")); + fileStatusChar.notify((uint8_t*)"AUTH:CAMERA", 11); + delay(50); + bleExitTransferMode(); +} + +bool bleRemoteSessionActive() { return bleActive && bleRemoteSession; } + void BLE_SETUP() { // Parked transfer — bump the SD clock for faster file transfers. Reverted // in BLE_STOP() (and by the auto-reboot on phone disconnect). @@ -874,6 +1122,13 @@ void BLE_SETUP() { debugln(F("BLE: Starting transfer mode...")); + // A local start takes the radio over from the menu standby advert (the + // Transfer page is not the main menu, so standby has normally stopped + // already) and is OPEN: someone at the logger chose to start it. + if (bleStandbyActive) bleStandbyStop(false); + bleSessionMode = remote_auth::Mode::kOpen; + bleRemoteSession = false; + bleCoreEnsureInit(); // Take the radio for the transfer service (main-loop context — the camera @@ -932,7 +1187,9 @@ void BLE_STOP() { // Disconnect any connected device if (Bluefruit.connected()) { - Bluefruit.disconnect(Bluefruit.connHandle()); + Bluefruit.disconnect(bleTransferConnHandle != BLE_CONN_HANDLE_INVALID + ? (uint16_t)bleTransferConnHandle + : Bluefruit.connHandle()); // BLE disconnect is async; no delay needed - stack handles it } @@ -948,6 +1205,8 @@ void BLE_STOP() { // Release radio ownership. The camera module re-acquires it on its next // advertising action (in CAMERA_LOOP()) — nothing to hand off here. bleOwner = BLE_OWNER_NONE; + bleRemoteSession = false; + bleSessionMode = remote_auth::Mode::kOpen; // Restore the EMI-safe SD clock now that the transfer session is over. sdSetTransferSpeed(false); @@ -1019,6 +1278,13 @@ void bleShutdownQuiesce() { } } bleConnLedOff(); // pre-begin() it only parks the pin (see its guard) + // The charging loop can soft-resume to the main menu without a reboot; + // standby must then start from scratch, not believe its advert is up. + if (bleStandbyActive) { + bleStandbyActive = false; + bleSessionMode = remote_auth::Mode::kOpen; + if (bleOwner == BLE_OWNER_TRANSFER) bleOwner = BLE_OWNER_NONE; + } } // Execute a deferred file command (main-loop context — the only place @@ -1110,6 +1376,10 @@ void processSettingsCommand() { int count = 0; for (JsonPair kv : doc.as()) { + // The PIN never leaves the logger through SLIST (plan 0019): anyone + // connected could otherwise read the secret that gates them. PINGET + // hands it out, on a local start only. + if (remote_auth::isProtectedSettingKey(kv.key().c_str())) continue; char entry[64]; // A hand-edited SETTINGS.json can hold a non-string value, and // as() returns NULL for those — %s on NULL streams @@ -1135,6 +1405,11 @@ void processSettingsCommand() { debug(key); debugln(F("]")); + if (remote_auth::isProtectedSettingKey(key)) { + fileStatusChar.notify((uint8_t*)"SERR:PROTECTED", 14); + return; + } + char valueBuf[48]; if (getSetting(key, valueBuf, sizeof(valueBuf))) { char response[64]; @@ -1159,10 +1434,17 @@ void processSettingsCommand() { char* key = payload; char* value = eq + 1; + // The PIN is the one write with a format: the handshake only ever + // accepts exactly four digits, so anything else would lock remote + // transfer out for good. + if (remote_auth::isProtectedSettingKey(key) && !remote_auth::isValidPin(value)) { + debugln(F("BLE: SSET - rejected PIN format")); + fileStatusChar.notify((uint8_t*)"SERR:BAD_VALUE", 14); + return; + } + debug(F("BLE: SSET - key: [")); debug(key); - debug(F("] value: [")); - debug(value); debugln(F("]")); if (setSetting(key, value)) { @@ -1175,6 +1457,22 @@ void processSettingsCommand() { debugln(F("BLE: SSET - write failed")); fileStatusChar.notify((uint8_t*)"SERR:WRITE_FAIL", 15); } + } else if (strcmp(settingsCmdBuffer, "PINGET") == 0) { + // Local start only — the callback already refuses it on a locked + // session; checked again because this is the one command that hands + // out the secret. + if (bleSessionMode != remote_auth::Mode::kOpen) { + fileStatusChar.notify((uint8_t*)"AUTH:REQUIRED", 13); + return; + } + char pin[16]; + if (getSetting("bluetooth_pin", pin, sizeof(pin))) { + char response[24]; + snprintf(response, sizeof(response), "PIN:%s", pin); + fileStatusChar.notify((uint8_t*)response, strlen(response)); + } else { + fileStatusChar.notify((uint8_t*)"SERR:NOT_FOUND", 14); + } } else if (strcmp(settingsCmdBuffer, "SRESET") == 0) { debugln(F("BLE: SRESET - resetting all settings to defaults")); if (resetSettings()) { diff --git a/BirdsEye/camera_ble.h b/BirdsEye/camera_ble.h index 48bcf1a..94baf11 100644 --- a/BirdsEye/camera_ble.h +++ b/BirdsEye/camera_ble.h @@ -104,6 +104,10 @@ bool cameraIsPaired(); // camera_fsm::stateName()). camera_fsm::State cameraFsmState(); +// True while the camera bench-test page is open. The bench page drives the +// radio by hand, so remote transfer standby must stay off it (plan 0019). +bool cameraTestModeActive(); + // True while the camera is connected to our remote service. bool cameraRemoteLinkUp(); diff --git a/BirdsEye/camera_ble.ino b/BirdsEye/camera_ble.ino index ed41159..45f29e7 100644 --- a/BirdsEye/camera_ble.ino +++ b/BirdsEye/camera_ble.ino @@ -961,6 +961,8 @@ void cameraTestExitMode() { debugln(F("CAM: bench-test mode exited")); } +bool cameraTestModeActive() { return cameraTestActive; } + bool cameraTestWake() { if (!cameraTestActive) return false; // Present the wake / remote-identity advert (only reaches an ARMED diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index e35e3a8..07275f8 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -18,6 +18,7 @@ void displayPage_gps_status(); void displayPage_main_menu(); void displayPage_bluetooth(); void displayPage_transfer_menu(); +void displayPage_transfer_pin(); void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 9878759..a7af0c8 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -9,6 +9,7 @@ #include "lap_format.h" #include "sat_bars.h" #include "sd_format_page.h" +#include "pin_page.h" #include "sd_functions.h" #include "wake_cause.h" #include "nan_bits.h" @@ -404,7 +405,13 @@ void displayPage_bluetooth() { resetDisplay(); display.setTextSize(1); - display.println(F(" Bluetooth Connection")); + // A session the app started from the menu (plan 0019) says so, so a + // transfer page nobody here opened never looks like a malfunction. + if (bleRemoteSessionActive()) { + display.println(F(" Remote Transfer")); + } else { + display.println(F(" Bluetooth Connection")); + } display.println(); display.setTextSize(2); @@ -467,20 +474,72 @@ void displayPage_bluetooth() { void displayPage_transfer_menu() { resetDisplay(); + // Four rows since plan 0019 added PIN, so this uses the drag-distance + // scrolling window (title 8 px + 3 size-2 rows + hint line). + // + // Back matters more here than on most menus: both transfer modes leave by + // rebooting, and this page is not the main menu so the idle-shutdown timer + // never runs on it. Without this row, opening Transfer by mistake could + // only be undone with the (unlabelled) reboot combo. + static const char* const kItems[] = {"Bluetooth", "USB", "PIN", "Back"}; + const int itemCount = (int)(sizeof(kItems) / sizeof(kItems[0])); + const int visibleRows = 3; + int first = menuSelectionIndex - 1; + if (first < 0) first = 0; + if (first > itemCount - visibleRows) first = itemCount - visibleRows; + display.setTextSize(1); display.println(F(" Transfer Mode")); + display.setTextSize(2); + for (int i = first; i < first + visibleRows; i++) { + display.print(menuSelectionIndex == i ? "->" : " "); + display.println(kItems[i]); + } + + display.setTextSize(1); + display.print(first > 0 ? F("^") : F(" ")); + if (first + visibleRows < itemCount) display.print(F(" v more")); + + safeDisplayUpdate(); +} + +// Transfer → PIN (plan 0019). Hidden until Select is held 3 s on Show; the +// digits then stay up for 15 s. Never on the transfer page itself. +void displayPage_transfer_pin() { + resetDisplay(); + const uint32_t now = millis(); + + display.setTextSize(1); + display.println(F(" Transfer PIN")); display.println(); + display.setTextSize(2); + const bool shown = pin_page::isRevealed(transferPinState, now) && + transferPinDigits[0] != '\0'; + display.print(F(" ")); + display.println(shown ? transferPinDigits : "****"); - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("Bluetooth")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("USB")); - // Back matters more here than on most menus: both transfer modes leave by - // rebooting, and this page is not the main menu so the idle-shutdown timer - // never runs on it. Without this row, opening Transfer by mistake could - // only be undone with the (unlabelled) reboot combo. - display.print(menuSelectionIndex == 2 ? "->" : " "); + display.setTextSize(1); + const uint32_t holdLeft = pin_page::holdSecondsLeft(transferPinState, now); + if (holdLeft > 0) { + display.print(F("keep holding ")); + display.print(holdLeft); + display.println(F("s")); + } else if (shown) { + display.print(F("hides in ")); + display.print(pin_page::revealSecondsLeft(transferPinState, now)); + display.println(F("s")); + } else if (transferPinWriteFailed) { + display.println(F("PIN write failed")); + } else { + display.println(F("hold SELECT 3s")); + } + + display.print(menuSelectionIndex == pin_page::kRowShow ? "->" : " "); + display.println(F("Show PIN")); + display.print(menuSelectionIndex == pin_page::kRowNewPin ? "->" : " "); + display.println(F("New PIN")); + display.print(menuSelectionIndex == pin_page::kRowBack ? "->" : " "); display.println(F("Back")); safeDisplayUpdate(); diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 88d8159..fae6225 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -516,10 +516,17 @@ void handleMenuPageSelection() { forceDisplayRefresh(); #endif } else if (currentPage == PAGE_TRANSFER_MENU) { - if (menuSelectionIndex == 2) { + if (menuSelectionIndex == 3) { // Back — the only non-rebooting way off this page. debugln(F("Transfer: Back selected")); switchToDisplayPage(PAGE_MAIN_MENU); + } else if (menuSelectionIndex == 2) { + // PIN — the remote-transfer PIN, behind a hold (plan 0019). + debugln(F("Transfer: PIN selected")); + pin_page::begin(transferPinState); + transferPinWriteFailed = false; + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + switchToDisplayPage(PAGE_TRANSFER_PIN); } else if (menuSelectionIndex == 0) { // Bluetooth — same flow as before debugln(F("Transfer: Bluetooth selected")); @@ -551,6 +558,15 @@ void handleMenuPageSelection() { } } } + } else if (currentPage == PAGE_TRANSFER_PIN) { + // Show / New PIN act on a 3 s HOLD (transferPinPageLoop); only Back + // acts on a press. Leaving hides the PIN and forgets the digits. + if (menuSelectionIndex == pin_page::kRowBack) { + debugln(F("Transfer PIN: Back")); + pin_page::begin(transferPinState); + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + switchToDisplayPage(PAGE_TRANSFER_MENU); + } } else if (currentPage == PAGE_USB_STORAGE) { // Exit — reboot to drop the drive and remount a fresh filesystem debugln(F("USB Storage: Exit selected")); @@ -732,6 +748,8 @@ void displayLoop() { displayPage_bluetooth(); } else if (currentPage == PAGE_TRANSFER_MENU) { displayPage_transfer_menu(); + } else if (currentPage == PAGE_TRANSFER_PIN) { + displayPage_transfer_pin(); } else if (currentPage == PAGE_USB_STORAGE) { displayPage_usb_storage(); } else if (currentPage == PAGE_PAIR_CAMERA) { @@ -840,6 +858,7 @@ void displayLoop() { currentPage == PAGE_DRAG_MODE || currentPage == PAGE_BLUETOOTH || currentPage == PAGE_TRANSFER_MENU || + currentPage == PAGE_TRANSFER_PIN || currentPage == PAGE_USB_STORAGE || currentPage == LOGGING_STOP_CONFIRM || currentPage == PAGE_COURSE_PRUNE || @@ -876,7 +895,9 @@ void displayLoop() { } else if (currentPage == PAGE_BLUETOOTH) { menuLimit = 1; // Only "Exit" option } else if (currentPage == PAGE_TRANSFER_MENU) { - menuLimit = 3; // Bluetooth, USB, Back + menuLimit = 4; // Bluetooth, USB, PIN, Back + } else if (currentPage == PAGE_TRANSFER_PIN) { + menuLimit = pin_page::kRowCount; // Show, New PIN, Back } else if (currentPage == PAGE_USB_STORAGE) { menuLimit = 1; // Only "Exit" option } else if (currentPage == PAGE_PAIR_CAMERA) { @@ -941,6 +962,7 @@ void displayLoop() { #endif currentPage == PAGE_COURSE_PRUNE || currentPage == PAGE_TRANSFER_MENU || + currentPage == PAGE_TRANSFER_PIN || courseCreatorActive()); // BUTTON UP (or DOWN for reversed menus) diff --git a/BirdsEye/pin_page.cpp b/BirdsEye/pin_page.cpp new file mode 100644 index 0000000..c6a9de4 --- /dev/null +++ b/BirdsEye/pin_page.cpp @@ -0,0 +1,54 @@ +#include "pin_page.h" + +namespace pin_page { + +void begin(State& s) { s = State(); } + +Action step(State& s, const Inputs& in) { + if (!in.selectHeld) { + s.selectSeenReleased = true; + s.holdArmed = false; + return Action::kNone; + } + const bool actionRow = in.row == kRowShow || in.row == kRowNewPin; + if (!s.selectSeenReleased || in.otherButtonHeld || !actionRow) { + s.holdArmed = false; + return Action::kNone; + } + if (!s.holdArmed || s.holdRow != in.row) { + s.holdArmed = true; + s.holdRow = in.row; + s.holdSinceMs = in.nowMs; + return Action::kNone; + } + if (uint32_t(in.nowMs - s.holdSinceMs) < kHoldMs) return Action::kNone; + + // Fire once; a fresh hold needs a release first. + s.holdArmed = false; + s.selectSeenReleased = false; + return in.row == kRowNewPin ? Action::kNewPin : Action::kReveal; +} + +void reveal(State& s, uint32_t nowMs) { + s.revealed = true; + s.revealSinceMs = nowMs; +} + +bool isRevealed(State& s, uint32_t nowMs) { + if (s.revealed && uint32_t(nowMs - s.revealSinceMs) >= kRevealMs) s.revealed = false; + return s.revealed; +} + +uint32_t revealSecondsLeft(State& s, uint32_t nowMs) { + if (!isRevealed(s, nowMs)) return 0; + return (kRevealMs - uint32_t(nowMs - s.revealSinceMs) + 999u) / 1000u; +} + +uint32_t holdSecondsLeft(const State& s, uint32_t nowMs) { + if (!s.holdArmed) return 0; + const uint32_t held = uint32_t(nowMs - s.holdSinceMs); + if (held >= kHoldMs) return 1; + return (kHoldMs - held + 999u) / 1000u; +} + +} // namespace pin_page diff --git a/BirdsEye/pin_page.h b/BirdsEye/pin_page.h new file mode 100644 index 0000000..f7399f6 --- /dev/null +++ b/BirdsEye/pin_page.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +/////////////////////////////////////////// +// TRANSFER PIN PAGE STATE MACHINE (plan 0019) +// Transfer → PIN: the one place the remote-transfer PIN is shown on the +// device, and where a lost one is replaced. With the SD card soldered in, +// this page (plus the USB drive and a local Bluetooth start) is the whole +// recovery story — but the PIN must not sit on screen for anyone walking +// past, so both actions are deliberate holds: +// - Show: hold Select 3 s → PIN visible for 15 s, then hidden again. +// - New PIN: hold Select 3 s → a fresh PIN is generated and shown. +// - Back: an ordinary press (handled by the menu code, not here). +// A release, a change of row, or a side button held alongside Select (the +// Select+side reboot combo) cancels a hold with no partial credit. A +// Select still down from the press that opened the page never counts — +// it must be seen released first, exactly like the SD format confirm. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace pin_page { + +constexpr uint32_t kHoldMs = 3000; +constexpr uint32_t kRevealMs = 15000; + +// Row order on the page. Back is last, as on every menu. +constexpr int kRowShow = 0; +constexpr int kRowNewPin = 1; +constexpr int kRowBack = 2; +constexpr int kRowCount = 3; + +enum class Action : uint8_t { + kNone, + kReveal, // show the stored PIN + kNewPin, // generate + store a new PIN, then show it +}; + +struct Inputs { + bool selectHeld = false; + bool otherButtonHeld = false; + int row = kRowShow; + uint32_t nowMs = 0; +}; + +struct State { + bool selectSeenReleased = false; + bool holdArmed = false; + int holdRow = kRowShow; + uint32_t holdSinceMs = 0; + bool revealed = false; + uint32_t revealSinceMs = 0; +}; + +// Page entry: everything hidden, nothing armed. +void begin(State& s); + +// Advance one loop iteration. +Action step(State& s, const Inputs& in); + +// Start (or restart) the 15 s reveal window — the caller does this once it +// has the digits to show, after kReveal or a successful kNewPin. +void reveal(State& s, uint32_t nowMs); + +// True while the PIN should be on screen. Expires the window as a side +// effect so the caller can drop its copy of the digits. +bool isRevealed(State& s, uint32_t nowMs); + +// Seconds left in the reveal (1..15), 0 when hidden. +uint32_t revealSecondsLeft(State& s, uint32_t nowMs); + +// Seconds left in a running hold (1..3), 0 when none. +uint32_t holdSecondsLeft(const State& s, uint32_t nowMs); + +} // namespace pin_page diff --git a/BirdsEye/remote_auth.cpp b/BirdsEye/remote_auth.cpp index ccbac9a..c86d7de 100644 --- a/BirdsEye/remote_auth.cpp +++ b/BirdsEye/remote_auth.cpp @@ -48,13 +48,10 @@ void toHex(const uint8_t* bytes, size_t len, char* out) { out[len * 2] = '\0'; } -void computeAnswer(const char* pin, const char* nonceHex, const char* deviceName, - char* out) { - // "BEAUTH1|" + 32 + "|" + name. Names are capped well below this by the - // settings and advert limits; truncating would only produce a mismatch. - char msg[8 + kNonceHexLen + 1 + 64 + 1]; +void computeAnswer(const char* pin, const char* nonceHex, char* out) { + char msg[8 + kNonceHexLen + 1]; size_t n = 0; - const char* parts[] = {"BEAUTH1|", nonceHex, "|", deviceName}; + const char* parts[] = {"BEAUTH1|", nonceHex}; for (const char* part : parts) { for (size_t i = 0; part[i] != '\0' && n < sizeof(msg) - 1; ++i) msg[n++] = part[i]; } @@ -102,8 +99,7 @@ void issueNonce(State& s, const uint8_t* bytes) { s.nonceValid = true; } -Verdict checkAnswer(State& s, const char* answerHex, const char* pin, - const char* deviceName, uint32_t nowMs) { +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t nowMs) { if (isLocked(s, nowMs)) { s.nonceValid = false; return Verdict::kLocked; @@ -119,7 +115,7 @@ Verdict checkAnswer(State& s, const char* answerHex, const char* pin, for (size_t i = 0; i < kAnswerHexLen; ++i) wellFormed = wellFormed && isHex(answerHex[i]); if (wellFormed) { char expected[kAnswerHexLen + 1]; - computeAnswer(pin, s.nonceHex, deviceName, expected); + computeAnswer(pin, s.nonceHex, expected); ok = hexEqualConstTime(answerHex, expected, kAnswerHexLen); } } @@ -161,17 +157,14 @@ bool isProtectedSettingKey(const char* key) { return key != nullptr && strcmp(key, "bluetooth_pin") == 0; } -bool standbyWanted(const StandbyInputs& in) { - return in.settingEnabled && in.onMainMenu && in.radioFree && !in.cameraBusy && - !in.raceActive; -} - -bool cameraBusy(bool fsmIdleOrUnpaired, bool testPageOpen, bool ownsRadio) { - return !fsmIdleOrUnpaired || testPageOpen || ownsRadio; +bool cameraWantsRadio(const CameraInputs& in) { + return !in.fsmIdleOrUnpaired || in.testPageOpen || in.ownsRadio || + (in.paired && in.rpm > kCameraWakeRpm); } -bool cameraEndsRemoteSession(bool cameraBusyNow, bool cameraPaired, uint16_t rpm) { - return cameraBusyNow || (cameraPaired && rpm > kCameraWakeRpm); +bool standbyWanted(const StandbyInputs& in) { + return in.settingEnabled && in.onMainMenu && in.radioFree && !in.cameraWantsRadio && + !in.raceActive; } } // namespace remote_auth diff --git a/BirdsEye/remote_auth.h b/BirdsEye/remote_auth.h index c31c58f..bac8db5 100644 --- a/BirdsEye/remote_auth.h +++ b/BirdsEye/remote_auth.h @@ -14,10 +14,12 @@ // host-tested. // // THE ANSWER (shared byte-for-byte with DovesDataViewer and LapWing): -// mac = HMAC-SHA256(key = PIN as ASCII, -// msg = "BEAUTH1|" + nonceHex + "|" + bluetooth_name) +// mac = HMAC-SHA256(key = PIN as ASCII, msg = "BEAUTH1|" + nonceHex) // answer = lowercase hex of the first 16 bytes of mac (32 chars) -// The nonce is 16 random bytes, sent as 32 lowercase hex chars. +// The nonce is 16 random bytes, sent as 32 lowercase hex chars. It is not +// bound to the device name: the nonce is already unique to this logger and +// this attempt, and the advertised name can be truncated on air, so mixing +// it in would only add a way for two correct implementations to disagree. // // What this does NOT protect against: the link is unencrypted Just Works, // so a sniffer that records one handshake can brute-force a 4-digit PIN @@ -53,10 +55,9 @@ void pinFromRandom(uint32_t r, char* out); // Lowercase hex of `len` bytes into `out` (2*len + 1 bytes). void toHex(const uint8_t* bytes, size_t len, char* out); -// The expected answer for (pin, nonceHex, deviceName) into `out` -// (kAnswerHexLen + 1 bytes). -void computeAnswer(const char* pin, const char* nonceHex, const char* deviceName, - char* out); +// The expected answer for (pin, nonceHex) into `out` (kAnswerHexLen + 1 +// bytes). +void computeAnswer(const char* pin, const char* nonceHex, char* out); // Case-insensitive compare of two hex strings of exactly `n` chars, in time // independent of where they differ. False if either is shorter than `n` or @@ -105,8 +106,7 @@ enum class Verdict : uint8_t { // Check `answerHex` against the expected answer for the current nonce. The // nonce is consumed on every verdict except kLocked-before-checking and // kNoNonce. A malformed answer counts as a wrong one. -Verdict checkAnswer(State& s, const char* answerHex, const char* pin, - const char* deviceName, uint32_t nowMs); +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t nowMs); // Reset per-connection state (authed flag, nonce). The lockout survives. void onDisconnect(State& s); @@ -124,24 +124,32 @@ bool commandAllowed(Mode mode, bool authed, const char* cmd); // Settings that SLIST must omit and SGET must refuse (SERR:PROTECTED). bool isProtectedSettingKey(const char* key); +// What the camera is doing, as far as the radio is concerned. +struct CameraInputs { + bool fsmIdleOrUnpaired = true; // camera_fsm state is kUnpaired or kIdle + bool testPageOpen = false; // camera bench page holds the radio + bool ownsRadio = false; // bleOwner == CAMERA + bool paired = false; + uint16_t rpm = 0; +}; + +// The camera has, or is about to have, a use for the radio: its state +// machine is anywhere but UNPAIRED/IDLE, the bench page is open, it owns +// the radio — or a paired camera's engine is above the wake threshold. +// That last one fires before the camera FSM's own 2 s wake debounce, so +// remote transfer has always let go of the radio before the camera asks. +bool cameraWantsRadio(const CameraInputs& in); + // When the menu standby advert should be up. struct StandbyInputs { bool onMainMenu = false; - bool radioFree = false; // bleOwner == NONE - bool cameraBusy = false; // see cameraBusy() + bool radioFree = false; // bleOwner == NONE, or already ours for standby + bool cameraWantsRadio = false; bool raceActive = false; - bool settingEnabled = false; // remote_transfer setting + bool settingEnabled = false; // remote_transfer setting }; bool standbyWanted(const StandbyInputs& in); -// The camera "has a use for the radio": its state machine is anywhere but -// UNPAIRED/IDLE, the camera bench page is open, or it owns the radio. -bool cameraBusy(bool fsmIdleOrUnpaired, bool testPageOpen, bool ownsRadio); - -// A REMOTE transfer session must hand the radio back now: the camera is -// busy, or a paired camera's engine just passed the wake threshold. -bool cameraEndsRemoteSession(bool cameraBusyNow, bool cameraPaired, uint16_t rpm); - // The 20 s squatting limit, wrap-safe. inline bool authTimedOut(uint32_t connectedAtMs, uint32_t nowMs) { return uint32_t(nowMs - connectedAtMs) >= kAuthTimeoutMs; diff --git a/BirdsEye/settings.h b/BirdsEye/settings.h index 79ce704..2a961e4 100644 --- a/BirdsEye/settings.h +++ b/BirdsEye/settings.h @@ -8,6 +8,7 @@ /////////////////////////////////////////// #include +#include /////////////////////////////////////////// // SETTINGS JSON CAPACITY — the single source of truth. @@ -37,7 +38,7 @@ // 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. +// len(key) + len(value) + 6. Plan 0019's remote_transfer adds 26. /////////////////////////////////////////// #define SETTINGS_JSON_CAPACITY 1024 @@ -65,3 +66,13 @@ bool removeSetting(const char* key); // Delete the settings file and re-create with fresh defaults // (rolls a new random BLE name and PIN). bool resetSettings(); + +// Fill `out` with `n` bytes from the nRF52840 hardware RNG — through the +// SoftDevice when it is enabled (it owns the peripheral then), directly +// otherwise. False only if the SoftDevice pool stays empty for 50 ms. +// Used for the BLE PIN and the remote-transfer auth nonce (plan 0019). +bool hwRandomBytes(uint8_t* out, size_t n); + +// A fresh 4-digit BLE PIN (1000-9999) from the hardware RNG into `out` +// (at least 5 bytes). +void generateBlePin(char* out); diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index 9fa2d56..cc7ab53 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -1,551 +1,610 @@ -/////////////////////////////////////////// -// SETTINGS MODULE -// Persistent JSON key-value store on SD card (/SETTINGS.json) -// Provides getSetting() and setSetting() for any subsystem. -/////////////////////////////////////////// - -#include "settings.h" - -static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json"; -// Quarantine target for a corrupt (non-empty, unparseable) settings file — -// typically a hand-edit gone wrong. The bad file is KEPT (renamed, not -// deleted) so its contents can be inspected/salvaged on a computer; a -// pre-existing .bad from an earlier quarantine is overwritten. -static const char SETTINGS_BAD_PATH[] = "/SETTINGS.json.bad"; -// Sized for the whole settings file plus its NUL, with real headroom: -// every read path below caps at sizeof(settingsFileBuffer) - 1, so a -// file larger than this parses as IncompleteInput and EVERY key read -// fails. That failure is self-inflicting — SETTINGS_SETUP quarantines -// the "corrupt" file and regenerates it, ensureDefaultSettings grows it -// back over the cap, and the device loses its settings on a loop. -// Raised 512 -> 1024 in plan 0010: the 18-key file was already 436 B and -// the four new keys put it at 543. The document is the other wall — 22 -// string pairs need JSON_OBJECT_SIZE(22) and a <512> doc returns -// NoMemory. Keep the two numbers equal, and see the measureJson() guard -// in setSettingInner() before adding more keys. -static char settingsFileBuffer[SETTINGS_JSON_CAPACITY]; -static StaticJsonDocument settingsJson; - -// 12 racing-adjacent words used to build a friendly default device name so -// logs dumped from a fleet of devices stay distinguishable. The literals -// live in flash and the pointer table is tiny (~48 bytes), so this costs -// almost no RAM. A default name is two of these picked at random. -static const char* const kDeviceNameWords[] = { - "Apex", "Turbo", "Nitro", "Drift", "Slick", "Boost", - "Piston", "Vortex", "Camber", "Draft", "Redline", "Gearbox" -}; - -/** - * @brief Compose a random default device name (e.g. "ApexTurbo") by picking - * two distinct racing words from kDeviceNameWords. - * @param buf Caller-provided buffer for the name (null-terminated) - * @param bufSize Size of buf - */ -static void generateDeviceName(char* buf, size_t bufSize) { - const int count = (int)(sizeof(kDeviceNameWords) / sizeof(kDeviceNameWords[0])); - int i = (int)random(0, count); - int j = (int)random(0, count - 1); - if (j >= i) j++; // map onto the remaining words so the two always differ - snprintf(buf, bufSize, "%s%s", kDeviceNameWords[i], kDeviceNameWords[j]); -} - -/** - * @brief Generate default settings file on first boot - * @return true if file created successfully - */ -bool createDefaultSettings() { - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD access for defaults")); - return false; - } - - settingsJson.clear(); - - // Generate random BLE name: DovesDataLogger-XXX - char nameSuffix[4]; - int randVal = random(0, 1000); - snprintf(nameSuffix, sizeof(nameSuffix), "%03d", randVal); - - char defaultName[32]; - snprintf(defaultName, sizeof(defaultName), "DovesDataLogger-%s", nameSuffix); - settingsJson["bluetooth_name"] = defaultName; - - // Generate random 4-digit PIN - char defaultPin[5]; - snprintf(defaultPin, sizeof(defaultPin), "%04d", (int)random(1000, 10000)); - settingsJson["bluetooth_pin"] = defaultPin; - - // Random racing-themed device name so multi-device log dumps stay sorted - char deviceName[32]; - generateDeviceName(deviceName, sizeof(deviceName)); - settingsJson["device_name"] = deviceName; - - // New default settings - settingsJson["driver_name"] = "Driver"; - settingsJson["lap_detection_distance"] = "7"; - settingsJson["waypoint_detection_distance"] = "30"; - settingsJson["waypoint_speed"] = "30"; - - // Camera pairing: empty until an Insta360 serial is captured/entered - settingsJson["camera_serial"] = ""; - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to create file")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Created defaults - name: ")); - debug(defaultName); - debug(F(", pin: ")); - debugln(defaultPin); - - return true; -} - -/** - * @brief Ensure all expected settings keys exist, adding missing ones with defaults. - * Called when settings file already exists to handle firmware upgrades that add new keys. - */ -static void ensureDefaultSettings() { - // Plan 0013 renamed `rev_limit` to `target_rpm`. Migrate BEFORE the - // defaults table runs: the table only writes a key that is missing, so - // once target_rpm exists it is left alone — but if we let the table go - // first it would stamp the 15000 default over a user's tuned 7550 and - // the old value would be gone. The old key is only dropped once the - // new one is confirmed written, so a failed or refused write leaves the - // device exactly as it was rather than silently resetting its shift - // point. - { - char legacyBuf[48]; - if (!getSetting("target_rpm", legacyBuf, sizeof(legacyBuf)) && - getSetting("rev_limit", legacyBuf, sizeof(legacyBuf))) { - debug(F("Settings: migrating rev_limit -> target_rpm = ")); - debugln(legacyBuf); - if (setSetting("target_rpm", legacyBuf)) { - removeSetting("rev_limit"); - } - } - } - - // Table of all expected settings with their defaults - static const struct { const char* key; const char* defaultValue; } defaults[] = { - { "driver_name", "Driver" }, - { "lap_detection_distance", "7" }, - { "waypoint_detection_distance", "30" }, - { "waypoint_speed", "30" }, - { "camera_serial", "" }, // empty = no Insta360 paired - { "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range - // Engine geometry for true RPM (plans 0003, 0014). spark_mode is the ONLY - // one of these two that reaches the RPM math — one clamp on one plug wire - // sees one cylinder's ignition regardless of what the engine has. - { "spark_mode", "wasted" }, // "wasted" = 1 spark/rev (2T or 4T wasted); "single" = 1 per 2 revs - // The engine's ACTUAL cylinder count — no pickup-placement mind game, and - // no longer a divider (plan 0014). Descriptive: >1 means crank speed is - // inferred from one cylinder's firing rate, which is what the settings UI - // warns about. - { "cylinder_count", "1" }, - // RPM estimator (plan 0009). "smooth" = outlier gate + RPM-aware noise - // model; "legacy" = the pre-0009 filter for A/B against older logs; - // "raw" = no filtering at all, so a track session shows exactly what the - // pickup delivers. Anything else reads as "smooth". - { "tach_filter", "smooth" }, - // Panel colours. "normal" is lit-on-black, exactly as shipped; "inverted" - // swaps lit and unlit pixels for glare/daylight readability. - { "display_invert", "normal" }, - // Race-rotation debug pages (GPS/RF DEBUG + GPS STATS). "hide" (default) - // 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). Read on every channel since 4.1.0 — - // BIRDSEYE_ENABLE_NEOPIXEL defaults to 1, so these are live - // settings on a stock logger, not beta-only bookkeeping. - { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled - // The SHIFT/warning point, not a limiter (plan 0013 renamed it from - // "rev_limit", which taught every new user the wrong thing — - // overrev_limit below is the actual problem limit). LED scale - // ceiling + the `rpm` status-LED flasher. - { "target_rpm", "15000" }, - // Plan 0007: the PROBLEM limit (0 = disabled) — whole LED chain - // flashes red past it, and the tach page's OVER REV header. - { "overrev_limit", "0" }, -#if BIRDSEYE_ENABLE_SENSOREGG - // Temp1 alert threshold in Celsius. SensorEgg builds only (plan - // 0013): with the POC compiled out there is no probe to have a - // 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 - // led_status::modeName(): off / rpm / speed / gps / camera / lap / - // sector / egt. `egt` renders dark on a build without SensorEgg - // support — the setting still round-trips to the companion app. - { "led_status_left", "rpm" }, -#if BIRDSEYE_ENABLE_SENSOREGG - { "led_status_right", "egt" }, -#else - { "led_status_right", "lap" }, -#endif - { "target_speed_mph", "60" }, // mph on device; the app converts - // Plan 0010: minutes east of UTC (US Central standard = -360, India - // = 330). PRESENTATION ONLY — logged timestamps stay UTC. The only - // consumer today is the LED day/night brightness swap below, which - // needs "7am" to mean the driver's 7am. NO DST: a fixed offset walks - // an hour twice a year, which is noise for a dim-after-dark gate. - { "utc_offset_min", "0" }, - // Night brightness cap and the LOCAL wall-clock hours the swap - // happens on. Equal hours disable the swap (led_brightness applies - // around the clock). 0 = strip dark at night, but the 5 V rail - // stays up — only led_brightness 0 cuts the rail. - { "led_brightness_night", "16" }, - { "led_day_start_hour", "7" }, - { "led_night_start_hour", "19" }, - }; - - char buf[48]; - for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) { - // Every key is a full open + read + parse of the file (and a - // read-modify-write when missing) — ~25 round trips at 2 MHz inside - // SETTINGS_SETUP(), with a WDT carried over a soft reset counting. - wdtPet(); - if (!getSetting(defaults[i].key, buf, sizeof(buf))) { - setSetting(defaults[i].key, defaults[i].defaultValue); - } - } - - // device_name needs random generation, not a static default - if (!getSetting("device_name", buf, sizeof(buf))) { - char dev[32]; - generateDeviceName(dev, sizeof(dev)); - setSetting("device_name", dev); - } - - // BLE keys need random generation, not static defaults - if (!getSetting("bluetooth_name", buf, sizeof(buf))) { - char name[32]; - snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); - setSetting("bluetooth_name", name); - } - if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { - char pin[5]; - snprintf(pin, sizeof(pin), "%04d", (int)random(1000, 10000)); - setSetting("bluetooth_pin", pin); - } -} - -/** - * @brief Quarantine a corrupt settings file and regenerate defaults. - * The unparseable file is renamed to /SETTINGS.json.bad (overwriting any - * earlier quarantine) so it can be inspected on a computer, then a fresh - * default file is created. Before this, a corrupt file was a dead end: - * setSetting() aborted on the parse error and createDefaultSettings() only - * ran when the file didn't exist, so every setting silently failed forever - * with no on-device recovery. - * @return true when a fresh default file is in place - */ -static bool settingsQuarantineCorrupt() { - debugln(F("Settings: corrupt file — quarantining to /SETTINGS.json.bad")); - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for quarantine")); - return false; - } - if (SD.exists(SETTINGS_BAD_PATH)) { - SD.remove(SETTINGS_BAD_PATH); // one quarantine slot — overwrite the old one - } - if (!SD.rename(SETTINGS_FILE_PATH, SETTINGS_BAD_PATH)) { - // Rename failing (odd FAT state) must not leave the corrupt file in - // place — a fresh default file matters more than preserving the bad one. - SD.remove(SETTINGS_FILE_PATH); - } - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return createDefaultSettings(); -} - -/** - * @brief True when /SETTINGS.json is readable and parses as JSON. An empty - * or missing file counts as fine — those self-heal through the existing - * default-population paths; only a non-empty unparseable file is corrupt. - */ -static bool settingsFileParses() { - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) return true; // can't check now - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return true; - } - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - if (bytesRead <= 0) return true; // empty: ensureDefaultSettings rebuilds it - settingsFileBuffer[bytesRead] = '\0'; - settingsJson.clear(); - return deserializeJson(settingsJson, settingsFileBuffer) == DeserializationError::Ok; -} - -/** - * @brief Initialize settings subsystem. Creates default file on first boot. - * Call once from setup() after SD is initialized. - * @return true if settings file exists (or was created) - */ -bool SETTINGS_SETUP() { - // Seed random for generating default BLE name and PIN - // WARNING: Do NOT use analogRead() on ANY pin here! On nRF52840, analogRead() - // permanently disables the digital input buffer on the target pin: - // A0 = D0 (tach ISR), A1-A3 = buttons, A4 = SDA, A5 = SCL - // micros() provides sufficient entropy — this only runs on first boot. - randomSeed(micros()); - - if (!sdSetupSuccess) { - debugln(F("Settings: SD not available, skipping")); - return false; - } - - if (!SD.exists(SETTINGS_FILE_PATH)) { - debugln(F("Settings: First boot, creating defaults")); - return createDefaultSettings(); - } - - // A corrupt (hand-edited) file used to poison every get/set forever — - // quarantine it and start fresh before the missing-key pass. - if (!settingsFileParses()) { - return settingsQuarantineCorrupt(); - } - - debugln(F("Settings: File exists, checking for missing keys")); - ensureDefaultSettings(); - return true; -} - -/** - * @brief Read a setting value by key - * @param key JSON key to look up - * @param buf Caller-provided buffer for the value (null-terminated) - * @param bufSize Size of buf (value truncated to bufSize-1 if needed) - * @return true if key found and copied, false on any failure - */ -bool getSetting(const char* key, char* buf, size_t bufSize) { - buf[0] = '\0'; - - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for read")); - return false; - } - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - debugln(F("Settings: Cannot open file for read")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - if (bytesRead <= 0) { - debugln(F("Settings: Empty or unreadable file")); - return false; - } - settingsFileBuffer[bytesRead] = '\0'; - - settingsJson.clear(); - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: JSON parse error: ")); - debugln(err.c_str()); - return false; - } - - if (!settingsJson.containsKey(key)) { - debug(F("Settings: Key not found: ")); - debugln(key); - return false; - } - - const char* value = settingsJson[key]; - if (value == nullptr) return false; - - strncpy(buf, value, bufSize - 1); - buf[bufSize - 1] = '\0'; - return true; -} - -/** - * @brief Write or update a setting value - * @param key JSON key to set - * @param value String value to store - * @return true on success, false on failure - */ -/** - * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). - * @return true on success - */ -bool resetSettings() { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for reset")); - return false; - } - - if (SD.exists(SETTINGS_FILE_PATH)) { - SD.remove(SETTINGS_FILE_PATH); - } - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debugln(F("Settings: Deleted, recreating defaults")); - return createDefaultSettings(); -} - -static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for write")); - return false; - } - - // Read existing file first - settingsJson.clear(); - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (settingsFile) { - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - if (bytesRead > 0) { - settingsFileBuffer[bytesRead] = '\0'; - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: Parse error on read-modify-write: ")); - debugln(err.c_str()); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - // Corrupt mid-session (boot already healed once): quarantine + - // regenerate, then apply this write to the fresh file. Single - // retry — the regenerated file always parses, so no recursion. - if (healCorrupt && settingsQuarantineCorrupt()) { - return setSettingInner(key, value, false); - } - return false; - } - } - } - - // Update the key - settingsJson[key] = value; - - // Refuse a write that could not be read back afterwards. Two ways it - // could quietly destroy the whole file: - // overflowed() - the document ran out of slots, so the assignment - // above silently did nothing and serializing now would drop the - // key. (The flag is sticky, but settingsJson.clear() above resets - // it, so here it can only mean THIS parse+assign.) - // measureJson() - the result exceeds settingsFileBuffer. Every read - // path caps at sizeof(settingsFileBuffer) - 1, so such a file - // parses as IncompleteInput and destroys ALL settings, not just - // this key — and the boot-time corrupt-file heal then loops - // forever. measureJson() needs no scratch buffer, which matters - // here: settingsFileBuffer is off limits because the zero-copy - // parse above left the document pointing INTO it. - // Refusing one write leaves the existing file intact and readable. - if (settingsJson.overflowed() || - measureJson(settingsJson) > sizeof(settingsFileBuffer) - 1) { - debug(F("Settings: REFUSING write, would not be readable back: ")); - debugln(key); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - // Write back (truncate and rewrite) - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to open file for write")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Saved ")); - debug(key); - debug(F(" = ")); - debugln(value); - - return true; -} - -bool setSetting(const char* key, const char* value) { - return setSettingInner(key, value, /*healCorrupt=*/true); -} - -bool removeSetting(const char* key) { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for remove")); - return false; - } - - settingsJson.clear(); - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; // nothing to remove from - } - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - if (bytesRead <= 0) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - settingsFileBuffer[bytesRead] = '\0'; - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: Parse error on remove: ")); - debugln(err.c_str()); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; // deliberately NOT healed here — see the header note - } - - if (!settingsJson.containsKey(key)) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return true; // already gone: the caller's post-condition holds - } - settingsJson.remove(key); - - // No size guard needed: a removal can only shrink the file, so it can - // never cross the read cap that setSettingInner() has to defend. - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to open file for remove")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Removed ")); - debugln(key); - return true; -} +/////////////////////////////////////////// +// SETTINGS MODULE +// Persistent JSON key-value store on SD card (/SETTINGS.json) +// Provides getSetting() and setSetting() for any subsystem. +/////////////////////////////////////////// + +#include "settings.h" +#include "remote_auth.h" + +static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json"; +// Quarantine target for a corrupt (non-empty, unparseable) settings file — +// typically a hand-edit gone wrong. The bad file is KEPT (renamed, not +// deleted) so its contents can be inspected/salvaged on a computer; a +// pre-existing .bad from an earlier quarantine is overwritten. +static const char SETTINGS_BAD_PATH[] = "/SETTINGS.json.bad"; +// Sized for the whole settings file plus its NUL, with real headroom: +// every read path below caps at sizeof(settingsFileBuffer) - 1, so a +// file larger than this parses as IncompleteInput and EVERY key read +// fails. That failure is self-inflicting — SETTINGS_SETUP quarantines +// the "corrupt" file and regenerates it, ensureDefaultSettings grows it +// back over the cap, and the device loses its settings on a loop. +// Raised 512 -> 1024 in plan 0010: the 18-key file was already 436 B and +// the four new keys put it at 543. The document is the other wall — 22 +// string pairs need JSON_OBJECT_SIZE(22) and a <512> doc returns +// NoMemory. Keep the two numbers equal, and see the measureJson() guard +// in setSettingInner() before adding more keys. +static char settingsFileBuffer[SETTINGS_JSON_CAPACITY]; +static StaticJsonDocument settingsJson; + +// 12 racing-adjacent words used to build a friendly default device name so +// logs dumped from a fleet of devices stay distinguishable. The literals +// live in flash and the pointer table is tiny (~48 bytes), so this costs +// almost no RAM. A default name is two of these picked at random. +static const char* const kDeviceNameWords[] = { + "Apex", "Turbo", "Nitro", "Drift", "Slick", "Boost", + "Piston", "Vortex", "Camber", "Draft", "Redline", "Gearbox" +}; + +/** + * @brief Compose a random default device name (e.g. "ApexTurbo") by picking + * two distinct racing words from kDeviceNameWords. + * @param buf Caller-provided buffer for the name (null-terminated) + * @param bufSize Size of buf + */ +static void generateDeviceName(char* buf, size_t bufSize) { + const int count = (int)(sizeof(kDeviceNameWords) / sizeof(kDeviceNameWords[0])); + int i = (int)random(0, count); + int j = (int)random(0, count - 1); + if (j >= i) j++; // map onto the remaining words so the two always differ + snprintf(buf, bufSize, "%s%s", kDeviceNameWords[i], kDeviceNameWords[j]); +} + +bool hwRandomBytes(uint8_t* out, size_t n) { +#ifdef SIM + // The sim has no RNG peripheral and must stay deterministic anyway. + for (size_t i = 0; i < n; i++) out[i] = (uint8_t)random(0, 256); + return true; +#else + uint8_t sdEnabled = 0; + (void)sd_softdevice_is_enabled(&sdEnabled); + if (sdEnabled) { + // The SoftDevice owns the RNG peripheral once it is up; its pool refills + // in tens of microseconds per byte, so 50 ms is a generous bound. + const uint32_t start = millis(); + size_t got = 0; + while (got < n) { + uint8_t avail = 0; + (void)sd_rand_application_bytes_available_get(&avail); + if (avail > 0) { + uint8_t take = (uint8_t)((n - got) < avail ? (n - got) : avail); + if (sd_rand_application_vector_get(out + got, take) == NRF_SUCCESS) got += take; + } else if (millis() - start > 50) { + return false; + } + } + return true; + } + // No SoftDevice yet (first boot on a stock build): drive the peripheral + // directly, with bias correction on. + NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Msk; + NRF_RNG->EVENTS_VALRDY = 0; + NRF_RNG->TASKS_START = 1; + for (size_t i = 0; i < n; i++) { + while (NRF_RNG->EVENTS_VALRDY == 0) { + } + out[i] = (uint8_t)NRF_RNG->VALUE; + NRF_RNG->EVENTS_VALRDY = 0; + } + NRF_RNG->TASKS_STOP = 1; + return true; +#endif +} + +void generateBlePin(char* out) { + uint8_t r[4] = {0}; + if (!hwRandomBytes(r, sizeof(r))) { + // Only reachable with the SoftDevice up and its pool wedged; a PIN is + // still required, so fall back rather than leave the key missing. + for (size_t i = 0; i < sizeof(r); i++) r[i] = (uint8_t)random(0, 256); + } + remote_auth::pinFromRandom(((uint32_t)r[0] << 24) | ((uint32_t)r[1] << 16) | + ((uint32_t)r[2] << 8) | r[3], + out); +} + +/** + * @brief Generate default settings file on first boot + * @return true if file created successfully + */ +bool createDefaultSettings() { + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD access for defaults")); + return false; + } + + settingsJson.clear(); + + // Generate random BLE name: DovesDataLogger-XXX + char nameSuffix[4]; + int randVal = random(0, 1000); + snprintf(nameSuffix, sizeof(nameSuffix), "%03d", randVal); + + char defaultName[32]; + snprintf(defaultName, sizeof(defaultName), "DovesDataLogger-%s", nameSuffix); + settingsJson["bluetooth_name"] = defaultName; + + // Random 4-digit PIN from the hardware RNG (plan 0019): it gates remote + // transfer now, so random() seeded from micros() is not good enough. + char defaultPin[remote_auth::kPinLen + 1]; + generateBlePin(defaultPin); + settingsJson["bluetooth_pin"] = defaultPin; + + // Random racing-themed device name so multi-device log dumps stay sorted + char deviceName[32]; + generateDeviceName(deviceName, sizeof(deviceName)); + settingsJson["device_name"] = deviceName; + + // New default settings + settingsJson["driver_name"] = "Driver"; + settingsJson["lap_detection_distance"] = "7"; + settingsJson["waypoint_detection_distance"] = "30"; + settingsJson["waypoint_speed"] = "30"; + + // Camera pairing: empty until an Insta360 serial is captured/entered + settingsJson["camera_serial"] = ""; + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to create file")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Created defaults - name: ")); + debug(defaultName); + debug(F(", pin: ")); + debugln(defaultPin); + + return true; +} + +/** + * @brief Ensure all expected settings keys exist, adding missing ones with defaults. + * Called when settings file already exists to handle firmware upgrades that add new keys. + */ +static void ensureDefaultSettings() { + // Plan 0013 renamed `rev_limit` to `target_rpm`. Migrate BEFORE the + // defaults table runs: the table only writes a key that is missing, so + // once target_rpm exists it is left alone — but if we let the table go + // first it would stamp the 15000 default over a user's tuned 7550 and + // the old value would be gone. The old key is only dropped once the + // new one is confirmed written, so a failed or refused write leaves the + // device exactly as it was rather than silently resetting its shift + // point. + { + char legacyBuf[48]; + if (!getSetting("target_rpm", legacyBuf, sizeof(legacyBuf)) && + getSetting("rev_limit", legacyBuf, sizeof(legacyBuf))) { + debug(F("Settings: migrating rev_limit -> target_rpm = ")); + debugln(legacyBuf); + if (setSetting("target_rpm", legacyBuf)) { + removeSetting("rev_limit"); + } + } + } + + // Table of all expected settings with their defaults + static const struct { const char* key; const char* defaultValue; } defaults[] = { + { "driver_name", "Driver" }, + { "lap_detection_distance", "7" }, + { "waypoint_detection_distance", "30" }, + { "waypoint_speed", "30" }, + { "camera_serial", "" }, // empty = no Insta360 paired + { "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range + // Engine geometry for true RPM (plans 0003, 0014). spark_mode is the ONLY + // one of these two that reaches the RPM math — one clamp on one plug wire + // sees one cylinder's ignition regardless of what the engine has. + { "spark_mode", "wasted" }, // "wasted" = 1 spark/rev (2T or 4T wasted); "single" = 1 per 2 revs + // The engine's ACTUAL cylinder count — no pickup-placement mind game, and + // no longer a divider (plan 0014). Descriptive: >1 means crank speed is + // inferred from one cylinder's firing rate, which is what the settings UI + // warns about. + { "cylinder_count", "1" }, + // RPM estimator (plan 0009). "smooth" = outlier gate + RPM-aware noise + // model; "legacy" = the pre-0009 filter for A/B against older logs; + // "raw" = no filtering at all, so a track session shows exactly what the + // pickup delivers. Anything else reads as "smooth". + { "tach_filter", "smooth" }, + // Panel colours. "normal" is lit-on-black, exactly as shipped; "inverted" + // swaps lit and unlit pixels for glare/daylight readability. + { "display_invert", "normal" }, + // Race-rotation debug pages (GPS/RF DEBUG + GPS STATS). "hide" (default) + // 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). Read on every channel since 4.1.0 — + // BIRDSEYE_ENABLE_NEOPIXEL defaults to 1, so these are live + // settings on a stock logger, not beta-only bookkeeping. + { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled + // The SHIFT/warning point, not a limiter (plan 0013 renamed it from + // "rev_limit", which taught every new user the wrong thing — + // overrev_limit below is the actual problem limit). LED scale + // ceiling + the `rpm` status-LED flasher. + { "target_rpm", "15000" }, + // Plan 0007: the PROBLEM limit (0 = disabled) — whole LED chain + // flashes red past it, and the tach page's OVER REV header. + { "overrev_limit", "0" }, +#if BIRDSEYE_ENABLE_SENSOREGG + // Temp1 alert threshold in Celsius. SensorEgg builds only (plan + // 0013): with the POC compiled out there is no probe to have a + // 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 + // led_status::modeName(): off / rpm / speed / gps / camera / lap / + // sector / egt. `egt` renders dark on a build without SensorEgg + // support — the setting still round-trips to the companion app. + { "led_status_left", "rpm" }, +#if BIRDSEYE_ENABLE_SENSOREGG + { "led_status_right", "egt" }, +#else + { "led_status_right", "lap" }, +#endif + { "target_speed_mph", "60" }, // mph on device; the app converts + // Plan 0010: minutes east of UTC (US Central standard = -360, India + // = 330). PRESENTATION ONLY — logged timestamps stay UTC. The only + // consumer today is the LED day/night brightness swap below, which + // needs "7am" to mean the driver's 7am. NO DST: a fixed offset walks + // an hour twice a year, which is noise for a dim-after-dark gate. + { "utc_offset_min", "0" }, + // Night brightness cap and the LOCAL wall-clock hours the swap + // happens on. Equal hours disable the swap (led_brightness applies + // around the clock). 0 = strip dark at night, but the 5 V rail + // stays up — only led_brightness 0 cuts the rail. + { "led_brightness_night", "16" }, + { "led_day_start_hour", "7" }, + { "led_night_start_hour", "19" }, + // Plan 0019: advertise the transfer service from the main menu so the + // app can start a PIN-gated transfer remotely. "off" disables it; + // anything else means on. + { "remote_transfer", "on" }, + }; + + char buf[48]; + for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) { + // Every key is a full open + read + parse of the file (and a + // read-modify-write when missing) — ~25 round trips at 2 MHz inside + // SETTINGS_SETUP(), with a WDT carried over a soft reset counting. + wdtPet(); + if (!getSetting(defaults[i].key, buf, sizeof(buf))) { + setSetting(defaults[i].key, defaults[i].defaultValue); + } + } + + // device_name needs random generation, not a static default + if (!getSetting("device_name", buf, sizeof(buf))) { + char dev[32]; + generateDeviceName(dev, sizeof(dev)); + setSetting("device_name", dev); + } + + // BLE keys need random generation, not static defaults + if (!getSetting("bluetooth_name", buf, sizeof(buf))) { + char name[32]; + snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); + setSetting("bluetooth_name", name); + } + if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { + char pin[remote_auth::kPinLen + 1]; + generateBlePin(pin); + setSetting("bluetooth_pin", pin); + } +} + +/** + * @brief Quarantine a corrupt settings file and regenerate defaults. + * The unparseable file is renamed to /SETTINGS.json.bad (overwriting any + * earlier quarantine) so it can be inspected on a computer, then a fresh + * default file is created. Before this, a corrupt file was a dead end: + * setSetting() aborted on the parse error and createDefaultSettings() only + * ran when the file didn't exist, so every setting silently failed forever + * with no on-device recovery. + * @return true when a fresh default file is in place + */ +static bool settingsQuarantineCorrupt() { + debugln(F("Settings: corrupt file — quarantining to /SETTINGS.json.bad")); + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for quarantine")); + return false; + } + if (SD.exists(SETTINGS_BAD_PATH)) { + SD.remove(SETTINGS_BAD_PATH); // one quarantine slot — overwrite the old one + } + if (!SD.rename(SETTINGS_FILE_PATH, SETTINGS_BAD_PATH)) { + // Rename failing (odd FAT state) must not leave the corrupt file in + // place — a fresh default file matters more than preserving the bad one. + SD.remove(SETTINGS_FILE_PATH); + } + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return createDefaultSettings(); +} + +/** + * @brief True when /SETTINGS.json is readable and parses as JSON. An empty + * or missing file counts as fine — those self-heal through the existing + * default-population paths; only a non-empty unparseable file is corrupt. + */ +static bool settingsFileParses() { + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) return true; // can't check now + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return true; + } + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + if (bytesRead <= 0) return true; // empty: ensureDefaultSettings rebuilds it + settingsFileBuffer[bytesRead] = '\0'; + settingsJson.clear(); + return deserializeJson(settingsJson, settingsFileBuffer) == DeserializationError::Ok; +} + +/** + * @brief Initialize settings subsystem. Creates default file on first boot. + * Call once from setup() after SD is initialized. + * @return true if settings file exists (or was created) + */ +bool SETTINGS_SETUP() { + // Seed random for generating default BLE name and PIN + // WARNING: Do NOT use analogRead() on ANY pin here! On nRF52840, analogRead() + // permanently disables the digital input buffer on the target pin: + // A0 = D0 (tach ISR), A1-A3 = buttons, A4 = SDA, A5 = SCL + // micros() provides sufficient entropy — this only runs on first boot. + randomSeed(micros()); + + if (!sdSetupSuccess) { + debugln(F("Settings: SD not available, skipping")); + return false; + } + + if (!SD.exists(SETTINGS_FILE_PATH)) { + debugln(F("Settings: First boot, creating defaults")); + return createDefaultSettings(); + } + + // A corrupt (hand-edited) file used to poison every get/set forever — + // quarantine it and start fresh before the missing-key pass. + if (!settingsFileParses()) { + return settingsQuarantineCorrupt(); + } + + debugln(F("Settings: File exists, checking for missing keys")); + ensureDefaultSettings(); + return true; +} + +/** + * @brief Read a setting value by key + * @param key JSON key to look up + * @param buf Caller-provided buffer for the value (null-terminated) + * @param bufSize Size of buf (value truncated to bufSize-1 if needed) + * @return true if key found and copied, false on any failure + */ +bool getSetting(const char* key, char* buf, size_t bufSize) { + buf[0] = '\0'; + + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for read")); + return false; + } + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + debugln(F("Settings: Cannot open file for read")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + if (bytesRead <= 0) { + debugln(F("Settings: Empty or unreadable file")); + return false; + } + settingsFileBuffer[bytesRead] = '\0'; + + settingsJson.clear(); + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: JSON parse error: ")); + debugln(err.c_str()); + return false; + } + + if (!settingsJson.containsKey(key)) { + debug(F("Settings: Key not found: ")); + debugln(key); + return false; + } + + const char* value = settingsJson[key]; + if (value == nullptr) return false; + + strncpy(buf, value, bufSize - 1); + buf[bufSize - 1] = '\0'; + return true; +} + +/** + * @brief Write or update a setting value + * @param key JSON key to set + * @param value String value to store + * @return true on success, false on failure + */ +/** + * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). + * @return true on success + */ +bool resetSettings() { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for reset")); + return false; + } + + if (SD.exists(SETTINGS_FILE_PATH)) { + SD.remove(SETTINGS_FILE_PATH); + } + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debugln(F("Settings: Deleted, recreating defaults")); + return createDefaultSettings(); +} + +static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for write")); + return false; + } + + // Read existing file first + settingsJson.clear(); + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (settingsFile) { + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + if (bytesRead > 0) { + settingsFileBuffer[bytesRead] = '\0'; + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: Parse error on read-modify-write: ")); + debugln(err.c_str()); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + // Corrupt mid-session (boot already healed once): quarantine + + // regenerate, then apply this write to the fresh file. Single + // retry — the regenerated file always parses, so no recursion. + if (healCorrupt && settingsQuarantineCorrupt()) { + return setSettingInner(key, value, false); + } + return false; + } + } + } + + // Update the key + settingsJson[key] = value; + + // Refuse a write that could not be read back afterwards. Two ways it + // could quietly destroy the whole file: + // overflowed() - the document ran out of slots, so the assignment + // above silently did nothing and serializing now would drop the + // key. (The flag is sticky, but settingsJson.clear() above resets + // it, so here it can only mean THIS parse+assign.) + // measureJson() - the result exceeds settingsFileBuffer. Every read + // path caps at sizeof(settingsFileBuffer) - 1, so such a file + // parses as IncompleteInput and destroys ALL settings, not just + // this key — and the boot-time corrupt-file heal then loops + // forever. measureJson() needs no scratch buffer, which matters + // here: settingsFileBuffer is off limits because the zero-copy + // parse above left the document pointing INTO it. + // Refusing one write leaves the existing file intact and readable. + if (settingsJson.overflowed() || + measureJson(settingsJson) > sizeof(settingsFileBuffer) - 1) { + debug(F("Settings: REFUSING write, would not be readable back: ")); + debugln(key); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + // Write back (truncate and rewrite) + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to open file for write")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Saved ")); + debug(key); + debug(F(" = ")); + debugln(value); + + return true; +} + +bool setSetting(const char* key, const char* value) { + return setSettingInner(key, value, /*healCorrupt=*/true); +} + +bool removeSetting(const char* key) { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for remove")); + return false; + } + + settingsJson.clear(); + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; // nothing to remove from + } + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + if (bytesRead <= 0) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + settingsFileBuffer[bytesRead] = '\0'; + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: Parse error on remove: ")); + debugln(err.c_str()); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; // deliberately NOT healed here — see the header note + } + + if (!settingsJson.containsKey(key)) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return true; // already gone: the caller's post-condition holds + } + settingsJson.remove(key); + + // No size guard needed: a removal can only shrink the file, so it can + // never cross the read cap that setSettingInner() has to defend. + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to open file for remove")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Removed ")); + debugln(key); + return true; +} diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 0696e37..fae12da 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -160,6 +160,7 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sd_access_policy.cpp ${BIRDSEYE_DIR}/sd_format_page.cpp + ${BIRDSEYE_DIR}/pin_page.cpp ${BIRDSEYE_DIR}/remote_auth.cpp ${BIRDSEYE_DIR}/sd_probe.cpp ${BIRDSEYE_DIR}/sector_purple.cpp diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index 94e9f25..83214d5 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -6,7 +6,10 @@ drag_staging_await_arm -19 54f95c26 drag_staging_wait_gps -19 41e9989b main_menu_after_drag_exit -1 14716beb main_menu_transfer -1 40da09f8 -transfer_menu -4 ae15b2a8 +transfer_menu -4 58ad68c6 +transfer_menu_pin -4 b51888cd +transfer_pin_hidden -22 195ab8b5 +transfer_pin_revealed -22 612cd8e4 bluetooth_waiting -2 c0f9211b warning_no_dovex 100 f800764b warning_create_needs_gps 100 45982329 diff --git a/BirdsEye/sim/golden_main.cpp b/BirdsEye/sim/golden_main.cpp index 0ce2699..12acaf6 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -79,6 +79,7 @@ constexpr int kPageDragMode = -18; constexpr int kPageDragStaging = -19; constexpr int kPageTransferMenu = -4; constexpr int kPageBluetooth = -2; +constexpr int kPageTransferPin = -22; constexpr int kPagePairCamera = -6; constexpr int kPageCameraSerialEntry = -7; constexpr int kPageCourseTrack = -11; @@ -209,10 +210,29 @@ void runScript() { press(2); capture("main_menu_transfer", kPageMainMenu); - // Select -> Transfer submenu (Bluetooth / USB). + // Select -> Transfer submenu (Bluetooth / USB / PIN / Back). press(1); capture("transfer_menu", kPageTransferMenu); + // Plan 0019: walk to PIN (the window scrolls) and open it. The PIN is + // hidden until Select is held 3 s on Show; then it stays up for 15 s. + press(2); + press(2); + capture("transfer_menu_pin", kPageTransferMenu); + press(1); + capture("transfer_pin_hidden", kPageTransferPin); + sim_button_down(1); + sim_step_millis(3300); + sim_button_up(1); + sim_step_millis(400); + capture("transfer_pin_revealed", kPageTransferPin); + + // Back (third row) returns to the submenu with the selection on its + // first row, so the walk continues into Bluetooth as before. + press(2); + press(2); + press(1); + // Select Bluetooth -> BLE page (stub radio: waiting-for-connection UI). press(1); capture("bluetooth_waiting", kPageBluetooth); diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index 9c280ed..8a36971 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -90,6 +90,7 @@ void checkAutoIdle(); void autoRaceModeCheck(); void gpsStatusPageLoop(); void sdFormatPageLoop(); +void transferPinPageLoop(); int courseCreatorPage(); bool courseCreatorActive(); bool courseCreatorEnter(); @@ -117,6 +118,7 @@ void displayPrintDragStats(float trapMph, unsigned long split60Ms); void displayPrintSplitSeconds(unsigned long ms); void displayPage_bluetooth(); void displayPage_transfer_menu(); +void displayPage_transfer_pin(); void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index a61344c..f2f49e2 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -55,6 +55,11 @@ void bleShutdownQuiesce() {} void BLUETOOTH_LOOP() {} +// No radio, so no menu standby advert and never a remote session. +void BLE_STANDBY_LOOP() {} +void bleRemoteSessionGuard() {} +bool bleRemoteSessionActive() { return false; } + // Transfer diagnostics the Bluetooth page prints. No radio in the sim, so // the page renders its idle "waiting" state and these are never meaningful — // they exist so display_pages.ino compiles unchanged. @@ -79,6 +84,7 @@ bool cameraBleOwnsConnHandle(uint16_t) { return false; } bool cameraIsPaired() { return false; } camera_fsm::State cameraFsmState() { return camera_fsm::State::kUnpaired; } +bool cameraTestModeActive() { return false; } bool cameraRemoteLinkUp() { return false; } bool cameraAdvertisingUp() { return false; } bool cameraCe82Subscribed() { return false; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3404752..69cd330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,38 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **Remote transfer with a PIN** (plan 0019). While the logger sits on its + main menu it now advertises the transfer service, so the app can start a + download without anyone pressing Transfer → Bluetooth — once it proves it + knows the logger's PIN. The PIN never crosses the air: the logger sends a + one-time challenge and the app answers with an HMAC of it. Five wrong + answers lock remote transfer for a minute (doubling up to 15 minutes), and + a peer that never answers is dropped after 20 seconds. Leaving the menu + stops the advert, and the `remote_transfer` setting (`on` by default) + turns it off entirely. The transfer page says **Remote Transfer** when a + session started this way. +- **The camera always wins the radio.** Remote standby never advertises + while a paired Insta360 is doing anything, the camera bench page is open, + or a paired camera's engine is running; and a remote transfer ends the + moment the engine starts with a camera paired, so auto-record is never + held up. Starting Transfer → Bluetooth on the device still bumps the + camera, as before. +- **Transfer → PIN page.** Hold Select for 3 seconds on *Show PIN* to see + the PIN for 15 seconds, or on *New PIN* to replace it. With the SD card + soldered in, this (plus the USB drive and the app re-reading it on a local + Bluetooth start) is how a lost PIN is recovered. The Transfer menu now + scrolls to fit its fourth row. +- BLE commands `AUTH?`, `AUTH:` and `PINGET` (local start only). + +### Changed +- **The PIN is no longer readable over Bluetooth by whoever is connected.** + `SLIST` leaves `bluetooth_pin` out and `SGET:bluetooth_pin` answers + `SERR:PROTECTED`; the app reads it with `PINGET` on a local start + instead. `SSET:bluetooth_pin` only accepts exactly four digits. +- New PINs come from the hardware random number generator instead of + `random()` seeded with the boot time. + ### Fixed - **Drag mode review fixes** (plans 0015/0016): - A GPS fix lost mid-pass and never regained no longer wedges manual diff --git a/CLAUDE.md b/CLAUDE.md index 134df59..7f4e176 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,9 @@ Core capabilities: - Accelerometer logging (g-force X/Y/Z) via onboard LSM6DS3 IMU - DOVEX data logging with reserved 1 KB header (crash-safe GPS data) - 8+ display pages on a 128x64 OLED (3 Hz refresh) -- Bluetooth LE file download to companion apps / LapWingData.com +- Bluetooth LE file download to companion apps / LapWingData.com — + started on the device, or **remotely from the main menu behind a PIN + challenge-response** (plan 0019; the camera always wins the radio) - On-device session replay: instant DOVEX header replay - **Insta360 X4 camera auto-record**: emulates the Insta360 GPS Remote as a pure BLE peripheral — wakes the camera on engine start, records via a ce82 @@ -162,6 +164,9 @@ desktop toolchain. This is where logic worth unit-testing lives. | `gps_status_page.{h,cpp}` | GPS status boot page state machine: hold, 3 s auto-close after fix+timeValid, button skip, exit destination (menu vs race), idle → shutdown; `timeSyncState()` names which time milestone is outstanding (date/time vs the slow `fullyResolved`) | | `sd_format_page.{h,cpp}` | SD format-confirm boot page state machine: Select held 3 s continuously → format (release restarts the full window; other buttons never confirm), 5 min idle → shutdown | | `sd_probe.{h,cpp}` | Boot SD probe verdict (mounted / retry / unformatted / dead): the format offer needs the card layer to answer on **two consecutive** probes across a 250 ms settle with the volume mounting on neither — one no-volume look is what a card wedged mid-command by a WDT-interrupted boot produces — and a card-layer failure at any point is "dead" (FAULT), never "blank" | +| `sha256.{h,cpp}` | SHA-256 + HMAC-SHA256 for the remote-transfer handshake (plan 0019), pinned to FIPS 180-4 and RFC 4231 vectors — the viewer (WebCrypto) and LapWing (hmac/sha2) must agree byte-for-byte | +| `remote_auth.{h,cpp}` | Remote transfer rules (plan 0019): the challenge answer (`HMAC(PIN, "BEAUTH1|"+nonce)`, first 16 bytes as hex — golden vector shared with both apps), single-use nonce, 5-try lockout doubling 60 s → 15 min (wrap-safe), 20 s squat limit, locked-mode command gating (`AUTH?`/`AUTH:`/`BATT` only; `PINGET` open-mode only), protected setting keys + PIN format, the standby predicate and `cameraWantsRadio()` | +| `pin_page.{h,cpp}` | Transfer → PIN page state machine (plan 0019): hold Select 3 s on Show (reveal 15 s) or New PIN; release / row change / side button cancels; the entry press never counts | | `sat_bars.{h,cpp}` | Status-page satellite signal bars: NAV-SAT CNO selection (used-in-nav first, strongest first) + bar x/w/h layout math for the 128×~30 px bottom half | ### Simulator (`BirdsEye/sim/`) @@ -243,6 +248,7 @@ loop() ~250 Hz ├─ TACH_LOOP() re-enable ISR after debounce, apply EMA filter ├─ ACCEL_LOOP() read LSM6DS3 accelerometer X/Y/Z (g-force) ├─ BLUETOOTH_LOOP() stream file chunks if transfer active + ├─ BLE_STANDBY_LOOP() menu remote-transfer advert + PIN handshake (plan 0019) ├─ SENSOREGG_LOOP() drain SensorEgg scan buffer → Temp1/Junction1 ├─ trackDetectionLoop() haversine scan → create CourseManager on match ├─ checkForNewLapData() reads from active timer (CourseManager or lapTimer) @@ -557,8 +563,10 @@ loop() ~250 Hz row — see `replayItemCount()` in `replay.h`, the single source of that layout for the renderer, the menu limit and the select handler), `PAGE_REPLAY_RESULTS` (-8), `PAGE_REPLAY_EXIT` (-9). - - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB/Back submenu, - `PAGE_USB_STORAGE` (-5) USB drive active. + - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB/PIN/Back submenu + (scrolling 3-row window), `PAGE_USB_STORAGE` (-5) USB drive active, + `PAGE_TRANSFER_PIN` (-22) hold-to-reveal / hold-to-replace the BLE PIN + (`transferPinPageLoop()` + the `pin_page` unit; plan 0019). - **Every menu page carries a Back/Cancel row.** Both transfer modes and the replay browser leave by rebooting or by walking forward, and the idle-shutdown timer only runs on the main menu (and the fault page), so @@ -734,6 +742,29 @@ loop() ~250 Hz - Upload/delete state machines: BLE callback sets flags, `BLUETOOTH_LOOP()` calls `processTrackUpload()` / `processTrackDelete()` for thread-safe SD access. Both call `buildTrackList()` after success. +- **Remote transfer + PIN (plan 0019)**. `BLE_STANDBY_LOOP()` advertises + the transfer service while on `PAGE_MAIN_MENU` with the radio free, no + race, `remote_transfer` not `off`, and `!cameraWantsRadio()`. Standby + holds `bleOwner = TRANSFER` with `bleActive` FALSE, so nothing parks and a + standby peer's disconnect never reboots. The session mode + (`bleSessionMode`) is **locked**: the callback answers anything but + `AUTH?` / `AUTH:` / `BATT` with `AUTH:REQUIRED`. `AUTH?` → + `AUTH:NONCE:` (16 bytes, `hwRandomBytes()`); `AUTH:` → + `AUTH:OK` (promote: transfer page, `bleRemoteSession`, labelled *Remote + Transfer*) / `AUTH:FAIL:` / `AUTH:LOCKED:` / `AUTH:NO_NONCE` / + `AUTH:BUSY`. Unauthenticated after 20 s → `AUTH:TIMEOUT` + drop. A local + `BLE_SETUP()` is **open**: `AUTH?` → `AUTH:OPEN`, and `PINGET` → + `PIN:` is how an app pairs. `SLIST` omits `bluetooth_pin`, + `SGET:bluetooth_pin` → `SERR:PROTECTED`, `SSET:bluetooth_pin` needs four + digits (`SERR:BAD_VALUE`). **Camera first**: standby stops (an unauthed + peer gets `AUTH:CAMERA`) when the camera FSM leaves IDLE/UNPAIRED, the + bench page opens, the camera owns the radio, or a paired camera's engine + passes 500 rpm — ahead of the FSM's 2 s wake debounce, which is why the + standby step runs before `CAMERA_LOOP()`. The parked branch runs + `bleRemoteSessionGuard()` (with `TACH_LOOP()`) so a remote session ends + the same way. Remote start never calls `CAMERA_FORCE_RELEASE()`. The PIN + only blocks other people in the pits — the link is unencrypted, so a + sniffer can brute-force a captured handshake offline (see the plan). - **Firmware OTA commands** (`FW*`, handled by `firmware_ota.ino` — see subsystem 11): `FWBEGIN`/`FWPUT`/`FWDONE`/`FWAPPLY`/`FWABORT`/`FWDFU`. The BLE callback dispatches them via `fwIsCommand()`/`fwHandleCommand()` and routes @@ -2001,14 +2032,16 @@ the one loaded). Sector lines stay optional — zero, one, or two. "utc_offset_min": "0", "led_brightness_night": "16", "led_day_start_hour": "7", - "led_night_start_hour": "19" + "led_night_start_hour": "19", + "remote_transfer": "on" } ``` | Key | Type | Default | Purpose | |-----|------|---------|---------| | `bluetooth_name` | string | Random | BLE device name | -| `bluetooth_pin` | string | Random 4-digit | BLE pairing PIN | +| `bluetooth_pin` | string | Random 4-digit (hardware RNG) | Remote-transfer PIN (plan 0019). **Never readable over BLE except `PINGET` on a locally started session**; shown on-device under Transfer → PIN; `SSET` accepts exactly 4 digits | +| `remote_transfer` | string | `"on"` | Menu standby advert for PIN-gated remote transfer (plan 0019). Only an explicit `off` disables it. Read once per boot | | `camera_serial` | string | `""` (empty = unpaired) | Paired Insta360 X4's 6-char serial (auto-captured on pairing, or entered manually) | | `device_name` | string | Random racing words | Identifies the logging device (DOVEX header) | | `driver_name` | string | `"Driver"` | Logged in DOVEX header | @@ -2070,6 +2103,9 @@ the one loaded). Sector lines stay optional — zero, one, or two. | Onboard charging (HICHG hold + USB charge UX) | `BIRDSEYE_ENABLE_ONBOARD_CHARGING`, default 0 (all channels) | `project.h` | | SensorEgg wireless EGT POC | `BIRDSEYE_ENABLE_SENSOREGG`, default 0; 1 on the beta channel | `project.h` | | Charging screen timeout | 10 s (`CHARGE_DISPLAY_TIMEOUT_MS`) | `project.h` | +| Remote auth lockout | 5 wrong answers → 60 s, doubling per lockout to 15 min; RAM only | `remote_auth.h` | +| Remote auth squat limit | 20 s connected without `AUTH:OK` → dropped | `remote_auth.h` | +| Transfer PIN page | hold Select 3 s; reveal lasts 15 s | `pin_page.h` | | Sat bars display cap / CNO ceiling | 16 bars / 50 dB-Hz | `sat_bars.h` | | Crossing threshold | 7.0 m | `BirdsEye.ino` | | Max laps/session | 1 000 | `BirdsEye.ino` | diff --git a/README.md b/README.md index 7e5d51e..974fd0c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto | Setting | Description | Default | |---|---|---| | `bluetooth_name` | BLE device name visible during pairing | Random (e.g. `DovesDataLogger-042`) | -| `bluetooth_pin` | PIN displayed on device for webapp pairing | Random 4-digit | +| `bluetooth_pin` | PIN the app proves it knows before a remote transfer. See it on the device under Transfer → PIN (hold Select 3 s). Never sent over Bluetooth except to an app connected through a transfer started on the device | Random 4-digit | +| `remote_transfer` | `on`: advertise from the main menu so the app can start a PIN-gated transfer remotely. `off`: only Transfer → Bluetooth on the device starts one | `on` | | `race_mode` | Circuit-vs-sprint tiebreak when both track kinds are within detection range (`sprint` always prefers the sprint track; `circuit` yields only on an event day) | `circuit` | | `driver_name` | Driver name logged in DOVEX session header | `Driver` | | `lap_detection_distance` | Crossing detection threshold in meters | `7` | @@ -275,7 +276,7 @@ DovesDataLogger/ # repo root │ ├── gps_config.h # GPS configuration constants (baud, nav rate) │ ├── images.h # PROGMEM bitmap data (splash, animations) │ ├── accelerometer.{h,ino} # LSM6DS3 IMU init and g-force reads -│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync) +│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync, remote transfer) │ ├── display_pages.{h,ino} # All page rendering functions (displayPage_*()) │ ├── display_ui.{h,ino} # Display init, button handling, menu navigation │ ├── gps_functions.{h,ino} # GPS init, PVT callback, time conversion, logging diff --git a/docs/plans/0019-remote-transfer-pin.md b/docs/plans/0019-remote-transfer-pin.md index 684339e..0cc951e 100644 --- a/docs/plans/0019-remote-transfer-pin.md +++ b/docs/plans/0019-remote-transfer-pin.md @@ -1,6 +1,6 @@ # 0019 — Remote transfer with a paired PIN -Status: **in progress** (logger side). Companion plans: DovesDataViewer +Status: **implemented, pending hardware check** (logger side). Companion plans: DovesDataViewer `docs/plans/0031-remote-transfer-pin.md`; LapWing implements the same handshake in `loggers/doveslogger/` (no plan folder there). @@ -48,15 +48,23 @@ app → AUTH? log → AUTH:NONCE:<32 hex> (locked) | AUTH:OPEN (open) | AUTH:LOCKED: | AUTH:CAMERA app → AUTH: + msg = "BEAUTH1|" + nonceHex)> log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE + | AUTH:BUSY (RNG pool empty, or an answer already queued) ``` - The PIN never crosses the air; a recorded exchange can't be replayed because the nonce is single-use (consumed on any answer, right or wrong) and a new `AUTH?` discards the previous one. - Nonce = 16 bytes from the SoftDevice RNG (`sd_rand_application_vector_get`). -- `bluetooth_name` in the MAC binds the answer to this logger. +- **Not bound to `bluetooth_name`** (changed during implementation). The + design artifact mixed the name into the MAC, but the nonce is already + unique to this logger and this attempt, and the advertised name can be + truncated on air (31-byte advert), so an app could hold a correct PIN and + still compute the wrong answer. Two correct implementations disagreeing + buys nothing. +- Golden vector, pinned in all three implementations: PIN `4821`, nonce + `00112233445566778899aabbccddeeff` → `4936f5dad4502101eb0f65790cca77b4`. - Lowercase hex on the wire; the logger compares case-insensitively and in constant time. @@ -69,6 +77,7 @@ log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE | `SGET:bluetooth_pin` | `SERR:PROTECTED` | All modes — use `PINGET` | | `SSET:bluetooth_pin=…`, `SRESET` | as before | Open, or locked after `AUTH:OK`. PIN must be exactly 4 digits (`SERR:BAD_VALUE`) | | (unsolicited) | `AUTH:CAMERA` | Right before the logger drops a link because the camera needs the radio | +| (unsolicited) | `AUTH:TIMEOUT` | Right before a peer that never authenticated is dropped (20 s) | ## Logger rules @@ -77,6 +86,11 @@ log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE stops the advert. Auto-race and the 5-minute menu idle shutdown are unchanged, so standby lasts exactly as long as the menu does. - **Squatting:** a peer without `AUTH:OK` after 20 s is disconnected. +- **State:** standby holds `bleOwner = TRANSFER` with `bleActive` false — + nothing parks, and a standby peer's disconnect never reboots. `AUTH:OK` + promotes into the ordinary transfer page (`bleActive`, reboot on + disconnect) with the session still locked-and-authenticated, so `PINGET` + stays off. - **Guessing:** 5 wrong answers lock auth for 60 s, doubling per lockout up to 15 min. RAM-only — a reboot clears it, which needs physical access. - **PIN generation** moves to the hardware RNG. @@ -91,10 +105,12 @@ for it. test page open, OR `bleOwner == CAMERA`. No standby advert while active. - Camera turns active during standby → advert stops that iteration; an unauthenticated peer gets `AUTH:CAMERA` and is dropped. -- Engine starts during a **remote** transfer with a paired camera → RPM above - 500 (the camera wake threshold) ends the session (`AUTH:CAMERA`, then the - usual reboot). The transfer branch parks `loop()`, so the remote branch - runs `TACH_LOOP()` to see it. +- Engine starts with a paired camera → RPM above 500 (the camera wake + threshold) stops standby, or ends a **remote** transfer (`AUTH:CAMERA`, + then the usual reboot). This fires ahead of the camera FSM's own 2 s wake + debounce, and the standby step runs before `CAMERA_LOOP()`, so the camera + never finds the radio taken. The transfer branch parks `loop()`, so the + remote guard runs `TACH_LOOP()` itself. - Remote start **never** calls `CAMERA_FORCE_RELEASE()`. Only a person at the logger choosing Transfer → Bluetooth bumps the camera (today's rule). - Consequence: after a camera session the camera stays in `WATCHING` until @@ -104,9 +120,11 @@ for it. Never on the transfer screen. Every path needs the device in hand. -- **Transfer → PIN** (new row between USB and Back): hold Select 3 s to - reveal for 15 s, then it hides. A **New PIN** row (hold-to-confirm) - regenerates it; apps forget the old one on their next `AUTH:FAIL`. +- **Transfer → PIN** (new row between USB and Back; the Transfer menu now + scrolls): rows *Show PIN*, *New PIN*, *Back*. Hold Select 3 s on Show to + reveal for 15 s, then it hides and the digits are wiped from RAM. Hold 3 s + on New PIN to regenerate it (persist-first, then shown). Apps forget the + old one on their next `AUTH:FAIL`. - **Local Bluetooth transfer:** the app re-learns it via `PINGET`. - **USB drive:** Transfer → USB exposes `SETTINGS.json`. @@ -135,20 +153,26 @@ encrypted, bonded link (LE Secure Connections) — a separate project. - Pure units + host tests: `sha256` (FIPS 180-4 + RFC 4231 HMAC vectors), `remote_auth` (nonce/answer/lockout/timeout state machine, command gating, - protected-key and PIN-format rules, standby/camera predicates). + protected-key and PIN-format rules, standby/camera predicates), + `pin_page` (the hold-to-reveal page). - `bluetooth.ino`: AUTH dispatch, locked-mode gate, settings redaction, standby advert + teardown, remote session promotion. - `settings.ino`: hardware-RNG PIN, `remote_transfer` default. - `BirdsEye.ino`: standby step in `loop()`, remote parked-branch camera check. -- `display_ui.ino` / `display_pages.ino`: PIN page. +- `display_ui.ino` / `display_pages.ino`: PIN page; `BirdsEye.ino` + `transferPinPageLoop()`. +- Sim: stubs for the new BLE surface, goldens for the PIN page (the + transfer menu's hash moved with its fourth row). - `camera_ble.{h,ino}`: camera-active accessor (+ sim stub). - Docs: `CLAUDE.md`, `ARCHITECTURE.md`, `CHANGELOG.md`. ## Status -- [ ] Plan committed -- [ ] PIN redaction, `PINGET`, write gates, hardware RNG -- [ ] Auth pure units + tests -- [ ] Standby advertising, locked mode, camera priority -- [ ] PIN page -- [ ] Docs + changelog +- [x] Plan committed +- [x] PIN redaction, `PINGET`, write gates, hardware RNG +- [x] Auth pure units + tests +- [x] Standby advertising, locked mode, camera priority +- [x] PIN page +- [x] Docs + changelog +- [ ] Hardware check: a phone authenticating from the menu, the lockout, + and the camera taking the radio back with the engine running diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 182a1b4..6662549 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -49,9 +49,11 @@ add_executable(birdseye_tests drag_tree_test.cpp sha256_test.cpp remote_auth_test.cpp + pin_page_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/sha256.cpp ${BIRDSEYE_DIR}/remote_auth.cpp + ${BIRDSEYE_DIR}/pin_page.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp ${BIRDSEYE_DIR}/gps_validation.cpp diff --git a/tests/pin_page_test.cpp b/tests/pin_page_test.cpp new file mode 100644 index 0000000..c0e34a7 --- /dev/null +++ b/tests/pin_page_test.cpp @@ -0,0 +1,99 @@ +#include "doctest.h" + +#include "pin_page.h" + +using namespace pin_page; + +namespace { + +Action press(State& s, int row, uint32_t t, bool held = true, bool other = false) { + Inputs in; + in.selectHeld = held; + in.otherButtonHeld = other; + in.row = row; + in.nowMs = t; + return step(s, in); +} + +} // namespace + +TEST_CASE("the press that opened the page never counts") { + State s; + begin(s); + for (uint32_t t = 0; t <= 5000; t += 100) CHECK(press(s, kRowShow, t) == Action::kNone); + CHECK(holdSecondsLeft(s, 5000) == 0); +} + +TEST_CASE("hold Select 3 s on Show reveals, once per hold") { + State s; + begin(s); + press(s, kRowShow, 0, false); + CHECK(press(s, kRowShow, 1000) == Action::kNone); + CHECK(holdSecondsLeft(s, 1000) == 3); + CHECK(holdSecondsLeft(s, 3500) == 1); + CHECK(press(s, kRowShow, 3999) == Action::kNone); + CHECK(press(s, kRowShow, 4000) == Action::kReveal); + // Still held: no repeat. + CHECK(press(s, kRowShow, 9000) == Action::kNone); +} + +TEST_CASE("hold on New PIN asks for a new PIN") { + State s; + begin(s); + press(s, kRowNewPin, 0, false); + press(s, kRowNewPin, 10); + CHECK(press(s, kRowNewPin, 3010) == Action::kNewPin); +} + +TEST_CASE("release, row change or a side button cancels with no credit") { + State s; + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowShow, 2000, false); // released + press(s, kRowShow, 2100); + CHECK(press(s, kRowShow, 4000) == Action::kNone); // only 1.9 s since re-hold + + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowNewPin, 2000); // moved + CHECK(press(s, kRowNewPin, 4000) == Action::kNone); + CHECK(press(s, kRowNewPin, 5000) == Action::kNewPin); + + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowShow, 1000, true, true); // reboot combo + CHECK(press(s, kRowShow, 3200) == Action::kNone); +} + +TEST_CASE("Back never arms a hold") { + State s; + begin(s); + press(s, kRowBack, 0, false); + for (uint32_t t = 0; t <= 6000; t += 100) CHECK(press(s, kRowBack, t) == Action::kNone); +} + +TEST_CASE("reveal lasts 15 s and wraps safely") { + State s; + begin(s); + CHECK_FALSE(isRevealed(s, 0)); + reveal(s, 1000); + CHECK(isRevealed(s, 1000)); + CHECK(revealSecondsLeft(s, 1000) == 15); + CHECK(revealSecondsLeft(s, 15999) == 1); + CHECK_FALSE(isRevealed(s, 16000)); + CHECK(revealSecondsLeft(s, 16000) == 0); + + reveal(s, 0xffffffffu - 1000u); + CHECK(isRevealed(s, 5000u)); // wrapped, 6 s in + CHECK_FALSE(isRevealed(s, 14000u)); +} + +TEST_CASE("begin hides the PIN") { + State s; + reveal(s, 0); + begin(s); + CHECK_FALSE(isRevealed(s, 1)); +} diff --git a/tests/remote_auth_test.cpp b/tests/remote_auth_test.cpp index 5ffc90d..339c752 100644 --- a/tests/remote_auth_test.cpp +++ b/tests/remote_auth_test.cpp @@ -11,23 +11,23 @@ namespace { const uint8_t kNonceA[kNonceLen] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}; -// The answer for kNonceA / "4821" / "DovesDataLogger-042". The SAME vector is +// The answer for kNonceA / "4821". The SAME vector is // pinned in DovesDataViewer (src/lib/ble/auth.test.ts) and LapWing // (loggers/doveslogger/auth.rs) — change all three together or none. -const char kGoldenAnswer[] = "2cc61d4ff111ba3236b50217443c3daa"; +const char kGoldenAnswer[] = "4936f5dad4502101eb0f65790cca77b4"; void answerFor(const State& s, const char* pin, char* out) { - computeAnswer(pin, s.nonceHex, "DovesDataLogger-042", out); + computeAnswer(pin, s.nonceHex, out); } } // namespace TEST_CASE("answer matches the cross-implementation golden vectors") { char out[kAnswerHexLen + 1]; - computeAnswer("4821", "00112233445566778899aabbccddeeff", "DovesDataLogger-042", out); + computeAnswer("4821", "00112233445566778899aabbccddeeff", out); CHECK(std::strcmp(out, kGoldenAnswer) == 0); - computeAnswer("1000", "ffffffffffffffffffffffffffffffff", "BirdsEye", out); - CHECK(std::strcmp(out, "1918b8ddb0591dd12fd31eb2ef04ba33") == 0); + computeAnswer("1000", "ffffffffffffffffffffffffffffffff", out); + CHECK(std::strcmp(out, "3db7fac4dc48559fa85594079b59fce4") == 0); } TEST_CASE("PIN format") { @@ -66,29 +66,29 @@ TEST_CASE("right answer authenticates and consumes the nonce") { State s; issueNonce(s, kNonceA); CHECK(std::strcmp(s.nonceHex, "00112233445566778899aabbccddeeff") == 0); - CHECK(checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0) == Verdict::kOk); + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kOk); CHECK(s.authed); // Replay of the same answer: the nonce is gone. - CHECK(checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0) == Verdict::kNoNonce); + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kNoNonce); } TEST_CASE("uppercase answer is accepted") { State s; issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "2CC61D4FF111BA3236B50217443C3DAA", "4821", "DovesDataLogger-042", 0) == + CHECK(checkAnswer(s, "4936F5DAD4502101EB0F65790CCA77B4", "4821", 0) == Verdict::kOk); } -TEST_CASE("answer is bound to the device name") { +TEST_CASE("answer is bound to the PIN") { State s; issueNonce(s, kNonceA); - CHECK(checkAnswer(s, kGoldenAnswer, "4821", "SomeOtherLogger", 0) == Verdict::kFail); + CHECK(checkAnswer(s, kGoldenAnswer, "4822", 0) == Verdict::kFail); } TEST_CASE("wrong answer consumes the nonce and counts down") { State s; issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", 0) == Verdict::kFail); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", 0) == Verdict::kFail); CHECK(triesLeft(s) == kMaxFails - 1); CHECK_FALSE(s.nonceValid); CHECK_FALSE(s.authed); @@ -97,9 +97,9 @@ TEST_CASE("wrong answer consumes the nonce and counts down") { TEST_CASE("malformed answers count as wrong") { State s; issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "short", "4821", "x", 0) == Verdict::kFail); + CHECK(checkAnswer(s, "short", "4821", 0) == Verdict::kFail); issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "zzc61d4ff111ba3236b50217443c3daa", "4821", "x", 0) == Verdict::kFail); + CHECK(checkAnswer(s, "zzc61d4ff111ba3236b50217443c3daa", "4821", 0) == Verdict::kFail); CHECK(triesLeft(s) == kMaxFails - 2); } @@ -107,8 +107,8 @@ TEST_CASE("a stored PIN that is not 4 digits never authenticates") { State s; issueNonce(s, kNonceA); char ans[kAnswerHexLen + 1]; - computeAnswer("", s.nonceHex, "x", ans); - CHECK(checkAnswer(s, ans, "", "x", 0) == Verdict::kFail); + computeAnswer("", s.nonceHex, ans); + CHECK(checkAnswer(s, ans, "", 0) == Verdict::kFail); } TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { @@ -116,10 +116,10 @@ TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { uint32_t now = 1000; for (uint8_t i = 0; i + 1 < kMaxFails; ++i) { issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now) == Verdict::kFail); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", now) == Verdict::kFail); } issueNonce(s, kNonceA); - CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now) == Verdict::kLocked); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", now) == Verdict::kLocked); CHECK(isLocked(s, now)); CHECK(lockSecondsLeft(s, now) == 60); CHECK(lockSecondsLeft(s, now + 59001) == 1); @@ -128,7 +128,7 @@ TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { issueNonce(s, kNonceA); char ans[kAnswerHexLen + 1]; answerFor(s, "4821", ans); - CHECK(checkAnswer(s, ans, "4821", "DovesDataLogger-042", now + 1000) == Verdict::kLocked); + CHECK(checkAnswer(s, ans, "4821", now + 1000) == Verdict::kLocked); CHECK_FALSE(s.nonceValid); now += 60000; @@ -147,7 +147,7 @@ TEST_CASE("lockout survives millis() wrap") { const uint32_t now = 0xffffffffu - 10000u; for (uint8_t i = 0; i < kMaxFails; ++i) { issueNonce(s, kNonceA); - checkAnswer(s, "00000000000000000000000000000000", "4821", "x", now); + checkAnswer(s, "00000000000000000000000000000000", "4821", now); } CHECK(isLocked(s, now + 30000u)); // wrapped past zero, still inside 60 s CHECK_FALSE(isLocked(s, now + 60000u)); @@ -156,14 +156,14 @@ TEST_CASE("lockout survives millis() wrap") { TEST_CASE("disconnect clears auth but not the lockout") { State s; issueNonce(s, kNonceA); - checkAnswer(s, kGoldenAnswer, "4821", "DovesDataLogger-042", 0); + checkAnswer(s, kGoldenAnswer, "4821", 0); onDisconnect(s); CHECK_FALSE(s.authed); CHECK_FALSE(s.nonceValid); for (uint8_t i = 0; i < kMaxFails; ++i) { issueNonce(s, kNonceA); - checkAnswer(s, "00000000000000000000000000000000", "4821", "x", 0); + checkAnswer(s, "00000000000000000000000000000000", "4821", 0); } onDisconnect(s); CHECK(isLocked(s, 1)); @@ -216,7 +216,7 @@ TEST_CASE("standby advert only on a free menu with the camera idle") { off.radioFree = false; CHECK_FALSE(standbyWanted(off)); off = in; - off.cameraBusy = true; + off.cameraWantsRadio = true; CHECK_FALSE(standbyWanted(off)); off = in; off.raceActive = true; @@ -224,16 +224,32 @@ TEST_CASE("standby advert only on a free menu with the camera idle") { } TEST_CASE("the camera always wins") { - CHECK_FALSE(cameraBusy(true, false, false)); - CHECK(cameraBusy(false, false, false)); // waking / recording / watching / pairing - CHECK(cameraBusy(true, true, false)); // bench page open - CHECK(cameraBusy(true, false, true)); // owns the radio - - CHECK(cameraEndsRemoteSession(true, false, 0)); - CHECK_FALSE(cameraEndsRemoteSession(false, true, kCameraWakeRpm)); - CHECK(cameraEndsRemoteSession(false, true, kCameraWakeRpm + 1)); + CameraInputs idle; + CHECK_FALSE(cameraWantsRadio(idle)); + + CameraInputs c = idle; + c.fsmIdleOrUnpaired = false; // waking / recording / watching / pairing + CHECK(cameraWantsRadio(c)); + c = idle; + c.testPageOpen = true; + CHECK(cameraWantsRadio(c)); + c = idle; + c.ownsRadio = true; + CHECK(cameraWantsRadio(c)); + + // A paired camera's engine above the wake threshold claims the radio + // before the camera FSM's own debounce does. + c = idle; + c.paired = true; + c.rpm = kCameraWakeRpm; + CHECK_FALSE(cameraWantsRadio(c)); + c.rpm = kCameraWakeRpm + 1; + CHECK(cameraWantsRadio(c)); + // No paired camera: the engine starting doesn't matter. - CHECK_FALSE(cameraEndsRemoteSession(false, false, 9000)); + c = idle; + c.rpm = 9000; + CHECK_FALSE(cameraWantsRadio(c)); } TEST_CASE("auth timeout is 20 s and wrap-safe") { From b314bf9439e439e5350b64de4c45fd3f8dbf3f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:21:49 +0000 Subject: [PATCH 4/7] plan 0019: restore settings.ino CRLF line endings The remote-transfer change rewrote settings.ino with LF endings, which turned a ~60-line change into a ~1160-line diff. BETA carries this file as CRLF; put it back so the PR shows only the real edits. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- BirdsEye/settings.ino | 1220 ++++++++++++++++++++--------------------- 1 file changed, 610 insertions(+), 610 deletions(-) diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index cc7ab53..56dc488 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -1,610 +1,610 @@ -/////////////////////////////////////////// -// SETTINGS MODULE -// Persistent JSON key-value store on SD card (/SETTINGS.json) -// Provides getSetting() and setSetting() for any subsystem. -/////////////////////////////////////////// - -#include "settings.h" -#include "remote_auth.h" - -static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json"; -// Quarantine target for a corrupt (non-empty, unparseable) settings file — -// typically a hand-edit gone wrong. The bad file is KEPT (renamed, not -// deleted) so its contents can be inspected/salvaged on a computer; a -// pre-existing .bad from an earlier quarantine is overwritten. -static const char SETTINGS_BAD_PATH[] = "/SETTINGS.json.bad"; -// Sized for the whole settings file plus its NUL, with real headroom: -// every read path below caps at sizeof(settingsFileBuffer) - 1, so a -// file larger than this parses as IncompleteInput and EVERY key read -// fails. That failure is self-inflicting — SETTINGS_SETUP quarantines -// the "corrupt" file and regenerates it, ensureDefaultSettings grows it -// back over the cap, and the device loses its settings on a loop. -// Raised 512 -> 1024 in plan 0010: the 18-key file was already 436 B and -// the four new keys put it at 543. The document is the other wall — 22 -// string pairs need JSON_OBJECT_SIZE(22) and a <512> doc returns -// NoMemory. Keep the two numbers equal, and see the measureJson() guard -// in setSettingInner() before adding more keys. -static char settingsFileBuffer[SETTINGS_JSON_CAPACITY]; -static StaticJsonDocument settingsJson; - -// 12 racing-adjacent words used to build a friendly default device name so -// logs dumped from a fleet of devices stay distinguishable. The literals -// live in flash and the pointer table is tiny (~48 bytes), so this costs -// almost no RAM. A default name is two of these picked at random. -static const char* const kDeviceNameWords[] = { - "Apex", "Turbo", "Nitro", "Drift", "Slick", "Boost", - "Piston", "Vortex", "Camber", "Draft", "Redline", "Gearbox" -}; - -/** - * @brief Compose a random default device name (e.g. "ApexTurbo") by picking - * two distinct racing words from kDeviceNameWords. - * @param buf Caller-provided buffer for the name (null-terminated) - * @param bufSize Size of buf - */ -static void generateDeviceName(char* buf, size_t bufSize) { - const int count = (int)(sizeof(kDeviceNameWords) / sizeof(kDeviceNameWords[0])); - int i = (int)random(0, count); - int j = (int)random(0, count - 1); - if (j >= i) j++; // map onto the remaining words so the two always differ - snprintf(buf, bufSize, "%s%s", kDeviceNameWords[i], kDeviceNameWords[j]); -} - -bool hwRandomBytes(uint8_t* out, size_t n) { -#ifdef SIM - // The sim has no RNG peripheral and must stay deterministic anyway. - for (size_t i = 0; i < n; i++) out[i] = (uint8_t)random(0, 256); - return true; -#else - uint8_t sdEnabled = 0; - (void)sd_softdevice_is_enabled(&sdEnabled); - if (sdEnabled) { - // The SoftDevice owns the RNG peripheral once it is up; its pool refills - // in tens of microseconds per byte, so 50 ms is a generous bound. - const uint32_t start = millis(); - size_t got = 0; - while (got < n) { - uint8_t avail = 0; - (void)sd_rand_application_bytes_available_get(&avail); - if (avail > 0) { - uint8_t take = (uint8_t)((n - got) < avail ? (n - got) : avail); - if (sd_rand_application_vector_get(out + got, take) == NRF_SUCCESS) got += take; - } else if (millis() - start > 50) { - return false; - } - } - return true; - } - // No SoftDevice yet (first boot on a stock build): drive the peripheral - // directly, with bias correction on. - NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Msk; - NRF_RNG->EVENTS_VALRDY = 0; - NRF_RNG->TASKS_START = 1; - for (size_t i = 0; i < n; i++) { - while (NRF_RNG->EVENTS_VALRDY == 0) { - } - out[i] = (uint8_t)NRF_RNG->VALUE; - NRF_RNG->EVENTS_VALRDY = 0; - } - NRF_RNG->TASKS_STOP = 1; - return true; -#endif -} - -void generateBlePin(char* out) { - uint8_t r[4] = {0}; - if (!hwRandomBytes(r, sizeof(r))) { - // Only reachable with the SoftDevice up and its pool wedged; a PIN is - // still required, so fall back rather than leave the key missing. - for (size_t i = 0; i < sizeof(r); i++) r[i] = (uint8_t)random(0, 256); - } - remote_auth::pinFromRandom(((uint32_t)r[0] << 24) | ((uint32_t)r[1] << 16) | - ((uint32_t)r[2] << 8) | r[3], - out); -} - -/** - * @brief Generate default settings file on first boot - * @return true if file created successfully - */ -bool createDefaultSettings() { - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD access for defaults")); - return false; - } - - settingsJson.clear(); - - // Generate random BLE name: DovesDataLogger-XXX - char nameSuffix[4]; - int randVal = random(0, 1000); - snprintf(nameSuffix, sizeof(nameSuffix), "%03d", randVal); - - char defaultName[32]; - snprintf(defaultName, sizeof(defaultName), "DovesDataLogger-%s", nameSuffix); - settingsJson["bluetooth_name"] = defaultName; - - // Random 4-digit PIN from the hardware RNG (plan 0019): it gates remote - // transfer now, so random() seeded from micros() is not good enough. - char defaultPin[remote_auth::kPinLen + 1]; - generateBlePin(defaultPin); - settingsJson["bluetooth_pin"] = defaultPin; - - // Random racing-themed device name so multi-device log dumps stay sorted - char deviceName[32]; - generateDeviceName(deviceName, sizeof(deviceName)); - settingsJson["device_name"] = deviceName; - - // New default settings - settingsJson["driver_name"] = "Driver"; - settingsJson["lap_detection_distance"] = "7"; - settingsJson["waypoint_detection_distance"] = "30"; - settingsJson["waypoint_speed"] = "30"; - - // Camera pairing: empty until an Insta360 serial is captured/entered - settingsJson["camera_serial"] = ""; - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to create file")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Created defaults - name: ")); - debug(defaultName); - debug(F(", pin: ")); - debugln(defaultPin); - - return true; -} - -/** - * @brief Ensure all expected settings keys exist, adding missing ones with defaults. - * Called when settings file already exists to handle firmware upgrades that add new keys. - */ -static void ensureDefaultSettings() { - // Plan 0013 renamed `rev_limit` to `target_rpm`. Migrate BEFORE the - // defaults table runs: the table only writes a key that is missing, so - // once target_rpm exists it is left alone — but if we let the table go - // first it would stamp the 15000 default over a user's tuned 7550 and - // the old value would be gone. The old key is only dropped once the - // new one is confirmed written, so a failed or refused write leaves the - // device exactly as it was rather than silently resetting its shift - // point. - { - char legacyBuf[48]; - if (!getSetting("target_rpm", legacyBuf, sizeof(legacyBuf)) && - getSetting("rev_limit", legacyBuf, sizeof(legacyBuf))) { - debug(F("Settings: migrating rev_limit -> target_rpm = ")); - debugln(legacyBuf); - if (setSetting("target_rpm", legacyBuf)) { - removeSetting("rev_limit"); - } - } - } - - // Table of all expected settings with their defaults - static const struct { const char* key; const char* defaultValue; } defaults[] = { - { "driver_name", "Driver" }, - { "lap_detection_distance", "7" }, - { "waypoint_detection_distance", "30" }, - { "waypoint_speed", "30" }, - { "camera_serial", "" }, // empty = no Insta360 paired - { "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range - // Engine geometry for true RPM (plans 0003, 0014). spark_mode is the ONLY - // one of these two that reaches the RPM math — one clamp on one plug wire - // sees one cylinder's ignition regardless of what the engine has. - { "spark_mode", "wasted" }, // "wasted" = 1 spark/rev (2T or 4T wasted); "single" = 1 per 2 revs - // The engine's ACTUAL cylinder count — no pickup-placement mind game, and - // no longer a divider (plan 0014). Descriptive: >1 means crank speed is - // inferred from one cylinder's firing rate, which is what the settings UI - // warns about. - { "cylinder_count", "1" }, - // RPM estimator (plan 0009). "smooth" = outlier gate + RPM-aware noise - // model; "legacy" = the pre-0009 filter for A/B against older logs; - // "raw" = no filtering at all, so a track session shows exactly what the - // pickup delivers. Anything else reads as "smooth". - { "tach_filter", "smooth" }, - // Panel colours. "normal" is lit-on-black, exactly as shipped; "inverted" - // swaps lit and unlit pixels for glare/daylight readability. - { "display_invert", "normal" }, - // Race-rotation debug pages (GPS/RF DEBUG + GPS STATS). "hide" (default) - // 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). Read on every channel since 4.1.0 — - // BIRDSEYE_ENABLE_NEOPIXEL defaults to 1, so these are live - // settings on a stock logger, not beta-only bookkeeping. - { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled - // The SHIFT/warning point, not a limiter (plan 0013 renamed it from - // "rev_limit", which taught every new user the wrong thing — - // overrev_limit below is the actual problem limit). LED scale - // ceiling + the `rpm` status-LED flasher. - { "target_rpm", "15000" }, - // Plan 0007: the PROBLEM limit (0 = disabled) — whole LED chain - // flashes red past it, and the tach page's OVER REV header. - { "overrev_limit", "0" }, -#if BIRDSEYE_ENABLE_SENSOREGG - // Temp1 alert threshold in Celsius. SensorEgg builds only (plan - // 0013): with the POC compiled out there is no probe to have a - // 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 - // led_status::modeName(): off / rpm / speed / gps / camera / lap / - // sector / egt. `egt` renders dark on a build without SensorEgg - // support — the setting still round-trips to the companion app. - { "led_status_left", "rpm" }, -#if BIRDSEYE_ENABLE_SENSOREGG - { "led_status_right", "egt" }, -#else - { "led_status_right", "lap" }, -#endif - { "target_speed_mph", "60" }, // mph on device; the app converts - // Plan 0010: minutes east of UTC (US Central standard = -360, India - // = 330). PRESENTATION ONLY — logged timestamps stay UTC. The only - // consumer today is the LED day/night brightness swap below, which - // needs "7am" to mean the driver's 7am. NO DST: a fixed offset walks - // an hour twice a year, which is noise for a dim-after-dark gate. - { "utc_offset_min", "0" }, - // Night brightness cap and the LOCAL wall-clock hours the swap - // happens on. Equal hours disable the swap (led_brightness applies - // around the clock). 0 = strip dark at night, but the 5 V rail - // stays up — only led_brightness 0 cuts the rail. - { "led_brightness_night", "16" }, - { "led_day_start_hour", "7" }, - { "led_night_start_hour", "19" }, - // Plan 0019: advertise the transfer service from the main menu so the - // app can start a PIN-gated transfer remotely. "off" disables it; - // anything else means on. - { "remote_transfer", "on" }, - }; - - char buf[48]; - for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) { - // Every key is a full open + read + parse of the file (and a - // read-modify-write when missing) — ~25 round trips at 2 MHz inside - // SETTINGS_SETUP(), with a WDT carried over a soft reset counting. - wdtPet(); - if (!getSetting(defaults[i].key, buf, sizeof(buf))) { - setSetting(defaults[i].key, defaults[i].defaultValue); - } - } - - // device_name needs random generation, not a static default - if (!getSetting("device_name", buf, sizeof(buf))) { - char dev[32]; - generateDeviceName(dev, sizeof(dev)); - setSetting("device_name", dev); - } - - // BLE keys need random generation, not static defaults - if (!getSetting("bluetooth_name", buf, sizeof(buf))) { - char name[32]; - snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); - setSetting("bluetooth_name", name); - } - if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { - char pin[remote_auth::kPinLen + 1]; - generateBlePin(pin); - setSetting("bluetooth_pin", pin); - } -} - -/** - * @brief Quarantine a corrupt settings file and regenerate defaults. - * The unparseable file is renamed to /SETTINGS.json.bad (overwriting any - * earlier quarantine) so it can be inspected on a computer, then a fresh - * default file is created. Before this, a corrupt file was a dead end: - * setSetting() aborted on the parse error and createDefaultSettings() only - * ran when the file didn't exist, so every setting silently failed forever - * with no on-device recovery. - * @return true when a fresh default file is in place - */ -static bool settingsQuarantineCorrupt() { - debugln(F("Settings: corrupt file — quarantining to /SETTINGS.json.bad")); - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for quarantine")); - return false; - } - if (SD.exists(SETTINGS_BAD_PATH)) { - SD.remove(SETTINGS_BAD_PATH); // one quarantine slot — overwrite the old one - } - if (!SD.rename(SETTINGS_FILE_PATH, SETTINGS_BAD_PATH)) { - // Rename failing (odd FAT state) must not leave the corrupt file in - // place — a fresh default file matters more than preserving the bad one. - SD.remove(SETTINGS_FILE_PATH); - } - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return createDefaultSettings(); -} - -/** - * @brief True when /SETTINGS.json is readable and parses as JSON. An empty - * or missing file counts as fine — those self-heal through the existing - * default-population paths; only a non-empty unparseable file is corrupt. - */ -static bool settingsFileParses() { - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) return true; // can't check now - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return true; - } - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - if (bytesRead <= 0) return true; // empty: ensureDefaultSettings rebuilds it - settingsFileBuffer[bytesRead] = '\0'; - settingsJson.clear(); - return deserializeJson(settingsJson, settingsFileBuffer) == DeserializationError::Ok; -} - -/** - * @brief Initialize settings subsystem. Creates default file on first boot. - * Call once from setup() after SD is initialized. - * @return true if settings file exists (or was created) - */ -bool SETTINGS_SETUP() { - // Seed random for generating default BLE name and PIN - // WARNING: Do NOT use analogRead() on ANY pin here! On nRF52840, analogRead() - // permanently disables the digital input buffer on the target pin: - // A0 = D0 (tach ISR), A1-A3 = buttons, A4 = SDA, A5 = SCL - // micros() provides sufficient entropy — this only runs on first boot. - randomSeed(micros()); - - if (!sdSetupSuccess) { - debugln(F("Settings: SD not available, skipping")); - return false; - } - - if (!SD.exists(SETTINGS_FILE_PATH)) { - debugln(F("Settings: First boot, creating defaults")); - return createDefaultSettings(); - } - - // A corrupt (hand-edited) file used to poison every get/set forever — - // quarantine it and start fresh before the missing-key pass. - if (!settingsFileParses()) { - return settingsQuarantineCorrupt(); - } - - debugln(F("Settings: File exists, checking for missing keys")); - ensureDefaultSettings(); - return true; -} - -/** - * @brief Read a setting value by key - * @param key JSON key to look up - * @param buf Caller-provided buffer for the value (null-terminated) - * @param bufSize Size of buf (value truncated to bufSize-1 if needed) - * @return true if key found and copied, false on any failure - */ -bool getSetting(const char* key, char* buf, size_t bufSize) { - buf[0] = '\0'; - - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for read")); - return false; - } - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - debugln(F("Settings: Cannot open file for read")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - if (bytesRead <= 0) { - debugln(F("Settings: Empty or unreadable file")); - return false; - } - settingsFileBuffer[bytesRead] = '\0'; - - settingsJson.clear(); - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: JSON parse error: ")); - debugln(err.c_str()); - return false; - } - - if (!settingsJson.containsKey(key)) { - debug(F("Settings: Key not found: ")); - debugln(key); - return false; - } - - const char* value = settingsJson[key]; - if (value == nullptr) return false; - - strncpy(buf, value, bufSize - 1); - buf[bufSize - 1] = '\0'; - return true; -} - -/** - * @brief Write or update a setting value - * @param key JSON key to set - * @param value String value to store - * @return true on success, false on failure - */ -/** - * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). - * @return true on success - */ -bool resetSettings() { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for reset")); - return false; - } - - if (SD.exists(SETTINGS_FILE_PATH)) { - SD.remove(SETTINGS_FILE_PATH); - } - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debugln(F("Settings: Deleted, recreating defaults")); - return createDefaultSettings(); -} - -static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for write")); - return false; - } - - // Read existing file first - settingsJson.clear(); - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (settingsFile) { - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - if (bytesRead > 0) { - settingsFileBuffer[bytesRead] = '\0'; - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: Parse error on read-modify-write: ")); - debugln(err.c_str()); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - // Corrupt mid-session (boot already healed once): quarantine + - // regenerate, then apply this write to the fresh file. Single - // retry — the regenerated file always parses, so no recursion. - if (healCorrupt && settingsQuarantineCorrupt()) { - return setSettingInner(key, value, false); - } - return false; - } - } - } - - // Update the key - settingsJson[key] = value; - - // Refuse a write that could not be read back afterwards. Two ways it - // could quietly destroy the whole file: - // overflowed() - the document ran out of slots, so the assignment - // above silently did nothing and serializing now would drop the - // key. (The flag is sticky, but settingsJson.clear() above resets - // it, so here it can only mean THIS parse+assign.) - // measureJson() - the result exceeds settingsFileBuffer. Every read - // path caps at sizeof(settingsFileBuffer) - 1, so such a file - // parses as IncompleteInput and destroys ALL settings, not just - // this key — and the boot-time corrupt-file heal then loops - // forever. measureJson() needs no scratch buffer, which matters - // here: settingsFileBuffer is off limits because the zero-copy - // parse above left the document pointing INTO it. - // Refusing one write leaves the existing file intact and readable. - if (settingsJson.overflowed() || - measureJson(settingsJson) > sizeof(settingsFileBuffer) - 1) { - debug(F("Settings: REFUSING write, would not be readable back: ")); - debugln(key); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - // Write back (truncate and rewrite) - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to open file for write")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Saved ")); - debug(key); - debug(F(" = ")); - debugln(value); - - return true; -} - -bool setSetting(const char* key, const char* value) { - return setSettingInner(key, value, /*healCorrupt=*/true); -} - -bool removeSetting(const char* key) { - if (!sdSetupSuccess) return false; - - if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { - debugln(F("Settings: Cannot acquire SD for remove")); - return false; - } - - settingsJson.clear(); - - File settingsFile; - settingsFile.open(SETTINGS_FILE_PATH, O_READ); - if (!settingsFile) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; // nothing to remove from - } - int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); - settingsFile.close(); - if (bytesRead <= 0) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - settingsFileBuffer[bytesRead] = '\0'; - DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); - if (err != DeserializationError::Ok) { - debug(F("Settings: Parse error on remove: ")); - debugln(err.c_str()); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; // deliberately NOT healed here — see the header note - } - - if (!settingsJson.containsKey(key)) { - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return true; // already gone: the caller's post-condition holds - } - settingsJson.remove(key); - - // No size guard needed: a removal can only shrink the file, so it can - // never cross the read cap that setSettingInner() has to defend. - settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); - if (!settingsFile) { - debugln(F("Settings: Failed to open file for remove")); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - return false; - } - serializeJson(settingsJson, settingsFile); - settingsFile.close(); - releaseSDAccess(SD_ACCESS_TRACK_PARSE); - - debug(F("Settings: Removed ")); - debugln(key); - return true; -} +/////////////////////////////////////////// +// SETTINGS MODULE +// Persistent JSON key-value store on SD card (/SETTINGS.json) +// Provides getSetting() and setSetting() for any subsystem. +/////////////////////////////////////////// + +#include "settings.h" +#include "remote_auth.h" + +static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json"; +// Quarantine target for a corrupt (non-empty, unparseable) settings file — +// typically a hand-edit gone wrong. The bad file is KEPT (renamed, not +// deleted) so its contents can be inspected/salvaged on a computer; a +// pre-existing .bad from an earlier quarantine is overwritten. +static const char SETTINGS_BAD_PATH[] = "/SETTINGS.json.bad"; +// Sized for the whole settings file plus its NUL, with real headroom: +// every read path below caps at sizeof(settingsFileBuffer) - 1, so a +// file larger than this parses as IncompleteInput and EVERY key read +// fails. That failure is self-inflicting — SETTINGS_SETUP quarantines +// the "corrupt" file and regenerates it, ensureDefaultSettings grows it +// back over the cap, and the device loses its settings on a loop. +// Raised 512 -> 1024 in plan 0010: the 18-key file was already 436 B and +// the four new keys put it at 543. The document is the other wall — 22 +// string pairs need JSON_OBJECT_SIZE(22) and a <512> doc returns +// NoMemory. Keep the two numbers equal, and see the measureJson() guard +// in setSettingInner() before adding more keys. +static char settingsFileBuffer[SETTINGS_JSON_CAPACITY]; +static StaticJsonDocument settingsJson; + +// 12 racing-adjacent words used to build a friendly default device name so +// logs dumped from a fleet of devices stay distinguishable. The literals +// live in flash and the pointer table is tiny (~48 bytes), so this costs +// almost no RAM. A default name is two of these picked at random. +static const char* const kDeviceNameWords[] = { + "Apex", "Turbo", "Nitro", "Drift", "Slick", "Boost", + "Piston", "Vortex", "Camber", "Draft", "Redline", "Gearbox" +}; + +/** + * @brief Compose a random default device name (e.g. "ApexTurbo") by picking + * two distinct racing words from kDeviceNameWords. + * @param buf Caller-provided buffer for the name (null-terminated) + * @param bufSize Size of buf + */ +static void generateDeviceName(char* buf, size_t bufSize) { + const int count = (int)(sizeof(kDeviceNameWords) / sizeof(kDeviceNameWords[0])); + int i = (int)random(0, count); + int j = (int)random(0, count - 1); + if (j >= i) j++; // map onto the remaining words so the two always differ + snprintf(buf, bufSize, "%s%s", kDeviceNameWords[i], kDeviceNameWords[j]); +} + +bool hwRandomBytes(uint8_t* out, size_t n) { +#ifdef SIM + // The sim has no RNG peripheral and must stay deterministic anyway. + for (size_t i = 0; i < n; i++) out[i] = (uint8_t)random(0, 256); + return true; +#else + uint8_t sdEnabled = 0; + (void)sd_softdevice_is_enabled(&sdEnabled); + if (sdEnabled) { + // The SoftDevice owns the RNG peripheral once it is up; its pool refills + // in tens of microseconds per byte, so 50 ms is a generous bound. + const uint32_t start = millis(); + size_t got = 0; + while (got < n) { + uint8_t avail = 0; + (void)sd_rand_application_bytes_available_get(&avail); + if (avail > 0) { + uint8_t take = (uint8_t)((n - got) < avail ? (n - got) : avail); + if (sd_rand_application_vector_get(out + got, take) == NRF_SUCCESS) got += take; + } else if (millis() - start > 50) { + return false; + } + } + return true; + } + // No SoftDevice yet (first boot on a stock build): drive the peripheral + // directly, with bias correction on. + NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Msk; + NRF_RNG->EVENTS_VALRDY = 0; + NRF_RNG->TASKS_START = 1; + for (size_t i = 0; i < n; i++) { + while (NRF_RNG->EVENTS_VALRDY == 0) { + } + out[i] = (uint8_t)NRF_RNG->VALUE; + NRF_RNG->EVENTS_VALRDY = 0; + } + NRF_RNG->TASKS_STOP = 1; + return true; +#endif +} + +void generateBlePin(char* out) { + uint8_t r[4] = {0}; + if (!hwRandomBytes(r, sizeof(r))) { + // Only reachable with the SoftDevice up and its pool wedged; a PIN is + // still required, so fall back rather than leave the key missing. + for (size_t i = 0; i < sizeof(r); i++) r[i] = (uint8_t)random(0, 256); + } + remote_auth::pinFromRandom(((uint32_t)r[0] << 24) | ((uint32_t)r[1] << 16) | + ((uint32_t)r[2] << 8) | r[3], + out); +} + +/** + * @brief Generate default settings file on first boot + * @return true if file created successfully + */ +bool createDefaultSettings() { + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD access for defaults")); + return false; + } + + settingsJson.clear(); + + // Generate random BLE name: DovesDataLogger-XXX + char nameSuffix[4]; + int randVal = random(0, 1000); + snprintf(nameSuffix, sizeof(nameSuffix), "%03d", randVal); + + char defaultName[32]; + snprintf(defaultName, sizeof(defaultName), "DovesDataLogger-%s", nameSuffix); + settingsJson["bluetooth_name"] = defaultName; + + // Random 4-digit PIN from the hardware RNG (plan 0019): it gates remote + // transfer now, so random() seeded from micros() is not good enough. + char defaultPin[remote_auth::kPinLen + 1]; + generateBlePin(defaultPin); + settingsJson["bluetooth_pin"] = defaultPin; + + // Random racing-themed device name so multi-device log dumps stay sorted + char deviceName[32]; + generateDeviceName(deviceName, sizeof(deviceName)); + settingsJson["device_name"] = deviceName; + + // New default settings + settingsJson["driver_name"] = "Driver"; + settingsJson["lap_detection_distance"] = "7"; + settingsJson["waypoint_detection_distance"] = "30"; + settingsJson["waypoint_speed"] = "30"; + + // Camera pairing: empty until an Insta360 serial is captured/entered + settingsJson["camera_serial"] = ""; + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to create file")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Created defaults - name: ")); + debug(defaultName); + debug(F(", pin: ")); + debugln(defaultPin); + + return true; +} + +/** + * @brief Ensure all expected settings keys exist, adding missing ones with defaults. + * Called when settings file already exists to handle firmware upgrades that add new keys. + */ +static void ensureDefaultSettings() { + // Plan 0013 renamed `rev_limit` to `target_rpm`. Migrate BEFORE the + // defaults table runs: the table only writes a key that is missing, so + // once target_rpm exists it is left alone — but if we let the table go + // first it would stamp the 15000 default over a user's tuned 7550 and + // the old value would be gone. The old key is only dropped once the + // new one is confirmed written, so a failed or refused write leaves the + // device exactly as it was rather than silently resetting its shift + // point. + { + char legacyBuf[48]; + if (!getSetting("target_rpm", legacyBuf, sizeof(legacyBuf)) && + getSetting("rev_limit", legacyBuf, sizeof(legacyBuf))) { + debug(F("Settings: migrating rev_limit -> target_rpm = ")); + debugln(legacyBuf); + if (setSetting("target_rpm", legacyBuf)) { + removeSetting("rev_limit"); + } + } + } + + // Table of all expected settings with their defaults + static const struct { const char* key; const char* defaultValue; } defaults[] = { + { "driver_name", "Driver" }, + { "lap_detection_distance", "7" }, + { "waypoint_detection_distance", "30" }, + { "waypoint_speed", "30" }, + { "camera_serial", "" }, // empty = no Insta360 paired + { "race_mode", "circuit" }, // tiebreak pref when circuit AND sprint tracks are in range + // Engine geometry for true RPM (plans 0003, 0014). spark_mode is the ONLY + // one of these two that reaches the RPM math — one clamp on one plug wire + // sees one cylinder's ignition regardless of what the engine has. + { "spark_mode", "wasted" }, // "wasted" = 1 spark/rev (2T or 4T wasted); "single" = 1 per 2 revs + // The engine's ACTUAL cylinder count — no pickup-placement mind game, and + // no longer a divider (plan 0014). Descriptive: >1 means crank speed is + // inferred from one cylinder's firing rate, which is what the settings UI + // warns about. + { "cylinder_count", "1" }, + // RPM estimator (plan 0009). "smooth" = outlier gate + RPM-aware noise + // model; "legacy" = the pre-0009 filter for A/B against older logs; + // "raw" = no filtering at all, so a track session shows exactly what the + // pickup delivers. Anything else reads as "smooth". + { "tach_filter", "smooth" }, + // Panel colours. "normal" is lit-on-black, exactly as shipped; "inverted" + // swaps lit and unlit pixels for glare/daylight readability. + { "display_invert", "normal" }, + // Race-rotation debug pages (GPS/RF DEBUG + GPS STATS). "hide" (default) + // 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). Read on every channel since 4.1.0 — + // BIRDSEYE_ENABLE_NEOPIXEL defaults to 1, so these are live + // settings on a stock logger, not beta-only bookkeeping. + { "led_brightness", "64" }, // global cap 0-255; 0 = LEDs disabled + // The SHIFT/warning point, not a limiter (plan 0013 renamed it from + // "rev_limit", which taught every new user the wrong thing — + // overrev_limit below is the actual problem limit). LED scale + // ceiling + the `rpm` status-LED flasher. + { "target_rpm", "15000" }, + // Plan 0007: the PROBLEM limit (0 = disabled) — whole LED chain + // flashes red past it, and the tach page's OVER REV header. + { "overrev_limit", "0" }, +#if BIRDSEYE_ENABLE_SENSOREGG + // Temp1 alert threshold in Celsius. SensorEgg builds only (plan + // 0013): with the POC compiled out there is no probe to have a + // 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 + // led_status::modeName(): off / rpm / speed / gps / camera / lap / + // sector / egt. `egt` renders dark on a build without SensorEgg + // support — the setting still round-trips to the companion app. + { "led_status_left", "rpm" }, +#if BIRDSEYE_ENABLE_SENSOREGG + { "led_status_right", "egt" }, +#else + { "led_status_right", "lap" }, +#endif + { "target_speed_mph", "60" }, // mph on device; the app converts + // Plan 0010: minutes east of UTC (US Central standard = -360, India + // = 330). PRESENTATION ONLY — logged timestamps stay UTC. The only + // consumer today is the LED day/night brightness swap below, which + // needs "7am" to mean the driver's 7am. NO DST: a fixed offset walks + // an hour twice a year, which is noise for a dim-after-dark gate. + { "utc_offset_min", "0" }, + // Night brightness cap and the LOCAL wall-clock hours the swap + // happens on. Equal hours disable the swap (led_brightness applies + // around the clock). 0 = strip dark at night, but the 5 V rail + // stays up — only led_brightness 0 cuts the rail. + { "led_brightness_night", "16" }, + { "led_day_start_hour", "7" }, + { "led_night_start_hour", "19" }, + // Plan 0019: advertise the transfer service from the main menu so the + // app can start a PIN-gated transfer remotely. "off" disables it; + // anything else means on. + { "remote_transfer", "on" }, + }; + + char buf[48]; + for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) { + // Every key is a full open + read + parse of the file (and a + // read-modify-write when missing) — ~25 round trips at 2 MHz inside + // SETTINGS_SETUP(), with a WDT carried over a soft reset counting. + wdtPet(); + if (!getSetting(defaults[i].key, buf, sizeof(buf))) { + setSetting(defaults[i].key, defaults[i].defaultValue); + } + } + + // device_name needs random generation, not a static default + if (!getSetting("device_name", buf, sizeof(buf))) { + char dev[32]; + generateDeviceName(dev, sizeof(dev)); + setSetting("device_name", dev); + } + + // BLE keys need random generation, not static defaults + if (!getSetting("bluetooth_name", buf, sizeof(buf))) { + char name[32]; + snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); + setSetting("bluetooth_name", name); + } + if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { + char pin[remote_auth::kPinLen + 1]; + generateBlePin(pin); + setSetting("bluetooth_pin", pin); + } +} + +/** + * @brief Quarantine a corrupt settings file and regenerate defaults. + * The unparseable file is renamed to /SETTINGS.json.bad (overwriting any + * earlier quarantine) so it can be inspected on a computer, then a fresh + * default file is created. Before this, a corrupt file was a dead end: + * setSetting() aborted on the parse error and createDefaultSettings() only + * ran when the file didn't exist, so every setting silently failed forever + * with no on-device recovery. + * @return true when a fresh default file is in place + */ +static bool settingsQuarantineCorrupt() { + debugln(F("Settings: corrupt file — quarantining to /SETTINGS.json.bad")); + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for quarantine")); + return false; + } + if (SD.exists(SETTINGS_BAD_PATH)) { + SD.remove(SETTINGS_BAD_PATH); // one quarantine slot — overwrite the old one + } + if (!SD.rename(SETTINGS_FILE_PATH, SETTINGS_BAD_PATH)) { + // Rename failing (odd FAT state) must not leave the corrupt file in + // place — a fresh default file matters more than preserving the bad one. + SD.remove(SETTINGS_FILE_PATH); + } + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return createDefaultSettings(); +} + +/** + * @brief True when /SETTINGS.json is readable and parses as JSON. An empty + * or missing file counts as fine — those self-heal through the existing + * default-population paths; only a non-empty unparseable file is corrupt. + */ +static bool settingsFileParses() { + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) return true; // can't check now + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return true; + } + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + if (bytesRead <= 0) return true; // empty: ensureDefaultSettings rebuilds it + settingsFileBuffer[bytesRead] = '\0'; + settingsJson.clear(); + return deserializeJson(settingsJson, settingsFileBuffer) == DeserializationError::Ok; +} + +/** + * @brief Initialize settings subsystem. Creates default file on first boot. + * Call once from setup() after SD is initialized. + * @return true if settings file exists (or was created) + */ +bool SETTINGS_SETUP() { + // Seed random for generating default BLE name and PIN + // WARNING: Do NOT use analogRead() on ANY pin here! On nRF52840, analogRead() + // permanently disables the digital input buffer on the target pin: + // A0 = D0 (tach ISR), A1-A3 = buttons, A4 = SDA, A5 = SCL + // micros() provides sufficient entropy — this only runs on first boot. + randomSeed(micros()); + + if (!sdSetupSuccess) { + debugln(F("Settings: SD not available, skipping")); + return false; + } + + if (!SD.exists(SETTINGS_FILE_PATH)) { + debugln(F("Settings: First boot, creating defaults")); + return createDefaultSettings(); + } + + // A corrupt (hand-edited) file used to poison every get/set forever — + // quarantine it and start fresh before the missing-key pass. + if (!settingsFileParses()) { + return settingsQuarantineCorrupt(); + } + + debugln(F("Settings: File exists, checking for missing keys")); + ensureDefaultSettings(); + return true; +} + +/** + * @brief Read a setting value by key + * @param key JSON key to look up + * @param buf Caller-provided buffer for the value (null-terminated) + * @param bufSize Size of buf (value truncated to bufSize-1 if needed) + * @return true if key found and copied, false on any failure + */ +bool getSetting(const char* key, char* buf, size_t bufSize) { + buf[0] = '\0'; + + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for read")); + return false; + } + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + debugln(F("Settings: Cannot open file for read")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + if (bytesRead <= 0) { + debugln(F("Settings: Empty or unreadable file")); + return false; + } + settingsFileBuffer[bytesRead] = '\0'; + + settingsJson.clear(); + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: JSON parse error: ")); + debugln(err.c_str()); + return false; + } + + if (!settingsJson.containsKey(key)) { + debug(F("Settings: Key not found: ")); + debugln(key); + return false; + } + + const char* value = settingsJson[key]; + if (value == nullptr) return false; + + strncpy(buf, value, bufSize - 1); + buf[bufSize - 1] = '\0'; + return true; +} + +/** + * @brief Write or update a setting value + * @param key JSON key to set + * @param value String value to store + * @return true on success, false on failure + */ +/** + * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). + * @return true on success + */ +bool resetSettings() { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for reset")); + return false; + } + + if (SD.exists(SETTINGS_FILE_PATH)) { + SD.remove(SETTINGS_FILE_PATH); + } + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debugln(F("Settings: Deleted, recreating defaults")); + return createDefaultSettings(); +} + +static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for write")); + return false; + } + + // Read existing file first + settingsJson.clear(); + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (settingsFile) { + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + if (bytesRead > 0) { + settingsFileBuffer[bytesRead] = '\0'; + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: Parse error on read-modify-write: ")); + debugln(err.c_str()); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + // Corrupt mid-session (boot already healed once): quarantine + + // regenerate, then apply this write to the fresh file. Single + // retry — the regenerated file always parses, so no recursion. + if (healCorrupt && settingsQuarantineCorrupt()) { + return setSettingInner(key, value, false); + } + return false; + } + } + } + + // Update the key + settingsJson[key] = value; + + // Refuse a write that could not be read back afterwards. Two ways it + // could quietly destroy the whole file: + // overflowed() - the document ran out of slots, so the assignment + // above silently did nothing and serializing now would drop the + // key. (The flag is sticky, but settingsJson.clear() above resets + // it, so here it can only mean THIS parse+assign.) + // measureJson() - the result exceeds settingsFileBuffer. Every read + // path caps at sizeof(settingsFileBuffer) - 1, so such a file + // parses as IncompleteInput and destroys ALL settings, not just + // this key — and the boot-time corrupt-file heal then loops + // forever. measureJson() needs no scratch buffer, which matters + // here: settingsFileBuffer is off limits because the zero-copy + // parse above left the document pointing INTO it. + // Refusing one write leaves the existing file intact and readable. + if (settingsJson.overflowed() || + measureJson(settingsJson) > sizeof(settingsFileBuffer) - 1) { + debug(F("Settings: REFUSING write, would not be readable back: ")); + debugln(key); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + // Write back (truncate and rewrite) + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to open file for write")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Saved ")); + debug(key); + debug(F(" = ")); + debugln(value); + + return true; +} + +bool setSetting(const char* key, const char* value) { + return setSettingInner(key, value, /*healCorrupt=*/true); +} + +bool removeSetting(const char* key) { + if (!sdSetupSuccess) return false; + + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { + debugln(F("Settings: Cannot acquire SD for remove")); + return false; + } + + settingsJson.clear(); + + File settingsFile; + settingsFile.open(SETTINGS_FILE_PATH, O_READ); + if (!settingsFile) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; // nothing to remove from + } + int bytesRead = settingsFile.read(settingsFileBuffer, sizeof(settingsFileBuffer) - 1); + settingsFile.close(); + if (bytesRead <= 0) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + settingsFileBuffer[bytesRead] = '\0'; + DeserializationError err = deserializeJson(settingsJson, settingsFileBuffer); + if (err != DeserializationError::Ok) { + debug(F("Settings: Parse error on remove: ")); + debugln(err.c_str()); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; // deliberately NOT healed here — see the header note + } + + if (!settingsJson.containsKey(key)) { + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return true; // already gone: the caller's post-condition holds + } + settingsJson.remove(key); + + // No size guard needed: a removal can only shrink the file, so it can + // never cross the read cap that setSettingInner() has to defend. + settingsFile.open(SETTINGS_FILE_PATH, O_WRITE | O_CREAT | O_TRUNC); + if (!settingsFile) { + debugln(F("Settings: Failed to open file for remove")); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + return false; + } + serializeJson(settingsJson, settingsFile); + settingsFile.close(); + releaseSDAccess(SD_ACCESS_TRACK_PARSE); + + debug(F("Settings: Removed ")); + debugln(key); + return true; +} From 4f12dcbc8345af71c5bd3bd70e985643ea0a0d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:30:18 +0000 Subject: [PATCH 5/7] plan 0019: harden remote-transfer session and link handling Review fixes for PR #168 (logger side): - A standby peer's disconnect could be routed to the camera when the camera claimed the radio in the same loop iteration, leaving bleConnected and a stale handle behind. Our own link is now matched by handle before owner, bleStandbyStop() waits (bounded, WDT-fed) for the disconnect, and standby starts from a clean link state. - bleStandbyStop() releases the owner before switching to open mode, so a write that preempts the teardown is ignored; a link being dropped is marked and gets no further commands. - A remote session ends with AUTH:ENGINE when the engine passes 500 rpm (camera paired or not) and with AUTH:IDLE after 10 minutes without a request, so it can no longer park the logger with no logging, auto-race or idle shutdown. Decision in remote_auth. - An unreadable or invalid stored PIN answers AUTH:BUSY without spending the nonce or counting a failure; PINGET refuses to hand out a PIN that could never authenticate. - BLE_SETUP() drops any surviving link and clears every pending flag before opening a local (open) session, and the transfer page says "PIN sent to app!" once PINGET has been served. - The paired-camera engine claim is latched until rpm < 300, so a pull-start no longer thrashes the standby advert. - After an AUTH:TIMEOUT squat drop the advert stays down 5 s. - Tests: the above, plus command gating with leading whitespace and embedded NULs, and the PIN page's entry press on the Back row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- BirdsEye/BirdsEye.ino | 5 +- BirdsEye/bluetooth.h | 12 +- BirdsEye/bluetooth.ino | 205 ++++++++++++++++++++++------ BirdsEye/display_pages.ino | 6 + BirdsEye/remote_auth.cpp | 38 +++++- BirdsEye/remote_auth.h | 71 +++++++++- BirdsEye/sim/stubs/module_stubs.cpp | 1 + tests/pin_page_test.cpp | 18 +++ tests/remote_auth_test.cpp | 154 +++++++++++++++++++-- 9 files changed, 445 insertions(+), 65 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index db9d347..239458b 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -3029,8 +3029,9 @@ void loop() { // When BLE is active, skip GPS/tach/lap processing for better throughput if (bleActive) { PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP()); - // A remotely started session hands the radio back the moment a paired - // camera wants it (plan 0019). Runs the tach itself; no-op otherwise. + // A remotely started session ends when the engine starts, the camera + // wants the radio, or it sits idle 10 min (plan 0019). Runs the tach + // itself; no-op on a local session. PROFILE_SECTION(loop_profile::kTach, bleRemoteSessionGuard()); // Keep battery voltage fresh for BLE BATT command and display diff --git a/BirdsEye/bluetooth.h b/BirdsEye/bluetooth.h index 2e58410..a8224c5 100644 --- a/BirdsEye/bluetooth.h +++ b/BirdsEye/bluetooth.h @@ -102,13 +102,21 @@ void BLUETOOTH_LOOP(); void BLE_STANDBY_LOOP(); // Called from the parked transfer branch. On a REMOTE session only, keeps -// the tachometer running and ends the session (AUTH:CAMERA, then the usual -// reboot) the moment a paired camera wants the radio back. +// the tachometer running and ends the session — an AUTH: notice, then the +// usual reboot — when the engine starts (AUTH:ENGINE, camera paired or +// not), when the camera wants the radio back (AUTH:CAMERA), or after 10 +// minutes without a request (AUTH:IDLE). Nobody at the logger started a +// remote session, so it must never be able to park the logger for good. void bleRemoteSessionGuard(); // True while the transfer page is serving a session that started remotely. bool bleRemoteSessionActive(); +// True once this (local) transfer session has handed out the PIN over +// PINGET — the transfer page shows it, so whoever is standing at the +// logger knows the connected app can now start remote transfers. +bool blePinSentThisSession(); + // --- Transfer diagnostics (read by the Bluetooth page) --------------------- // These exist so a download-speed regression is visible on the device instead // of being inferred from a progress bar. See diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino index dacaba1..7730598 100644 --- a/BirdsEye/bluetooth.ino +++ b/BirdsEye/bluetooth.ino @@ -126,6 +126,24 @@ static int8_t bleRemoteTransferSetting = -1; // runs on the main loop like every other SD-touching command). static volatile bool authCmdPending = false; static char authCmdBuffer[65]; +// A link we are in the middle of dropping. Writes from it are ignored (so a +// peer on its way out can never act in whatever mode comes next), and its +// disconnect event is routed to the transfer path by this handle even if it +// lands after the owner has moved on. +static volatile uint16_t bleDroppingConnHandle = BLE_CONN_HANDLE_INVALID; +// Bounded wait for a local disconnect to be confirmed (WDT-fed). A local +// terminate normally lands in a few connection intervals. +static const uint32_t BLE_DROP_SETTLE_MS = 1000; +// Last write to the request characteristic — the remote-session idle clock. +static volatile uint32_t bleLastRequestMs = 0; +// Hysteresis state for a paired camera's engine claim (remote_auth). +static bool bleCameraEngineLatch = false; +// Post-squat quiet period for the menu advert (remote_auth::kSquatBackoffMs). +static bool bleSquatBackoffArmed = false; +static uint32_t bleSquatDroppedAtMs = 0; +// PINGET was served this session — the transfer page says so, so a person +// at the logger sees that whoever is connected now holds the PIN. +static volatile bool blePinServed = false; // Set by the disconnect callback; BLUETOOTH_LOOP() performs the SD teardown // (close transfer/staging file, release SD, abort OTA) and the auto-reboot @@ -266,12 +284,27 @@ void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) { // already moved to NONE/TRANSFER (e.g. CAMERA_FORCE_RELEASE() // immediately followed by BLE_SETUP() on the transfer page) — owner // routing alone would misdeliver it here and reboot the device. - if (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA) { + // + // Our own transfer/standby link is matched by handle FIRST, for the + // mirror-image reason: the menu standby can hand the radio to the camera + // while its peer's disconnect is still in flight, and owner routing would + // then deliver that peer to the camera module, leaving bleConnected and + // the stale handle behind. + const bool transferLink = conn_handle == bleTransferConnHandle || + conn_handle == bleDroppingConnHandle; + if (!transferLink && + (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA)) { cameraBleOnDisconnect(conn_handle, reason); return; } debugln(F("BLE: Disconnected!")); + if (conn_handle == bleDroppingConnHandle) bleDroppingConnHandle = BLE_CONN_HANDLE_INVALID; + // A link we had already let go of, while a different peer is current: + // nothing below belongs to it. + if (bleTransferConnHandle != BLE_CONN_HANDLE_INVALID && conn_handle != bleTransferConnHandle) { + return; + } bleConnected = false; bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; // The next peer must authenticate from scratch. The lockout survives. @@ -608,6 +641,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* // peer that connects during camera mode must not queue deferred SD work — // BLUETOOTH_LOOP() is gated on bleActive and would never drain it. if (bleOwner != BLE_OWNER_TRANSFER) return; + // A link being dropped gets nothing more (plan 0019). + if (conn_hdl == bleDroppingConnHandle) return; + bleLastRequestMs = millis(); // A write to the request characteristic means this is a genuine transfer // peer (the phone app), not a bonded camera vetting our GATT — arm the @@ -940,18 +976,69 @@ static bool bleCameraWantsRadio() { cam.ownsRadio = bleOwner == BLE_OWNER_CAMERA; cam.paired = cameraIsPaired(); cam.rpm = (uint16_t)(tachLastReported > 65535 ? 65535 : tachLastReported); - return remote_auth::cameraWantsRadio(cam); + return remote_auth::cameraWantsRadio(cam, bleCameraEngineLatch); +} + +// Send an unsolicited AUTH: notice and give it a moment to reach the app +// before the link drops. nullptr = drop silently. +static void bleNotifyDropReason(const char* token) { + if (token == nullptr) return; + fileStatusChar.notify((uint8_t*)token, strlen(token)); + delay(50); +} + +// Drop our transfer/standby link, if any, and wait (bounded, WDT-fed) for +// the stack to confirm it. Main-loop only. While it is going, the link is +// marked as dropping: its writes are ignored and its disconnect event is +// routed here by handle, so neither can leak into whatever mode or owner +// the caller sets next — even if the confirmation outlives the wait. +static void bleDropTransferLink(const char* reason) { + const uint16_t handle = bleTransferConnHandle; + if (handle == BLE_CONN_HANDLE_INVALID) return; + BLEConnection* connection = Bluefruit.Connection(handle); + if (!connection || !connection->connected()) { + // Already gone (its event was handled elsewhere): just forget it. + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + bleConnected = false; + return; + } + bleNotifyDropReason(reason); + bleDroppingConnHandle = handle; + Bluefruit.disconnect(handle); + const uint32_t t0 = millis(); + while (bleTransferConnHandle == handle && (uint32_t)(millis() - t0) < BLE_DROP_SETTLE_MS) { + wdtPet(); + delay(10); + } + if (bleTransferConnHandle == handle) debugln(F("BLE: drop not confirmed in time")); +} + +// Every queued command and half-finished exchange from a previous peer or +// session. Main-loop only; nothing here touches SD. +static void bleClearPendingCommands() { + authCmdPending = false; + settingsCmdPending = false; + fileCmdPending = false; + trackDeletePending = false; + trackUploadActive = false; + trackUploadReady = false; + trackUploadComplete = false; + trackUploadError = false; + remote_auth::onDisconnect(bleAuth); } static void bleStandbyStart() { bleCoreEnsureInit(); - bleOwner = BLE_OWNER_TRANSFER; + // Start from a clean slate: no peer from an earlier owner can still be + // counted as ours (a disconnect misrouted or not yet landed would leave + // bleConnected and the handle stale, and a stale handle can be reused). + bleDropTransferLink(nullptr); + bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; bleSessionMode = remote_auth::Mode::kLocked; bleRemoteSession = false; - remote_auth::onDisconnect(bleAuth); - authCmdPending = false; - settingsCmdPending = false; - fileCmdPending = false; + bleClearPendingCommands(); + bleOwner = BLE_OWNER_TRANSFER; bleApplyTransferAdvertising(); // No blue LED for an idle menu advert — that LED means "transfer page". bleConnLedOff(); @@ -959,27 +1046,24 @@ static void bleStandbyStart() { debugln(F("BLE: remote standby advertising")); } -static void bleStandbyStop(bool forCamera) { +// `reason` is the AUTH: notice a connected, unauthenticated peer gets +// before it is dropped (nullptr = none). +static void bleStandbyStop(const char* reason) { bleStandbyActive = false; + // Owner FIRST. Once it is NONE the request callback ignores every write, + // so a command that preempts the rest of this teardown can never run — + // in particular not in the open mode set at the end. + bleOwner = BLE_OWNER_NONE; // Disarm BEFORE the async disconnect, or Bluefruit restarts an ownerless // advert behind us (see BLE_STOP()). Bluefruit.Advertising.restartOnDisconnect(false); - const uint16_t handle = bleTransferConnHandle; - if (handle != BLE_CONN_HANDLE_INVALID) { - if (forCamera) { - fileStatusChar.notify((uint8_t*)"AUTH:CAMERA", 11); - delay(50); // let the reason reach the app before the link drops - } - Bluefruit.disconnect(handle); - } Bluefruit.Advertising.stop(); - authCmdPending = false; - settingsCmdPending = false; - fileCmdPending = false; - remote_auth::onDisconnect(bleAuth); + // Wait the disconnect out before anyone else (the camera, in the same + // loop iteration) can take the radio, so its event can't be misrouted. + bleDropTransferLink(reason); + bleClearPendingCommands(); bleSessionMode = remote_auth::Mode::kOpen; - bleOwner = BLE_OWNER_NONE; - debugln(forCamera ? F("BLE: remote standby off (camera)") : F("BLE: remote standby off")); + debugln(F("BLE: remote standby off")); } // AUTH:OK on the menu advert: become the transfer page, exactly as a local @@ -989,6 +1073,8 @@ static void bleStandbyPromote() { if (!bleConnected) return; // peer left between the answer and here bleStandbyActive = false; bleRemoteSession = true; + bleLastRequestMs = millis(); // the idle clock starts at the handshake + blePinServed = false; sdSetTransferSpeed(true); SENSOREGG_SLEEP(); // same guarantee BLE_SETUP() gives the transfer link Bluefruit.autoConnLed(true); @@ -1030,10 +1116,13 @@ static void bleProcessAuthCommand() { return; } - // AUTH: + // AUTH:. An unreadable or malformed stored PIN (SD refused, a + // corrupt file, a hand-edited number) is kPinUnavailable: AUTH:BUSY, the + // nonce kept and no failure counted, so the app can simply retry. char pin[16]; if (!getSetting("bluetooth_pin", pin, sizeof(pin))) pin[0] = '\0'; const remote_auth::Verdict v = remote_auth::checkAnswer(bleAuth, cmd + 5, pin, now); + memset(pin, 0, sizeof(pin)); switch (v) { case remote_auth::Verdict::kOk: fileStatusChar.notify((uint8_t*)"AUTH:OK", 7); @@ -1051,12 +1140,17 @@ static void bleProcessAuthCommand() { case remote_auth::Verdict::kNoNonce: fileStatusChar.notify((uint8_t*)"AUTH:NO_NONCE", 13); break; + case remote_auth::Verdict::kPinUnavailable: + debugln(F("BLE: stored PIN unreadable or invalid")); + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + break; } } void BLE_STANDBY_LOOP() { const bool onMenu = currentPage == PAGE_MAIN_MENU; const bool cameraWants = bleCameraWantsRadio(); + const uint32_t now = millis(); remote_auth::StandbyInputs in; in.onMainMenu = onMenu; @@ -1064,6 +1158,7 @@ void BLE_STANDBY_LOOP() { (bleStandbyActive && bleOwner == BLE_OWNER_TRANSFER); in.cameraWantsRadio = cameraWants; in.raceActive = raceActive; + in.squatBackoff = remote_auth::squatBackoffActive(bleSquatBackoffArmed, bleSquatDroppedAtMs, now); // Only read the setting (an SD read, once) when it could matter. in.settingEnabled = onMenu && bleRemoteTransferEnabled(); @@ -1073,38 +1168,51 @@ void BLE_STANDBY_LOOP() { return; } if (!wanted) { - bleStandbyStop(cameraWants); + bleStandbyStop(cameraWants ? "AUTH:CAMERA" : nullptr); return; } bleProcessAuthCommand(); if (!bleStandbyActive) return; // promoted - // Nobody gets to sit on the only peripheral slot without the PIN. - const uint16_t handle = bleTransferConnHandle; - if (handle != BLE_CONN_HANDLE_INVALID && !bleAuth.authed && - remote_auth::authTimedOut(bleConnectAtMs, millis())) { + // Nobody gets to sit on the only peripheral slot without the PIN. The + // advert then stays down for a short back-off, or a squatter (or a + // bonded camera chasing our address) just reconnects and repeats. + if (bleTransferConnHandle != BLE_CONN_HANDLE_INVALID && !bleAuth.authed && + remote_auth::authTimedOut(bleConnectAtMs, now)) { debugln(F("BLE: remote peer never authenticated, dropping")); - fileStatusChar.notify((uint8_t*)"AUTH:TIMEOUT", 12); - Bluefruit.disconnect(handle); - bleConnectAtMs = millis(); // don't re-fire while the drop completes + bleStandbyStop("AUTH:TIMEOUT"); + bleSquatBackoffArmed = true; + bleSquatDroppedAtMs = millis(); } } void bleRemoteSessionGuard() { if (!bleRemoteSession) return; // The parked branch skips the normal pipeline, tach included — and the - // engine starting is exactly what the camera wakes on. + // engine starting is exactly what has to end this session. TACH_LOOP(); - if (!bleCameraWantsRadio()) return; - debugln(F("BLE: camera needs the radio, ending remote transfer")); - fileStatusChar.notify((uint8_t*)"AUTH:CAMERA", 11); - delay(50); + + remote_auth::RemoteSessionInputs in; + in.otaApplyPending = fwApplyRequested(); + in.rpm = (uint16_t)(tachLastReported > 65535 ? 65535 : tachLastReported); + in.cameraWantsRadio = bleCameraWantsRadio(); + in.busy = bleTransferInProgress || trackUploadActive || fwReceiving(); + in.lastRequestMs = bleLastRequestMs; + in.nowMs = millis(); + const remote_auth::RemoteEnd end = remote_auth::remoteSessionEnd(in); + if (end == remote_auth::RemoteEnd::kNone) return; + + debug(F("BLE: ending remote transfer: ")); + debugln(remote_auth::remoteEndToken(end)); + bleNotifyDropReason(remote_auth::remoteEndToken(end)); bleExitTransferMode(); } bool bleRemoteSessionActive() { return bleActive && bleRemoteSession; } +bool blePinSentThisSession() { return bleActive && blePinServed; } + void BLE_SETUP() { // Parked transfer — bump the SD clock for faster file transfers. Reverted // in BLE_STOP() (and by the auto-reboot on phone disconnect). @@ -1125,12 +1233,23 @@ void BLE_SETUP() { // A local start takes the radio over from the menu standby advert (the // Transfer page is not the main menu, so standby has normally stopped // already) and is OPEN: someone at the logger chose to start it. - if (bleStandbyActive) bleStandbyStop(false); - bleSessionMode = remote_auth::Mode::kOpen; - bleRemoteSession = false; + if (bleStandbyActive) bleStandbyStop(nullptr); bleCoreEnsureInit(); + // An OPEN session hands out the PIN (PINGET), so it must begin with + // nobody connected: a standby peer whose link outlived the menu would + // otherwise be promoted, unauthenticated, into full access. Then forget + // everything a previous peer or session left queued. + bleDropTransferLink(nullptr); + bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + bleClearPendingCommands(); + bleDisconnectCleanupPending = false; + blePinServed = false; + bleSessionMode = remote_auth::Mode::kOpen; + bleRemoteSession = false; + // Take the radio for the transfer service (main-loop context — the camera // module released its links via CAMERA_FORCE_RELEASE() before this page // opened). The full advert rebuild below is what makes re-entry correct @@ -1466,13 +1585,19 @@ void processSettingsCommand() { return; } char pin[16]; - if (getSetting("bluetooth_pin", pin, sizeof(pin))) { + if (getSetting("bluetooth_pin", pin, sizeof(pin)) && remote_auth::isValidPin(pin)) { char response[24]; snprintf(response, sizeof(response), "PIN:%s", pin); fileStatusChar.notify((uint8_t*)response, strlen(response)); + memset(response, 0, sizeof(response)); + // Whoever is connected now holds the PIN; the transfer page says so. + blePinServed = true; } else { + // Unreadable, or a value the handshake could never accept — handing + // that out would only pair the app to a PIN that cannot work. fileStatusChar.notify((uint8_t*)"SERR:NOT_FOUND", 14); } + memset(pin, 0, sizeof(pin)); } else if (strcmp(settingsCmdBuffer, "SRESET") == 0) { debugln(F("BLE: SRESET - resetting all settings to defaults")); if (resetSettings()) { diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index a7af0c8..3625624 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -409,6 +409,12 @@ void displayPage_bluetooth() { // transfer page nobody here opened never looks like a malfunction. if (bleRemoteSessionActive()) { display.println(F(" Remote Transfer")); + } else if (blePinSentThisSession()) { + // A local session hands the PIN to whoever asks (PINGET), and the first + // app in range is not necessarily the owner's. The title says so for + // the rest of the session — it is the one line a transfer never + // repurposes — so the person who opened it can see it happened. + display.println(F(" PIN sent to app!")); } else { display.println(F(" Bluetooth Connection")); } diff --git a/BirdsEye/remote_auth.cpp b/BirdsEye/remote_auth.cpp index c86d7de..bec9c5c 100644 --- a/BirdsEye/remote_auth.cpp +++ b/BirdsEye/remote_auth.cpp @@ -105,12 +105,14 @@ Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t n return Verdict::kLocked; } if (!s.nonceValid) return Verdict::kNoNonce; + // Before the nonce is spent: the logger can't check anything right now. + if (!isValidPin(pin)) return Verdict::kPinUnavailable; // Single use, right or wrong. s.nonceValid = false; bool ok = false; - if (isValidPin(pin) && answerHex != nullptr && strlen(answerHex) == kAnswerHexLen) { + if (answerHex != nullptr && strlen(answerHex) == kAnswerHexLen) { bool wellFormed = true; for (size_t i = 0; i < kAnswerHexLen; ++i) wellFormed = wellFormed && isHex(answerHex[i]); if (wellFormed) { @@ -157,14 +159,40 @@ bool isProtectedSettingKey(const char* key) { return key != nullptr && strcmp(key, "bluetooth_pin") == 0; } -bool cameraWantsRadio(const CameraInputs& in) { - return !in.fsmIdleOrUnpaired || in.testPageOpen || in.ownsRadio || - (in.paired && in.rpm > kCameraWakeRpm); +bool cameraWantsRadio(const CameraInputs& in, bool& engineLatch) { + if (!in.paired || in.rpm < kCameraReleaseRpm) { + engineLatch = false; + } else if (in.rpm > kCameraWakeRpm) { + engineLatch = true; + } + return !in.fsmIdleOrUnpaired || in.testPageOpen || in.ownsRadio || engineLatch; } bool standbyWanted(const StandbyInputs& in) { return in.settingEnabled && in.onMainMenu && in.radioFree && !in.cameraWantsRadio && - !in.raceActive; + !in.raceActive && !in.squatBackoff; +} + +RemoteEnd remoteSessionEnd(const RemoteSessionInputs& in) { + if (in.otaApplyPending) return RemoteEnd::kNone; + if (in.rpm > kEngineRunningRpm) return RemoteEnd::kEngine; + if (in.cameraWantsRadio) return RemoteEnd::kCamera; + if (!in.busy && uint32_t(in.nowMs - in.lastRequestMs) >= kRemoteIdleMs) return RemoteEnd::kIdle; + return RemoteEnd::kNone; +} + +const char* remoteEndToken(RemoteEnd e) { + switch (e) { + case RemoteEnd::kEngine: + return "AUTH:ENGINE"; + case RemoteEnd::kCamera: + return "AUTH:CAMERA"; + case RemoteEnd::kIdle: + return "AUTH:IDLE"; + case RemoteEnd::kNone: + break; + } + return nullptr; } } // namespace remote_auth diff --git a/BirdsEye/remote_auth.h b/BirdsEye/remote_auth.h index bac8db5..1cfd0fd 100644 --- a/BirdsEye/remote_auth.h +++ b/BirdsEye/remote_auth.h @@ -41,9 +41,26 @@ constexpr uint32_t kLockoutMaxMs = 15UL * 60UL * 1000UL; // A locked-mode peer that hasn't authenticated by now is dropped, so a // stranger can't squat on the only peripheral connection slot. constexpr uint32_t kAuthTimeoutMs = 20000; -// Camera wake threshold (camera_fsm kRpmOnThreshold). A remote transfer -// ends above it when a camera is paired, so the camera can wake. +// Camera wake threshold (camera_fsm kRpmOnThreshold). A paired camera +// claims the radio above it, and keeps the claim until the engine falls +// below kCameraReleaseRpm (camera_fsm kRpmOffThreshold) — without that +// hysteresis a pull-start cranking through 500 thrashes the standby advert +// on and off, an SD read and an advert rebuild each time. constexpr uint16_t kCameraWakeRpm = 500; +constexpr uint16_t kCameraReleaseRpm = 300; +// An engine above this ends a remote session whatever the camera is doing: +// the driver is about to need logging, auto-race and the race pages, and +// the parked transfer loop runs none of them. Same number as the camera +// wake and auto-race thresholds. +constexpr uint16_t kEngineRunningRpm = 500; +// A remote session with no request traffic for this long ends. Nobody at +// the logger started it, so nobody there would think to end it, and the +// parked loop has no idle shutdown of its own. +constexpr uint32_t kRemoteIdleMs = 10UL * 60UL * 1000UL; +// After dropping a peer that never authenticated, the menu advert stays +// down this long. One slot means a squatter (or a bonded camera chasing +// our address) could otherwise hold it in a tight 20 s loop forever. +constexpr uint32_t kSquatBackoffMs = 5000; // Exactly kPinLen ASCII digits, nothing else. bool isValidPin(const char* pin); @@ -101,11 +118,15 @@ enum class Verdict : uint8_t { kFail, // AUTH:FAIL: kLocked, // AUTH:LOCKED: (already locked, or this fail locked it) kNoNonce, // AUTH:NO_NONCE — no AUTH? since the last answer + kPinUnavailable, // AUTH:BUSY — the stored PIN is unreadable or not 4 digits }; // Check `answerHex` against the expected answer for the current nonce. The -// nonce is consumed on every verdict except kLocked-before-checking and -// kNoNonce. A malformed answer counts as a wrong one. +// nonce is consumed on kOk, kFail and kLocked. kNoNonce has none to consume, +// and kPinUnavailable leaves it intact and counts nothing: an SD refusal or +// a hand-edited PIN is the logger's fault, not a wrong guess, so it must +// not burn the app's attempt or walk it toward a lockout. A malformed +// answer counts as a wrong one. Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t nowMs); // Reset per-connection state (authed flag, nonce). The lockout survives. @@ -116,7 +137,8 @@ void onDisconnect(State& s); enum class Mode : uint8_t { kOpen, kLocked }; // May this command run? `cmd` is the trimmed text written to the request -// characteristic. Open: everything. Locked before AUTH:OK: only AUTH?, +// characteristic, compared exactly — a leading space or anything after an +// embedded NUL makes it a different (unknown) command, never an allowed one. Open: everything. Locked before AUTH:OK: only AUTH?, // AUTH:<…> and BATT. PINGET is open-mode only — it is how an app learns the // PIN, so it is never available to a peer that started remotely. bool commandAllowed(Mode mode, bool authed, const char* cmd); @@ -135,10 +157,12 @@ struct CameraInputs { // The camera has, or is about to have, a use for the radio: its state // machine is anywhere but UNPAIRED/IDLE, the bench page is open, it owns -// the radio — or a paired camera's engine is above the wake threshold. +// the radio — or a paired camera's engine has passed the wake threshold. // That last one fires before the camera FSM's own 2 s wake debounce, so // remote transfer has always let go of the radio before the camera asks. -bool cameraWantsRadio(const CameraInputs& in); +// `engineLatch` is the caller's state for the engine half: set above +// kCameraWakeRpm, cleared below kCameraReleaseRpm (or when unpaired). +bool cameraWantsRadio(const CameraInputs& in, bool& engineLatch); // When the menu standby advert should be up. struct StandbyInputs { @@ -147,6 +171,7 @@ struct StandbyInputs { bool cameraWantsRadio = false; bool raceActive = false; bool settingEnabled = false; // remote_transfer setting + bool squatBackoff = false; // squatBackoffActive() }; bool standbyWanted(const StandbyInputs& in); @@ -155,4 +180,36 @@ inline bool authTimedOut(uint32_t connectedAtMs, uint32_t nowMs) { return uint32_t(nowMs - connectedAtMs) >= kAuthTimeoutMs; } +// The post-squat quiet period, wrap-safe. `armed` is false until the first +// squat drop. +inline bool squatBackoffActive(bool armed, uint32_t droppedAtMs, uint32_t nowMs) { + return armed && uint32_t(nowMs - droppedAtMs) < kSquatBackoffMs; +} + +// Why a remote (menu-started, authenticated) session must end now. +enum class RemoteEnd : uint8_t { + kNone, + kEngine, // AUTH:ENGINE — engine above kEngineRunningRpm + kCamera, // AUTH:CAMERA — the camera wants the radio back + kIdle, // AUTH:IDLE — kRemoteIdleMs without a request +}; + +struct RemoteSessionInputs { + bool otaApplyPending = false; // FWAPPLY accepted: the flasher owns the exit + uint16_t rpm = 0; + bool cameraWantsRadio = false; + bool busy = false; // a download / upload / OTA stream in flight + uint32_t lastRequestMs = 0; // last write to the request characteristic + uint32_t nowMs = 0; +}; + +// Engine first (it also covers a paired camera's own engine wake), then the +// camera, then idleness. An in-flight transfer is activity — a long +// download is one command — but never outranks the engine or the camera. +// An accepted OTA apply is never interrupted: it reboots on its own. +RemoteEnd remoteSessionEnd(const RemoteSessionInputs& in); + +// The unsolicited notice sent before the drop; nullptr for kNone. +const char* remoteEndToken(RemoteEnd e); + } // namespace remote_auth diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index f2f49e2..d1a9331 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -59,6 +59,7 @@ void BLUETOOTH_LOOP() {} void BLE_STANDBY_LOOP() {} void bleRemoteSessionGuard() {} bool bleRemoteSessionActive() { return false; } +bool blePinSentThisSession() { return false; } // Transfer diagnostics the Bluetooth page prints. No radio in the sim, so // the page renders its idle "waiting" state and these are never meaningful — diff --git a/tests/pin_page_test.cpp b/tests/pin_page_test.cpp index c0e34a7..afe5059 100644 --- a/tests/pin_page_test.cpp +++ b/tests/pin_page_test.cpp @@ -24,6 +24,24 @@ TEST_CASE("the press that opened the page never counts") { CHECK(holdSecondsLeft(s, 5000) == 0); } +TEST_CASE("the entry press on the Transfer menu's PIN row never counts") { + // Transfer -> PIN is opened from the Transfer menu's third row, so for a + // frame or two the shared menu index still reads 2 (this page's Back) + // before the page resets it to Show — all while the opening Select is + // still down. Neither row may arm a hold off that press. + State s; + begin(s); + CHECK(press(s, kRowBack, 0) == Action::kNone); + for (uint32_t t = 100; t <= 5000; t += 100) { + CHECK(press(s, kRowShow, t) == Action::kNone); + CHECK(holdSecondsLeft(s, t) == 0); + } + // Released once, a fresh hold then works as normal. + press(s, kRowShow, 5100, false); + press(s, kRowShow, 5200); + CHECK(press(s, kRowShow, 5200 + kHoldMs) == Action::kReveal); +} + TEST_CASE("hold Select 3 s on Show reveals, once per hold") { State s; begin(s); diff --git a/tests/remote_auth_test.cpp b/tests/remote_auth_test.cpp index 339c752..0d548dc 100644 --- a/tests/remote_auth_test.cpp +++ b/tests/remote_auth_test.cpp @@ -103,12 +103,38 @@ TEST_CASE("malformed answers count as wrong") { CHECK(triesLeft(s) == kMaxFails - 2); } -TEST_CASE("a stored PIN that is not 4 digits never authenticates") { +TEST_CASE("an unreadable or invalid stored PIN never authenticates") { State s; issueNonce(s, kNonceA); char ans[kAnswerHexLen + 1]; computeAnswer("", s.nonceHex, ans); - CHECK(checkAnswer(s, ans, "", 0) == Verdict::kFail); + CHECK(checkAnswer(s, ans, "", 0) == Verdict::kPinUnavailable); + CHECK_FALSE(s.authed); + computeAnswer("12345", s.nonceHex, ans); + CHECK(checkAnswer(s, ans, "12345", 0) == Verdict::kPinUnavailable); + CHECK(checkAnswer(s, ans, "12a4", 0) == Verdict::kPinUnavailable); + CHECK_FALSE(s.authed); +} + +TEST_CASE("PIN unreadable leaves the nonce intact and counts nothing") { + State s; + issueNonce(s, kNonceA); + // Every try with the PIN unreadable: no failure, no lockout, nonce kept. + for (uint8_t i = 0; i < kMaxFails + 2; ++i) { + CHECK(checkAnswer(s, "00000000000000000000000000000000", "", 0) == + Verdict::kPinUnavailable); + } + CHECK(s.nonceValid); + CHECK(triesLeft(s) == kMaxFails); + CHECK_FALSE(isLocked(s, 0)); + // Once the card answers again, the SAME nonce still authenticates. + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kOk); + CHECK_FALSE(s.nonceValid); +} + +TEST_CASE("no nonce outranks an unreadable PIN") { + State s; + CHECK(checkAnswer(s, kGoldenAnswer, "", 0) == Verdict::kNoNonce); } TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { @@ -185,6 +211,19 @@ TEST_CASE("command gating") { CHECK_FALSE(commandAllowed(Mode::kLocked, false, "AUTHX")); CHECK_FALSE(commandAllowed(Mode::kLocked, false, "PINGET")); CHECK_FALSE(commandAllowed(Mode::kLocked, false, nullptr)); + // Exact match only: a leading space is a different (unknown) command. + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " LIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " AUTH?")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " BATT")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "BATT ")); + // An embedded NUL ends the command: what follows can't ride along. + const char authThenList[] = "AUTH?\0LIST"; + CHECK(commandAllowed(Mode::kLocked, false, authThenList)); + CHECK(std::strcmp(authThenList, "AUTH?") == 0); + const char listAfterNul[] = "\0LIST"; + CHECK_FALSE(commandAllowed(Mode::kLocked, false, listAfterNul)); + const char pinGetNul[] = "PINGET\0x"; + CHECK_FALSE(commandAllowed(Mode::kLocked, true, pinGetNul)); // Locked after auth: everything but PINGET. CHECK(commandAllowed(Mode::kLocked, true, "LIST")); @@ -221,35 +260,132 @@ TEST_CASE("standby advert only on a free menu with the camera idle") { off = in; off.raceActive = true; CHECK_FALSE(standbyWanted(off)); + off = in; + off.squatBackoff = true; + CHECK_FALSE(standbyWanted(off)); +} + +TEST_CASE("squat back-off is 5 s, only once armed, and wrap-safe") { + CHECK_FALSE(squatBackoffActive(false, 1000, 1000)); + CHECK(squatBackoffActive(true, 1000, 1000)); + CHECK(squatBackoffActive(true, 1000, 1000 + kSquatBackoffMs - 1)); + CHECK_FALSE(squatBackoffActive(true, 1000, 1000 + kSquatBackoffMs)); + CHECK(squatBackoffActive(true, 0xfffffff0u, 100)); + CHECK_FALSE(squatBackoffActive(true, 0xfffffff0u, 0xfffffff0u + kSquatBackoffMs)); } TEST_CASE("the camera always wins") { + bool latch = false; CameraInputs idle; - CHECK_FALSE(cameraWantsRadio(idle)); + CHECK_FALSE(cameraWantsRadio(idle, latch)); CameraInputs c = idle; c.fsmIdleOrUnpaired = false; // waking / recording / watching / pairing - CHECK(cameraWantsRadio(c)); + CHECK(cameraWantsRadio(c, latch)); c = idle; c.testPageOpen = true; - CHECK(cameraWantsRadio(c)); + CHECK(cameraWantsRadio(c, latch)); c = idle; c.ownsRadio = true; - CHECK(cameraWantsRadio(c)); + CHECK(cameraWantsRadio(c, latch)); // A paired camera's engine above the wake threshold claims the radio // before the camera FSM's own debounce does. c = idle; c.paired = true; c.rpm = kCameraWakeRpm; - CHECK_FALSE(cameraWantsRadio(c)); + CHECK_FALSE(cameraWantsRadio(c, latch)); c.rpm = kCameraWakeRpm + 1; - CHECK(cameraWantsRadio(c)); + CHECK(cameraWantsRadio(c, latch)); + CHECK(latch); // No paired camera: the engine starting doesn't matter. + latch = false; c = idle; c.rpm = 9000; - CHECK_FALSE(cameraWantsRadio(c)); + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); +} + +TEST_CASE("the engine claim holds until the engine is really off") { + bool latch = false; + CameraInputs c; + c.paired = true; + // A pull-start: cranking blips over 500 and sags back between pulls. + c.rpm = 450; + CHECK_FALSE(cameraWantsRadio(c, latch)); + c.rpm = 620; + CHECK(cameraWantsRadio(c, latch)); + const uint16_t band[] = {480, 350, 300, 499, 450}; + for (uint16_t rpm : band) { + c.rpm = rpm; + CHECK(cameraWantsRadio(c, latch)); // no flapping between 300 and 500 + } + c.rpm = kCameraReleaseRpm - 1; + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); + c.rpm = 450; // back in the band, but not latched: stays released + CHECK_FALSE(cameraWantsRadio(c, latch)); + + // Unpairing releases a latched claim at once. + c.rpm = 900; + CHECK(cameraWantsRadio(c, latch)); + c.paired = false; + c.rpm = 450; + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); +} + +TEST_CASE("a remote session ends on the engine, the camera, or idleness") { + RemoteSessionInputs in; + in.lastRequestMs = 1000; + in.nowMs = 2000; + CHECK(remoteSessionEnd(in) == RemoteEnd::kNone); + + // Engine, with or without a camera — and ahead of it. + RemoteSessionInputs e = in; + e.rpm = kEngineRunningRpm; + CHECK(remoteSessionEnd(e) == RemoteEnd::kNone); + e.rpm = kEngineRunningRpm + 1; + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + e.cameraWantsRadio = true; + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + e.busy = true; // a download in flight doesn't hold off the engine + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + + RemoteSessionInputs c = in; + c.cameraWantsRadio = true; + c.busy = true; + CHECK(remoteSessionEnd(c) == RemoteEnd::kCamera); + + // Idle: 10 minutes with no request, unless something is streaming. + RemoteSessionInputs i = in; + i.nowMs = in.lastRequestMs + kRemoteIdleMs - 1; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + i.nowMs = in.lastRequestMs + kRemoteIdleMs; + CHECK(remoteSessionEnd(i) == RemoteEnd::kIdle); + i.busy = true; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + // Wrap-safe. + i.busy = false; + i.lastRequestMs = 0xffffff00u; + i.nowMs = 0xffffff00u + kRemoteIdleMs; + CHECK(remoteSessionEnd(i) == RemoteEnd::kIdle); + i.nowMs = 100; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + + // An accepted OTA apply owns the exit, whatever else is going on. + RemoteSessionInputs o = e; + o.otaApplyPending = true; + o.nowMs = o.lastRequestMs + kRemoteIdleMs; + CHECK(remoteSessionEnd(o) == RemoteEnd::kNone); +} + +TEST_CASE("remote end tokens") { + CHECK(remoteEndToken(RemoteEnd::kNone) == nullptr); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kEngine), "AUTH:ENGINE") == 0); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kCamera), "AUTH:CAMERA") == 0); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kIdle), "AUTH:IDLE") == 0); } TEST_CASE("auth timeout is 20 s and wrap-safe") { From 5ee4323ff9e3435e1cec5f02df58c606b5c7c564 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:31:28 +0000 Subject: [PATCH 6/7] plan 0019: keep the stored PIN valid, private and stable Review fixes for PR #168 (logger side): - ensureDefaultSettings() regenerates a stored PIN that the handshake could never accept (wrong length, non-digits, or a JSON number that getSetting() can't read), instead of leaving remote transfer locked out for good. - Transfer -> PIN reads the PIN wider than four digits and shows "PIN read failed" / "PIN invalid: New PIN" rather than a blank page or a value cut down to four digits. The page also lands on Show rather than inheriting the Transfer menu's row index. - The PIN no longer reaches the debug serial: createDefaultSettings() drops it from its log line and setSettingInner() hides the value of protected keys. - SRESET keeps a valid bluetooth_pin. A reset sent from a remote session used to roll a PIN the app could never learn; replacing the PIN stays on the device's PIN page. - settings.h: remote_transfer costs 23 bytes, not 26. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- BirdsEye/BirdsEye.ino | 32 +++++++++++++++----- BirdsEye/display_pages.ino | 6 +++- BirdsEye/display_ui.ino | 5 +++- BirdsEye/settings.h | 8 +++-- BirdsEye/settings.ino | 61 +++++++++++++++++++++++++++++++------- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 239458b..1f4a05e 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -849,8 +849,14 @@ const int PAGE_TRANSFER_PIN = -22; pin_page::State transferPinState; // The digits, only while the page is showing them — cleared on hide. char transferPinDigits[remote_auth::kPinLen + 1] = ""; -// Last New PIN attempt could not be written (shown on the page). -bool transferPinWriteFailed = false; +// Why the last Show / New PIN produced no digits (shown on the page instead +// of a blank). A stored PIN that isn't exactly four digits is reported as +// such, never shown cut down to four. +const uint8_t TRANSFER_PIN_NOTE_NONE = 0; +const uint8_t TRANSFER_PIN_NOTE_WRITE_FAILED = 1; +const uint8_t TRANSFER_PIN_NOTE_READ_FAILED = 2; +const uint8_t TRANSFER_PIN_NOTE_INVALID = 3; +uint8_t transferPinNote = TRANSFER_PIN_NOTE_NONE; // running menu (these must be in order) #if BIRDSEYE_ENABLE_PROFILING @@ -2317,23 +2323,35 @@ void transferPinPageLoop() { in.nowMs = millis(); switch (pin_page::step(transferPinState, in)) { - case pin_page::Action::kReveal: - transferPinWriteFailed = false; - if (getSetting("bluetooth_pin", transferPinDigits, sizeof(transferPinDigits))) { + case pin_page::Action::kReveal: { + // Read wider than a PIN so a too-long value is caught, not truncated. + char stored[16]; + if (!getSetting("bluetooth_pin", stored, sizeof(stored))) { + transferPinNote = TRANSFER_PIN_NOTE_READ_FAILED; + } else if (!remote_auth::isValidPin(stored)) { + transferPinNote = TRANSFER_PIN_NOTE_INVALID; + } else { + transferPinNote = TRANSFER_PIN_NOTE_NONE; + strncpy(transferPinDigits, stored, sizeof(transferPinDigits)); + transferPinDigits[sizeof(transferPinDigits) - 1] = '\0'; pin_page::reveal(transferPinState, in.nowMs); } + memset(stored, 0, sizeof(stored)); forceDisplayRefresh(); break; + } case pin_page::Action::kNewPin: { char pin[remote_auth::kPinLen + 1]; generateBlePin(pin); // Persist-first: show the new PIN only once it is the stored one. - transferPinWriteFailed = !setSetting("bluetooth_pin", pin); - if (!transferPinWriteFailed) { + const bool written = setSetting("bluetooth_pin", pin); + transferPinNote = written ? TRANSFER_PIN_NOTE_NONE : TRANSFER_PIN_NOTE_WRITE_FAILED; + if (written) { strncpy(transferPinDigits, pin, sizeof(transferPinDigits)); transferPinDigits[sizeof(transferPinDigits) - 1] = '\0'; pin_page::reveal(transferPinState, in.nowMs); } + memset(pin, 0, sizeof(pin)); forceDisplayRefresh(); break; } diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 3625624..ce322f4 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -535,8 +535,12 @@ void displayPage_transfer_pin() { display.print(F("hides in ")); display.print(pin_page::revealSecondsLeft(transferPinState, now)); display.println(F("s")); - } else if (transferPinWriteFailed) { + } else if (transferPinNote == TRANSFER_PIN_NOTE_WRITE_FAILED) { display.println(F("PIN write failed")); + } else if (transferPinNote == TRANSFER_PIN_NOTE_READ_FAILED) { + display.println(F("PIN read failed")); + } else if (transferPinNote == TRANSFER_PIN_NOTE_INVALID) { + display.println(F("PIN invalid: New PIN")); } else { display.println(F("hold SELECT 3s")); } diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index fae6225..67972dd 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -524,8 +524,11 @@ void handleMenuPageSelection() { // PIN — the remote-transfer PIN, behind a hold (plan 0019). debugln(F("Transfer: PIN selected")); pin_page::begin(transferPinState); - transferPinWriteFailed = false; + transferPinNote = TRANSFER_PIN_NOTE_NONE; memset(transferPinDigits, 0, sizeof(transferPinDigits)); + // Land on Show, not on this menu's row index (2 = the PIN page's Back) + // for the frames before the page's first render resets it. + menuSelectionIndex = 0; switchToDisplayPage(PAGE_TRANSFER_PIN); } else if (menuSelectionIndex == 0) { // Bluetooth — same flow as before diff --git a/BirdsEye/settings.h b/BirdsEye/settings.h index 2a961e4..899f6c3 100644 --- a/BirdsEye/settings.h +++ b/BirdsEye/settings.h @@ -38,7 +38,7 @@ // 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. Plan 0019's remote_transfer adds 26. +// len(key) + len(value) + 6. Plan 0019's remote_transfer adds 23. /////////////////////////////////////////// #define SETTINGS_JSON_CAPACITY 1024 @@ -63,8 +63,10 @@ bool setSetting(const char* key, const char* value); // quarantining the user's whole settings file behind their back. bool removeSetting(const char* key); -// Delete the settings file and re-create with fresh defaults -// (rolls a new random BLE name and PIN). +// Delete the settings file and re-create with fresh defaults (rolls a new +// random BLE name). A valid bluetooth_pin is KEPT (plan 0019): SRESET can +// come from a remote session, which could never learn a new PIN. Replacing +// the PIN is the device's Transfer -> PIN page. bool resetSettings(); // Fill `out` with `n` bytes from the nRF52840 hardware RNG — through the diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index 56dc488..66457a6 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -104,10 +104,12 @@ void generateBlePin(char* out) { } /** - * @brief Generate default settings file on first boot + * @brief Write a fresh default settings file. + * @param keepPin A valid PIN to carry over (resetSettings()), or nullptr to + * roll a new one. * @return true if file created successfully */ -bool createDefaultSettings() { +static bool writeDefaultSettings(const char* keepPin) { if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { debugln(F("Settings: Cannot acquire SD access for defaults")); return false; @@ -127,7 +129,12 @@ bool createDefaultSettings() { // Random 4-digit PIN from the hardware RNG (plan 0019): it gates remote // transfer now, so random() seeded from micros() is not good enough. char defaultPin[remote_auth::kPinLen + 1]; - generateBlePin(defaultPin); + if (keepPin != nullptr && remote_auth::isValidPin(keepPin)) { + strncpy(defaultPin, keepPin, sizeof(defaultPin)); + defaultPin[sizeof(defaultPin) - 1] = '\0'; + } else { + generateBlePin(defaultPin); + } settingsJson["bluetooth_pin"] = defaultPin; // Random racing-themed device name so multi-device log dumps stay sorted @@ -156,14 +163,22 @@ bool createDefaultSettings() { settingsFile.close(); releaseSDAccess(SD_ACCESS_TRACK_PARSE); + // The PIN is a secret (plan 0019) — never on the debug serial. + memset(defaultPin, 0, sizeof(defaultPin)); debug(F("Settings: Created defaults - name: ")); - debug(defaultName); - debug(F(", pin: ")); - debugln(defaultPin); + debugln(defaultName); return true; } +/** + * @brief Generate default settings file on first boot + * @return true if file created successfully + */ +bool createDefaultSettings() { + return writeDefaultSettings(nullptr); +} + /** * @brief Ensure all expected settings keys exist, adding missing ones with defaults. * Called when settings file already exists to handle firmware upgrades that add new keys. @@ -297,11 +312,17 @@ static void ensureDefaultSettings() { snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); setSetting("bluetooth_name", name); } - if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { + // A PIN the handshake can never accept (a hand-edited "12345", a number + // instead of a string — which getSetting() can't read either) would lock + // remote transfer out for good, so it is replaced like a missing one. + // The new one is on the device's Transfer -> PIN page. + if (!getSetting("bluetooth_pin", buf, sizeof(buf)) || !remote_auth::isValidPin(buf)) { char pin[remote_auth::kPinLen + 1]; generateBlePin(pin); setSetting("bluetooth_pin", pin); + memset(pin, 0, sizeof(pin)); } + memset(buf, 0, sizeof(buf)); } /** @@ -452,12 +473,23 @@ bool getSetting(const char* key, char* buf, size_t bufSize) { * @return true on success, false on failure */ /** - * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). + * @brief Delete settings file and recreate with fresh defaults (new random + * BLE name). The remote-transfer PIN is KEPT when it is valid (plan + * 0019): SRESET can come from a remote session, and an app that has + * just reset the logger must still be able to reach it — it has no + * way to learn a new PIN without the device in hand. A new PIN is + * what the device's Transfer -> PIN page is for. * @return true on success */ bool resetSettings() { if (!sdSetupSuccess) return false; + char keepPin[16]; + if (!getSetting("bluetooth_pin", keepPin, sizeof(keepPin)) || + !remote_auth::isValidPin(keepPin)) { + keepPin[0] = '\0'; + } + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { debugln(F("Settings: Cannot acquire SD for reset")); return false; @@ -469,7 +501,9 @@ bool resetSettings() { releaseSDAccess(SD_ACCESS_TRACK_PARSE); debugln(F("Settings: Deleted, recreating defaults")); - return createDefaultSettings(); + const bool ok = writeDefaultSettings(keepPin[0] != '\0' ? keepPin : nullptr); + memset(keepPin, 0, sizeof(keepPin)); + return ok; } static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { @@ -545,8 +579,13 @@ static bool setSettingInner(const char* key, const char* value, bool healCorrupt debug(F("Settings: Saved ")); debug(key); - debug(F(" = ")); - debugln(value); + // The PIN never reaches the debug serial (plan 0019). + if (remote_auth::isProtectedSettingKey(key)) { + debugln(F(" = ")); + } else { + debug(F(" = ")); + debugln(value); + } return true; } From 37c483b391e7fe73562724433b16d7827a7e799b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:33:22 +0000 Subject: [PATCH 7/7] plan 0019: document the review fixes and correct stale claims - Plan 0019: AUTH:ENGINE / AUTH:IDLE drop notices, AUTH:BUSY for an unreadable PIN, the squat back-off, synchronous standby handover, SRESET keeping the PIN, and the accepted local-start PINGET gap in the threat model. - CLAUDE.md / ARCHITECTURE.md: BLE is no longer lazy on stock builds (the menu standby starts it), plus the new rules and constants. - CHANGELOG: the PIN does cross the air on one path, PINGET on a local start; the remote-session end, PIN-sent notice, PIN regeneration and SRESET behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk --- ARCHITECTURE.md | 27 ++++++-- CHANGELOG.md | 38 ++++++++--- CLAUDE.md | 63 ++++++++++++----- docs/plans/0019-remote-transfer-pin.md | 95 ++++++++++++++++++++------ 4 files changed, 172 insertions(+), 51 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a01a2d3..d0c67e3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,7 +134,8 @@ to the matching `*_LOOP()`. untouched. Readings older than 1 s go NaN — never held across a dropout. Gated on the `BIRDSEYE_ENABLE_SENSOREGG` build flag: on in the beta channel, off in master/release, where the scanner and temp - pages are compiled out, BLE returns to lazy init, and the DOVEX + pages are compiled out, BLE init moves from boot to the first + main-menu visit (remote-transfer standby), 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` / @@ -375,8 +376,10 @@ an explicit `bleOwner` (NONE/TRANSFER/CAMERA); the owner model keeps a camera link from ever triggering the transfer path's auto-reboot-on-disconnect, and opening the transfer page force-releases the camera first. The link is Just-Works bonded (the genuine remote link -is encrypted), and BLE comes up lazily on the first camera action, so an -unpaired device pays nothing. +is encrypted). The camera costs nothing until its first advertising action, +but it no longer decides when BLE starts: the remote-transfer standby +(below) brings the core up on the first main-menu visit, and SensorEgg +builds start it at boot. The lifecycle is a **pure FSM** (`camera_fsm`), deliberately RPM-driven and simple: wake on engine start, connect+subscribe, record once RPM has held a @@ -451,13 +454,25 @@ that may read the PIN back (`PINGET`), which is how an app pairs. The lockout (5 tries, then 60 s doubling to 15 min) and the 20 s squatting limit keep the pits out; they do not stop a sniffer, because the link is unencrypted Just Works — a captured handshake brute-forces a 4-digit PIN -offline. That is accepted and written down in the plan. +offline. That is accepted and written down in the plan, along with the +other accepted gap: an open (local) session hands the PIN to whichever app +connects first, which is why the transfer page then says "PIN sent to app!" +and why a local start first drops any link left over from the menu advert. + +A remote session must never be able to strand the logger, because nobody +at the logger started it: it ends (with an `AUTH:` notice, then the usual +reboot) when the engine starts, when the camera wants the radio, or after +10 minutes without a request. There is one peripheral link, shared with the Insta360 remote, and the camera has priority at every stage. Standby only advertises while the camera has no use for the radio, and it lets go when a paired camera's -engine passes 500 rpm — before the camera's own 2 s wake debounce, so the -camera never finds the radio taken. Remote standby therefore never calls +engine passes 500 rpm (held until it drops below 300, so a pull-start +can't flap the advert) — before the camera's own 2 s wake debounce, so the +camera never finds the radio taken. Handing the radio over is synchronous: +standby releases ownership, then waits out its peer's disconnect before the +camera can claim the slot, and the disconnect callback matches the transfer +link by handle before routing on owner. Remote standby therefore never calls `CAMERA_FORCE_RELEASE()`; only a person at the logger can bump the camera. All of these rules are the `remote_auth` pure unit, and the 3 s hold that reveals or replaces the PIN on the device is `pin_page`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cd330..297bd32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,24 +16,38 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 - **Remote transfer with a PIN** (plan 0019). While the logger sits on its main menu it now advertises the transfer service, so the app can start a download without anyone pressing Transfer → Bluetooth — once it proves it - knows the logger's PIN. The PIN never crosses the air: the logger sends a - one-time challenge and the app answers with an HMAC of it. Five wrong - answers lock remote transfer for a minute (doubling up to 15 minutes), and - a peer that never answers is dropped after 20 seconds. Leaving the menu + knows the logger's PIN. A remote start never sends the PIN over the air: + the logger sends a one-time challenge and the app answers with an HMAC of + it. (It does travel, unencrypted, on one path: to the app connected to a + transfer started on the device, which is how the app learns it.) Five + wrong answers lock remote transfer for a minute (doubling up to 15 + minutes), and a peer that never answers is dropped after 20 seconds, with + a 5-second pause before the logger advertises again. Leaving the menu stops the advert, and the `remote_transfer` setting (`on` by default) turns it off entirely. The transfer page says **Remote Transfer** when a session started this way. +- **A remote transfer can't strand the logger.** It ends on its own — the + app is told why (`AUTH:ENGINE`, `AUTH:CAMERA`, `AUTH:IDLE`), then the + logger reboots as after any transfer — when the engine starts (camera + paired or not), when the camera needs the radio, or after 10 minutes + without a request. Before, an app left connected kept the logger parked + with no logging, no auto-race and no idle shutdown. - **The camera always wins the radio.** Remote standby never advertises while a paired Insta360 is doing anything, the camera bench page is open, - or a paired camera's engine is running; and a remote transfer ends the - moment the engine starts with a camera paired, so auto-record is never - held up. Starting Transfer → Bluetooth on the device still bumps the - camera, as before. + or a paired camera's engine is running (from 500 rpm until it drops below + 300, so pull-starting doesn't flick the advert on and off); and a remote + transfer ends the moment the engine starts, so auto-record is never held + up. Starting Transfer → Bluetooth on the device still bumps the camera, + as before. - **Transfer → PIN page.** Hold Select for 3 seconds on *Show PIN* to see the PIN for 15 seconds, or on *New PIN* to replace it. With the SD card soldered in, this (plus the USB drive and the app re-reading it on a local - Bluetooth start) is how a lost PIN is recovered. The Transfer menu now - scrolls to fit its fourth row. + Bluetooth start) is how a lost PIN is recovered. If the stored PIN can't + be read, or isn't four digits, the page says so instead of going blank. + The Transfer menu now scrolls to fit its fourth row. +- On a transfer started on the device, the page title changes to **PIN sent + to app!** once the connected app has read the PIN — the first app in + range isn't necessarily yours. - BLE commands `AUTH?`, `AUTH:` and `PINGET` (local start only). ### Changed @@ -43,6 +57,10 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 instead. `SSET:bluetooth_pin` only accepts exactly four digits. - New PINs come from the hardware random number generator instead of `random()` seeded with the boot time. +- A stored PIN that isn't exactly four digits is replaced at boot (see it + under Transfer → PIN), and a settings reset (`SRESET`) now keeps the PIN, + so an app that resets the logger remotely can still reach it. +- The PIN is no longer printed on the debug serial port. ### Fixed - **Drag mode review fixes** (plans 0015/0016): diff --git a/CLAUDE.md b/CLAUDE.md index 7f4e176..3fdf490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `sd_format_page.{h,cpp}` | SD format-confirm boot page state machine: Select held 3 s continuously → format (release restarts the full window; other buttons never confirm), 5 min idle → shutdown | | `sd_probe.{h,cpp}` | Boot SD probe verdict (mounted / retry / unformatted / dead): the format offer needs the card layer to answer on **two consecutive** probes across a 250 ms settle with the volume mounting on neither — one no-volume look is what a card wedged mid-command by a WDT-interrupted boot produces — and a card-layer failure at any point is "dead" (FAULT), never "blank" | | `sha256.{h,cpp}` | SHA-256 + HMAC-SHA256 for the remote-transfer handshake (plan 0019), pinned to FIPS 180-4 and RFC 4231 vectors — the viewer (WebCrypto) and LapWing (hmac/sha2) must agree byte-for-byte | -| `remote_auth.{h,cpp}` | Remote transfer rules (plan 0019): the challenge answer (`HMAC(PIN, "BEAUTH1|"+nonce)`, first 16 bytes as hex — golden vector shared with both apps), single-use nonce, 5-try lockout doubling 60 s → 15 min (wrap-safe), 20 s squat limit, locked-mode command gating (`AUTH?`/`AUTH:`/`BATT` only; `PINGET` open-mode only), protected setting keys + PIN format, the standby predicate and `cameraWantsRadio()` | +| `remote_auth.{h,cpp}` | Remote transfer rules (plan 0019): the challenge answer (`HMAC(PIN, "BEAUTH1|"+nonce)`, first 16 bytes as hex — golden vector shared with both apps), single-use nonce, 5-try lockout doubling 60 s → 15 min (wrap-safe), 20 s squat limit, locked-mode command gating (`AUTH?`/`AUTH:`/`BATT` only, exact match; `PINGET` open-mode only), protected setting keys + PIN format (an unreadable/invalid stored PIN is `kPinUnavailable` — nonce kept, nothing counted), the standby predicate + 5 s post-squat back-off, `cameraWantsRadio()` with its 500/300 rpm engine latch, and `remoteSessionEnd()` (engine > camera > 10 min idle; never during an accepted OTA apply) | | `pin_page.{h,cpp}` | Transfer → PIN page state machine (plan 0019): hold Select 3 s on Show (reveal 15 s) or New PIN; release / row change / side button cancels; the entry press never counts | | `sat_bars.{h,cpp}` | Status-page satellite signal bars: NAV-SAT CNO selection (used-in-nav first, strongest first) + bar x/w/h layout math for the 128×~30 px bottom half | @@ -752,17 +752,38 @@ loop() ~250 Hz `AUTH:NONCE:` (16 bytes, `hwRandomBytes()`); `AUTH:` → `AUTH:OK` (promote: transfer page, `bleRemoteSession`, labelled *Remote Transfer*) / `AUTH:FAIL:` / `AUTH:LOCKED:` / `AUTH:NO_NONCE` / - `AUTH:BUSY`. Unauthenticated after 20 s → `AUTH:TIMEOUT` + drop. A local + `AUTH:BUSY` (also the answer when the stored PIN is unreadable or not + four digits — the nonce is kept and no failure counted). Unauthenticated + after 20 s → `AUTH:TIMEOUT` + drop, then the advert stays down 5 s so a + squatter can't hold the slot in a tight loop. A local `BLE_SETUP()` is **open**: `AUTH?` → `AUTH:OPEN`, and `PINGET` → - `PIN:` is how an app pairs. `SLIST` omits `bluetooth_pin`, + `PIN:` is how an app pairs — after which the transfer page's + title reads `PIN sent to app!`, since the first app in range need not be + the owner's. `BLE_SETUP()` drops any surviving link and clears every + pending flag first, so a standby peer can never be promoted into an open + session. `SLIST` omits `bluetooth_pin`, `SGET:bluetooth_pin` → `SERR:PROTECTED`, `SSET:bluetooth_pin` needs four - digits (`SERR:BAD_VALUE`). **Camera first**: standby stops (an unauthed + digits (`SERR:BAD_VALUE`), and `SRESET` keeps a valid PIN. **Camera + first**: standby stops (an unauthed peer gets `AUTH:CAMERA`) when the camera FSM leaves IDLE/UNPAIRED, the bench page opens, the camera owns the radio, or a paired camera's engine - passes 500 rpm — ahead of the FSM's 2 s wake debounce, which is why the - standby step runs before `CAMERA_LOOP()`. The parked branch runs - `bleRemoteSessionGuard()` (with `TACH_LOOP()`) so a remote session ends - the same way. Remote start never calls `CAMERA_FORCE_RELEASE()`. The PIN + passes 500 rpm (latched until it falls below 300, so a pull-start can't + flap the advert) — ahead of the FSM's 2 s wake debounce, which is why the + standby step runs before `CAMERA_LOOP()`. **Standby teardown order**: + owner → NONE first (writes ignored from then on), advert stopped, then + the peer dropped and the disconnect WAITED for (bounded, WDT-fed) before + the loop moves on — and the disconnect callback matches our own link by + handle before routing on owner, so the camera taking the radio in the + same iteration can never be handed the standby peer's disconnect. A link + being dropped is marked (`bleDroppingConnHandle`) and gets no further + commands. The parked branch runs `bleRemoteSessionGuard()` (with + `TACH_LOOP()`); a **remote** session ends — notice, then the usual + reboot — on engine > 500 rpm whether or not a camera is paired + (`AUTH:ENGINE`), on the camera wanting the radio (`AUTH:CAMERA`), or + after 10 min without a request (`AUTH:IDLE`; an in-flight transfer counts + as activity). Nobody at the logger started it, so without these it could + park the logger with no logging, auto-race or idle shutdown. Remote start + never calls `CAMERA_FORCE_RELEASE()`. The PIN only blocks other people in the pits — the link is unencrypted, so a sniffer can brute-force a captured handshake offline (see the plan). - **Firmware OTA commands** (`FW*`, handled by `firmware_ota.ino` — see @@ -1018,7 +1039,8 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. never touched. The charging-loop soft resume (`softResumeFromCharging()`) restarts the egg scanner via `SENSOREGG_WAKE()` and re-raises the strip via `NEOPIXEL_WAKE()`; - BLE/camera stay lazy. + the camera stays lazy, and the remote-transfer standby advert (plan + 0019) restarts by itself once the menu is up again. - **System OFF entry** (`shutdownSystemOff()`, no return): wait for the entry combo's buttons to release (a held button = SENSE satisfied = instant wake-reset), **sample the tach line's parked idle level** @@ -1034,7 +1056,9 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. hardcoded). Then clear the GPIO LATCH registers (a set latch = pending DETECT = instant re-wake), clear pending FPU exceptions, then `sd_power_system_off()` when the SoftDevice is enabled - (BLE is lazy — check `sd_softdevice_is_enabled()`) else raw + (the SoftDevice may never have started — no SD card, `remote_transfer` + off, or a shutdown before the menu was reached — so check + `sd_softdevice_is_enabled()`) else raw `NRF_POWER->SYSTEMOFF`. **GPREGRET is untouched** — register 0 belongs to the OTA/bootloader handoff (subsystem 11). The WDT halts in System OFF (all clocks stop); `wdtSetup()` re-arms on the fresh boot. @@ -1324,9 +1348,12 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. Shutdown entry runs `CAMERA_SLEEP()` (same, plus a power-off that is **streamed synchronously** before the disconnect — the chip powers off right after, so the non-blocking ce82 hold would otherwise never transmit - a frame and the camera would run all night). BLE - comes up **lazily** on the first camera action (first advertising - `Action`), so unpaired users pay zero RAM/power cost. + a frame and the camera would run all night). The + camera is no longer what brings BLE up: since plan 0019 the remote-transfer standby + starts the BLE core on the first main-menu visit (unless + `remote_transfer` is `off` or there is no SD card), and SensorEgg builds + start it at boot. The camera itself still costs nothing until its first + advertising `Action`, so an unpaired camera adds no radio time. - **Threading**: same deferred pattern as `firmware_ota` — Bluefruit callbacks (connect/disconnect/ce81 writes/ce82 CCCD writes) only copy into RAM and set volatile flags; `CAMERA_LOOP()` on the main loop @@ -1386,7 +1413,8 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. compiles `sensoregg.ino` down to no-op `SENSOREGG_SETUP/LOOP` and NaN accessors, drops the Temp1 and Temp2 race pages from the rotation (`display_pages.ino` + the page-constant block in `BirdsEye.ino`), and - returns BLE to lazy init. `1` (passed by `beta.yml`, and by + returns BLE init to the first main-menu visit (the plan-0019 remote + transfer standby) instead of boot. `1` (passed by `beta.yml`, and by `compile-sketch.yml` for PRs targeting `BETA`) is everything described below. **Plan 0013 closed the three leaks**: the DOVEX `Temp1`/`Junction1`/`Temp2` columns, the `temp1_alert_c` setting, and @@ -2040,7 +2068,7 @@ the one loaded). Sector lines stay optional — zero, one, or two. | Key | Type | Default | Purpose | |-----|------|---------|---------| | `bluetooth_name` | string | Random | BLE device name | -| `bluetooth_pin` | string | Random 4-digit (hardware RNG) | Remote-transfer PIN (plan 0019). **Never readable over BLE except `PINGET` on a locally started session**; shown on-device under Transfer → PIN; `SSET` accepts exactly 4 digits | +| `bluetooth_pin` | string | Random 4-digit (hardware RNG) | Remote-transfer PIN (plan 0019). **Never readable over BLE except `PINGET` on a locally started session**; shown on-device under Transfer → PIN; `SSET` accepts exactly 4 digits; a stored value that isn't 4 digits is regenerated at boot; `SRESET` keeps it; never printed on debug serial | | `remote_transfer` | string | `"on"` | Menu standby advert for PIN-gated remote transfer (plan 0019). Only an explicit `off` disables it. Read once per boot | | `camera_serial` | string | `""` (empty = unpaired) | Paired Insta360 X4's 6-char serial (auto-captured on pairing, or entered manually) | | `device_name` | string | Random racing words | Identifies the logging device (DOVEX header) | @@ -2104,7 +2132,10 @@ the one loaded). Sector lines stay optional — zero, one, or two. | SensorEgg wireless EGT POC | `BIRDSEYE_ENABLE_SENSOREGG`, default 0; 1 on the beta channel | `project.h` | | Charging screen timeout | 10 s (`CHARGE_DISPLAY_TIMEOUT_MS`) | `project.h` | | Remote auth lockout | 5 wrong answers → 60 s, doubling per lockout to 15 min; RAM only | `remote_auth.h` | -| Remote auth squat limit | 20 s connected without `AUTH:OK` → dropped | `remote_auth.h` | +| Remote auth squat limit | 20 s connected without `AUTH:OK` → dropped, then 5 s with no advert | `remote_auth.h` | +| Remote session end | engine > 500 rpm (`AUTH:ENGINE`) / camera wants radio (`AUTH:CAMERA`) / 10 min without a request (`AUTH:IDLE`) | `remote_auth.h` | +| Remote standby camera engine latch | claim > 500 rpm, release < 300 rpm (paired camera only) | `remote_auth.h` | +| BLE local-drop settle | ≤ 1 s, WDT-fed (`BLE_DROP_SETTLE_MS`) | `bluetooth.ino` | | Transfer PIN page | hold Select 3 s; reveal lasts 15 s | `pin_page.h` | | Sat bars display cap / CNO ceiling | 16 bars / 50 dB-Hz | `sat_bars.h` | | Crossing threshold | 7.0 m | `BirdsEye.ino` | diff --git a/docs/plans/0019-remote-transfer-pin.md b/docs/plans/0019-remote-transfer-pin.md index 0cc951e..d50b760 100644 --- a/docs/plans/0019-remote-transfer-pin.md +++ b/docs/plans/0019-remote-transfer-pin.md @@ -50,12 +50,20 @@ log → AUTH:NONCE:<32 hex> (locked) | AUTH:OPEN (open) app → AUTH: log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE - | AUTH:BUSY (RNG pool empty, or an answer already queued) + | AUTH:BUSY (RNG pool empty, an answer already queued, or the + stored PIN unreadable / not 4 digits) ``` -- The PIN never crosses the air; a recorded exchange can't be replayed - because the nonce is single-use (consumed on any answer, right or wrong) - and a new `AUTH?` discards the previous one. +- The handshake never sends the PIN; a recorded exchange can't be replayed + because the nonce is single-use (consumed on any right or wrong answer) + and a new `AUTH?` discards the previous one. The PIN does cross the air + in one place — `PINGET` on a local start — see the threat model below. +- **The logger's own failure is not a wrong answer.** If the stored PIN + can't be read (SD refused, corrupt file) or isn't exactly 4 digits (a + hand-edited `"12345"`, or `1234` stored as a JSON number), the answer is + `AUTH:BUSY`: the nonce stays valid and no failure is counted, so the app + retries instead of walking toward a lockout it did nothing to earn. Boot + replaces a stored PIN that isn't 4 digits. - Nonce = 16 bytes from the SoftDevice RNG (`sd_rand_application_vector_get`). - **Not bound to `bluetooth_name`** (changed during implementation). The design artifact mixed the name into the MAC, but the nonce is already @@ -72,12 +80,18 @@ log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE | App sends | Logger replies | When | |---|---|---| -| `PINGET` | `PIN:` / `AUTH:REQUIRED` | Open mode only | +| `PINGET` | `PIN:` / `AUTH:REQUIRED` / `SERR:NOT_FOUND` (unreadable or not 4 digits) | Open mode only. The transfer page then reads `PIN sent to app!` | | `SLIST` | as before, **minus `bluetooth_pin`** | All modes | | `SGET:bluetooth_pin` | `SERR:PROTECTED` | All modes — use `PINGET` | -| `SSET:bluetooth_pin=…`, `SRESET` | as before | Open, or locked after `AUTH:OK`. PIN must be exactly 4 digits (`SERR:BAD_VALUE`) | +| `SSET:bluetooth_pin=…`, `SRESET` | as before | Open, or locked after `AUTH:OK`. PIN must be exactly 4 digits (`SERR:BAD_VALUE`). `SRESET` **keeps** a valid PIN — a remote app that reset the logger could never learn a new one; replacing it is the device's PIN page | | (unsolicited) | `AUTH:CAMERA` | Right before the logger drops a link because the camera needs the radio | -| (unsolicited) | `AUTH:TIMEOUT` | Right before a peer that never authenticated is dropped (20 s) | +| (unsolicited) | `AUTH:TIMEOUT` | Right before a peer that never authenticated is dropped (20 s); the advert then stays down 5 s | +| (unsolicited) | `AUTH:ENGINE` | Right before a **remote** session ends because the engine passed 500 rpm (camera paired or not) | +| (unsolicited) | `AUTH:IDLE` | Right before a **remote** session ends after 10 min with no request (an in-flight download/upload/OTA counts as activity) | + +All four drop notices are best-effort: the logger sends one, waits 50 ms, +then drops the link (a remote session then reboots, as every transfer +exit does). An app must treat a plain disconnect the same way. ## Logger rules @@ -85,7 +99,28 @@ log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE inactive, no race session, `remote_transfer` setting on. Leaving the menu stops the advert. Auto-race and the 5-minute menu idle shutdown are unchanged, so standby lasts exactly as long as the menu does. -- **Squatting:** a peer without `AUTH:OK` after 20 s is disconnected. +- **Squatting:** a peer without `AUTH:OK` after 20 s is disconnected, and + the advert stays down for 5 s. One slot means anyone in range (or a + bonded camera chasing our address) can hold it in a 20 s loop; the + back-off stops it being a tight one. It is still a denial of service the + PIN can't prevent — it never bypasses auth. +- **A remote session can't strand the logger.** It parks `loop()` (no + logging, no auto-race, no idle shutdown), and nobody at the logger + started it, so it ends — `AUTH:ENGINE` / `AUTH:CAMERA` / `AUTH:IDLE`, then + the usual reboot — on engine > 500 rpm, on the camera wanting the radio, + or after 10 minutes without a request. Engine first; an accepted OTA + apply is never interrupted. The rule is `remote_auth::remoteSessionEnd`. +- **Handing the radio over is synchronous.** Standby stop releases the + owner first (so a write that preempts the teardown is ignored), stops the + advert, then waits (≤ 1 s, WDT-fed) for its peer's disconnect before the + loop moves on — the camera can claim the radio in the same iteration, + and the disconnect callback matches our link by handle before routing on + owner, so the camera is never handed the standby peer's disconnect. A + link being dropped is marked and gets no further commands. +- **A local start begins empty.** `BLE_SETUP()` drops any surviving + transfer-side link and clears every pending flag before opening an open + session — otherwise an unauthenticated standby peer whose link outlived + the menu would be promoted straight into it, `PINGET` included. - **State:** standby holds `bleOwner = TRANSFER` with `bleActive` false — nothing parks, and a standby peer's disconnect never reboots. `AUTH:OK` promotes into the ordinary transfer page (`bleActive`, reboot on @@ -106,10 +141,13 @@ for it. - Camera turns active during standby → advert stops that iteration; an unauthenticated peer gets `AUTH:CAMERA` and is dropped. - Engine starts with a paired camera → RPM above 500 (the camera wake - threshold) stops standby, or ends a **remote** transfer (`AUTH:CAMERA`, - then the usual reboot). This fires ahead of the camera FSM's own 2 s wake - debounce, and the standby step runs before `CAMERA_LOOP()`, so the camera - never finds the radio taken. The transfer branch parks `loop()`, so the + threshold) stops standby, or ends a **remote** transfer (`AUTH:ENGINE` + — since the review, any engine start ends a remote session, camera or + not — then the usual reboot). The engine claim is latched until RPM + falls below 300, so a pull-start cranking through 500 doesn't flap the + advert (an SD read and an advert rebuild each time). This fires ahead of + the camera FSM's own 2 s wake debounce, and the standby step runs before + `CAMERA_LOOP()`, so the camera never finds the radio taken. The transfer branch parks `loop()`, so the remote guard runs `TACH_LOOP()` itself. - Remote start **never** calls `CAMERA_FORCE_RELEASE()`. Only a person at the logger choosing Transfer → Bluetooth bumps the camera (today's rule). @@ -136,8 +174,19 @@ Never on the transfer screen. Every path needs the device in hand. ## What the PIN does and doesn't protect -It keeps the pits out (weeks of over-the-air guessing with the lockout). It -does **not** stop a sniffer: the link is unencrypted Just Works, so data +It keeps the pits out (weeks of over-the-air guessing with the lockout). Two +gaps are accepted: + +- **A local start hands the PIN to whoever connects first.** Transfer → + Bluetooth is open to anyone in range, as it always was — but where a + stranger who won that race used to get one session, they now also get + the PIN (`PINGET`) and keep remote access until it is replaced. Mitigated, + not closed: the session starts with no link left over from the menu + advert, the transfer page says `PIN sent to app!` the moment `PINGET` is + served so the person who opened it can see it, and *New PIN* on the + device revokes it. + +It also does **not** stop a sniffer: the link is unencrypted Just Works, so data after `AUTH:OK` is readable and one captured handshake brute-forces offline instantly (6 digits would not change that). Real protection needs an encrypted, bonded link (LE Secure Connections) — a separate project. @@ -153,12 +202,15 @@ encrypted, bonded link (LE Secure Connections) — a separate project. - Pure units + host tests: `sha256` (FIPS 180-4 + RFC 4231 HMAC vectors), `remote_auth` (nonce/answer/lockout/timeout state machine, command gating, - protected-key and PIN-format rules, standby/camera predicates), + protected-key and PIN-format rules, standby/camera predicates, the + engine latch, the squat back-off, `remoteSessionEnd`), `pin_page` (the hold-to-reveal page). - `bluetooth.ino`: AUTH dispatch, locked-mode gate, settings redaction, - standby advert + teardown, remote session promotion. -- `settings.ino`: hardware-RNG PIN, `remote_transfer` default. -- `BirdsEye.ino`: standby step in `loop()`, remote parked-branch camera check. + standby advert + teardown, remote session promotion and guard, the + handle-first disconnect routing. +- `settings.ino`: hardware-RNG PIN, `remote_transfer` default, invalid-PIN + regeneration, PIN kept across `SRESET`, PIN off the debug log. +- `BirdsEye.ino`: standby step in `loop()`, remote parked-branch guard. - `display_ui.ino` / `display_pages.ino`: PIN page; `BirdsEye.ino` `transferPinPageLoop()`. - Sim: stubs for the new BLE surface, goldens for the PIN page (the @@ -174,5 +226,10 @@ encrypted, bonded link (LE Secure Connections) — a separate project. - [x] Standby advertising, locked mode, camera priority - [x] PIN page - [x] Docs + changelog +- [x] Review fixes (PR #168): synchronous standby handover + handle-first + disconnect routing, remote-session end on engine / idle, unreadable PIN + = `AUTH:BUSY`, local start drops leftover links + "PIN sent", engine + latch, squat back-off, PIN kept across `SRESET`, PIN off debug serial - [ ] Hardware check: a phone authenticating from the menu, the lockout, - and the camera taking the radio back with the engine running + the camera taking the radio back with the engine running, and a remote + session ending on engine start (`AUTH:ENGINE`) with no camera paired