Skip to content

SensorEgg: GATT link and pairing review fixes (plans 0017/0018) - #167

Open
TheAngryRaven wants to merge 8 commits into
BETAfrom
claude/project-thread-xnz9wt-egg
Open

TheAngryRaven wants to merge 8 commits into
BETAfrom
claude/project-thread-xnz9wt-egg

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Requested by Dove · project thread

Summary

Before: the 4.2.0 release review found eight problems in the beta-only SensorEgg GATT link and pairing capture:

  • A disconnect that raced the bring-up could wedge the link in STREAMING with no connection.
  • A connection completing after sleep came up during a transfer or charging park.
  • A refused connect() hung until the 10 s timeout.
  • Frames over 32 B were silently dropped while the link looked healthy.
  • Beacon-only fields such as tcFault were held under fresh stream stamps.
  • Battery bypassed descriptor scaling.
  • A failing pairing persist hammered the SD at the beacon rate.

After: each of those is fixed in its own commit, so any single fix can be reverted or cherry-picked.

Commit Fix
be6329e (E1) STREAMING commits only onto the bring-up that was staged; the check and set run inside one critical section. A connected state with no handle falls back to BACKOFF.
3da5e88 (E2) The central connect callback refuses a link that completes after sleep or is no longer wanted. The disconnect callback ignores handles it never adopted.
5372b3f (E3) Beacon-only fields are cleared when the stream commits, and the zombie monitor restarts. Each role (EGT, CJ, aux, battery) has its own freshness stamp, so a silent channel reads NaN instead of a held value.
a5d0f0a (E4) A connect the SoftDevice refuses backs off immediately and resumes the scanner.
6c10853 (E5) Ring slots sized to a full 244 B notify (about 1.9 KB total). Drops are counted, shown on EGG TEST as D<n>, and drops in three consecutive 1 s windows drop the link so the beacon takes over.
4cb8d0c (E6) Battery goes through the descriptor's scale/offset, clamped to 0–100, with the sentinel meaning unknown.
75f8e4b (E7) Pairing-capture persist retries are throttled to about 1 Hz. The first attempt in a window is still immediate.
513af17 (E8) Docs now say the clock fit is anchored but only its boot_id epoch is consumed today.

The link states and their race-sensitive transition rules moved into the host-tested sensoregg_gatt unit. The sketch keeps its EGG_LINK_* names as aliases. Everything stays behind BIRDSEYE_ENABLE_SENSOREGG, and there are flag-off and sim stubs for the new sensoreggFrameDrops().

Judgement calls:

  • Per-role staleness is 2 × the frame period, floored at 1 s and capped at 60 s. EGT and CJ still get exactly 1 s, but the 1 s aux and 30 s battery channels don't flicker to NaN.
  • protoVersion keeps its last beacon value.

Type of change

  • Bug fix (no user-visible behavior change beyond the fix)
  • New feature / behavior
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

How it was verified

  • Host unit tests pass, run after every commit; new tests cover the link-state helpers, role freshness, the drop monitor, battery conversion and the persist throttle.
  • clang-tidy clean (CI)
  • Compiles for the XIAO nRF52840 Sense (CI). Locally, sensoregg.ino was only syntax-checked against a hand-written Bluefruit/FreeRTOS mock, with the flag on and off.
  • Tested on real hardware: not yet. Bench-check race-length streaming, the drop threshold, and the larger ring.

The native sim passes 6/6 with golden hashes unchanged. The sim builds with the flag off, so an unchanged hash also confirms no gating leak.

Checklist

Related issues

Follow-up to the 4.2.0 release review on #160.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw


Generated by Claude Code

The central disconnect callback runs in a higher-priority task than the
main loop. If the egg dropped right after enableNotify(), the callback
could set BACKOFF and invalidate the handle before the loop consumed
eggBringupReady — and the loop then overwrote BACKOFF with STREAMING.
STREAMING only acts when the handle is valid and counts as "engaged", so
the scanner stayed stopped and EGT read NaN until shutdown or a
transfer.

