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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/clang-tidy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@ 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 \
BirdsEye/pin_page.cpp
44 changes: 41 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -439,6 +442,41 @@ 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, 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 (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`.

### The settings file has a hard size ceiling

Every settings read path caps at `sizeof(settingsFileBuffer) - 1`. A file
Expand Down
88 changes: 86 additions & 2 deletions BirdsEye/BirdsEye.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -840,6 +842,21 @@ 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] = "";
// 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
Expand Down Expand Up @@ -2289,6 +2306,65 @@ 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: {
// 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.
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;
}
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
Expand Down Expand Up @@ -2971,6 +3047,10 @@ 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 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
if (millis() - lastBatteryCheck > batteryUpdateInterval) {
Expand Down Expand Up @@ -3040,7 +3120,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());

Expand Down Expand Up @@ -3157,9 +3240,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();

Expand Down
32 changes: 32 additions & 0 deletions BirdsEye/bluetooth.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,38 @@ 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:<answer>) 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 — 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
Expand Down
Loading
Loading