The commit is now conditional (state still BRINGUP and a handle still
held) and is checked and applied inside one critical section; a
BRINGUP/STREAMING state with no handle is reconciled to BACKOFF every
loop as a backstop. Both rules live in sensoregg_gatt (LinkState +
linkMayCommitStreaming / linkReconcileOrphan) with host tests. The
connect callback and SENSOREGG_SLEEP() clear stale ready/failed flags so
a bring-up staged before a sleep can never be consumed against a later
link's buffers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
SENSOREGG_SLEEP() cancels a pending connect and disconnects a known
handle, but there is a window where the SoftDevice has already raised
CONNECTED while the deferred central connect callback has not run: the
handle is still INVALID and connect_cancel() is a no-op. The callback
then ran the full bring-up after sleep, and in BLE transfer mode or the
charging park nothing ever reconciled it — the egg link held the radio
for the whole session.

The connect callback now keeps a connection only when it is the one we
asked for (state CONNECTING), the sleep gate is down and the link is
still wanted (linkAcceptCentralConnect, host-tested); otherwise it
disconnects that handle without adopting it (a CONNECTING state is
retired to BACKOFF instead of waiting out the timeout). That also covers a
connect landing after the 10 s connect timeout already moved to
BACKOFF. The disconnect callback now ignores any handle that is not
eggConnHandle, so a refused connection can't drive the state machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The GATT stream writes into the same Reading the beacon does, but only
into the roles the descriptor maps, while every frame refreshes the
shared eggRxMs. So tcFault, the aux temperature and the battery kept
whatever the last beacon said for as long as frames kept coming: a
stuck *TC FAULT*, and a pod with no IAT channel logging a flat line into
Temp2 — exactly what the never-hold rule forbids. Zombie detection only
watches the fastest channel, so an EGT channel that stopped while CJ
continued also held EGT.

On STREAMING commit the surface is now reset (readingResetForStream:
temps NaN, battery 0xFF, flags/status/tcFault/pairing false, seq 0;
protoVersion kept) and the sequence monitor restarts. Each mapped role
gets its own receive stamp (RoleFreshness) and every value accessor
also requires its role to be fresh while the stream was the last
writer; a beacon parse hands the gate back to the plain 1 s rule.

The per-role window is the channel's own frame cadence plus one frame
of slack (2 x n x interval), floored at kStalenessMs and capped at 60 s:
a flat 1 s rule would flap the EGT pod's 1 s IAT channel and blank its
30 s battery forever, while EGT/CJ keep exactly the 1 s rule. All of it
lives in sensoregg_gatt with host tests. eggRoleLive() takes a uint8_t
because the auto-prototype lands above the sensoregg_gatt include.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The scan callback ignored Bluefruit.Central.connect()'s return. A
refused request (radio busy, bad params) never produces a connect
callback, so the link sat in CONNECTING for the full 10 s timeout, then
5 s of backoff — with the scanner still paused on the report that
triggered the connect, so the beacon path could not fill in. That is
15 s of NaN EGT per refusal.

On false the state now goes straight to BACKOFF (linkAfterConnectRequest,
host-tested), the backoff clock is stamped, and the scanner is resumed
from the callback when the scan is wanted — the same callback-safe
resume the beacon path already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The notify ring had 32-byte slots, enough for the current egg's <=26-byte
frames but not for the spec's 117-sample maximum: any frame over 11
samples was counted in eggFrameDrops and discarded while the link stayed
STREAMING and looked healthy, and nothing ever read the counter.

Slots are now kMaxFrameLen (244 = ATT_MTU 247 - 3, the largest notify
the link can carry at the MTU cap we configure), still 8 deep: ~1.9 KB
instead of 256 B, justified in the comment — the depth rides out a
main-loop stall at the stream's frame rate and the extra RAM is small
next to the track-JSON buffers. The drop counter is exposed
(sensoreggFrameDrops(), no-op twins in the flag-off build and the sim)
and rendered on EGG TEST as D<n>. A new host-tested DropMonitor drops
the link after drops in 3 consecutive 1 s windows so the beacon path
takes over; one SD-stall burst is a single window and never trips it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The battery role routed the raw int16 straight into the percent,
assuming scale 1.0 / offset 0 — true of the EGT pod, but the whole
point of the self-describing descriptor is that a future pod need not
match. A pod declaring deci-percent would have read 870 -> "unknown".

The frame value now goes through sampleToReal() like every other role,
then the new host-tested batteryPercent(): sentinel/NaN -> 0xFF,
otherwise clamped to 0-100 and rounded. NaN is detected with isNanF()
because the device build is -Ofast, where isnan() folds to false and
NaN comparisons are undefined.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Capture is persist-first: a failed setSetting() leaves the pairing
window open so a later frame retries. But "a later frame" was every
matching beacon, ~10 Hz — a full SD read-modify-write of SETTINGS.json
per frame, for up to the 2-minute window, against a card that had just
failed a write. That is the worst possible load for a struggling card
and stalls the main loop on every beacon.

Attempts now go through sensoregg_protocol::pairPersistDue(): the
window's first attempt is immediate, later ones at least
kPairPersistRetryMs (1 s) apart, wrap-safe and host-tested. Requesting
a new window re-arms the immediate first attempt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review flagged clockFitPodToLogger() and ClockFit::halfRttMs as having
no firmware consumer. That is true and intended — the surface is
latest-value-only and DOVEX rows are stamped by the GPS clock — but
nothing said so, so the API read as dead code. The tested API stays
(it is the groundwork for per-sample row timestamping); sensoregg_gatt.h,
sensoregg.h, the sketch's eggClockFit and plan 0018 now say plainly that
only the boot_id epoch check is consumed today and the mapping is
reserved for the resampling follow-up. No sketch state was dead: the
anchored fit carries the epoch the reboot check uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
@TheAngryRaven TheAngryRaven self-assigned this Sep 27, 2026
@github-actions

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 2331/2365 (98.6%)
Functions 🟢 247/248 (99.6%)
Branches 🟢 1692/1874 (90.3%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 213/221 (96.4%) 🟢 21/21 (100.0%) 🟡 119/136 (87.5%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/drag_timer.cpp 🟢 150/154 (97.4%) 🟢 10/10 (100.0%) 🟡 76/94 (80.9%)
BirdsEye/drag_tree.cpp 🟢 112/114 (98.2%) 🟡 7/8 (87.5%) 🟢 87/94 (92.6%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 17/17 (100.0%) 🟢 2/2 (100.0%) 🟢 14/14 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 84/84 (100.0%) 🟢 7/7 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 67/68 (98.5%) 🟢 6/6 (100.0%) 🟢 50/52 (96.2%)
BirdsEye/led_status.cpp 🟢 106/108 (98.1%) 🟢 11/11 (100.0%) 🟢 71/75 (94.7%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
BirdsEye/loop_profile.cpp 🟢 65/65 (100.0%) 🟢 7/7 (100.0%) 🟢 35/36 (97.2%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sd_probe.cpp 🟢 6/6 (100.0%) 🟢 1/1 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/sector_purple.cpp 🟢 84/85 (98.8%) 🟢 3/3 (100.0%) 🟡 57/64 (89.1%)
BirdsEye/sensoregg_gatt.cpp 🟢 154/154 (100.0%) 🟢 24/24 (100.0%) 🟢 99/110 (90.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 90/91 (98.9%) 🟢 14/14 (100.0%) 🟢 76/78 (97.4%)
BirdsEye/setting_parse.cpp 🟢 29/30 (96.7%) 🟢 2/2 (100.0%) 🟢 38/42 (90.5%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 91/91 (100.0%) 🟢 13/13 (100.0%) 🟡 72/82 (87.8%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 23/24 (95.8%) 🟢 3/3 (100.0%) 🟢 27/28 (96.4%)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants