Skip to content

Group logging: log on a whole fleet at once, MetaBase style - #19

Merged
lkasso merged 18 commits into
mainfrom
group-logging
Jul 20, 2026
Merged

Group logging: log on a whole fleet at once, MetaBase style#19
lkasso merged 18 commits into
mainfrom
group-logging

Conversation

@lkasso

@lkasso lkasso commented Jul 19, 2026

Copy link
Copy Markdown
Member

PR 3 of 3 — the feature. Per the scoped plan: one shared config, sequential orchestration, catch-all download.

What you'll see: a new button on the scan screen (badged red while a fleet records) opens Group Logging: check off boards (remembered + nearby + the demo fleet), pick sensors once, Start Logging All — each board gets a quick connect → start → disconnect with a live per-board progress row. Pocket the boards; the group survives app restarts. Come back and Stop & Download All: each board is collected in turn (own records stopped + downloaded; boards with foreign/lost sessions recovered via the anonymous path; out-of-range boards skipped with their data intact), everything saved with board names and one shared groupID, sectioned in Session History.

Architecture: GroupCaptureCoordinator (owned by AppStore, survives navigation) drives SDK device.connect() directly — deliberately bypassing AppStore.connect's navigation/active-slot/orphan-alert entanglement. Busy boards (Live Stream, solo flows) are skipped; an idle active device is borrowed, not stolen. 15 s connect timeout per absent board.

Adversarial review: 13 confirmed findings, all fixed — including two criticals (a disconnect-after-failed-connect that parked an unresumable continuation and wedged the walk; demo boards resolved through the scanner into unusable CoreBluetooth twins). Regression tests pin both.

Validation: 5 end-to-end coordinator tests run the whole flow against the demo fleet (real SDK connects, real logger round-trips, real persistence); 1058 SPM + full app suite green.

Hardware checklist (MMS + MMR): start a 2-board group → both rows go "Logging" · kill + relaunch the app → group restores → Stop & Download All → two named, group-tagged session batches in History with per-board CSVs · repeat with one board in another room (skip + later individual collect) · repeat with one board mid-Live-Stream (skipped as busy).

🤖 Generated with Claude Code

lkasso and others added 2 commits July 19, 2026 16:37
The 10.1 headline, scoped to what users actually need: "log data on
multiple sensors at once." Logging is connectionless — boards record to
their own flash — so the app never holds N live links; it ORCHESTRATES:
sequential connect → act → disconnect per board.

- GroupCaptureCoordinator (owned by AppStore, never a view): start pass
  connects each board, starts the shared config's loggers, stamps
  group-tagged LogSessionRecords, disconnects. Collect pass is the
  catch-all: stop own running records → download; stopped records →
  download; NO local records but data/logging on the board (other
  phone, reinstall) → anonymous foreign download; out of range →
  skipped, data stays on flash for later. 15 s connect timeout per
  board so an absent board costs one wait, not the walk.
- Drives SDK device.connect() directly — AppStore.connect is entangled
  with navigation, the active-device slot, and the orphan alert, none
  of which should fire N times mid-pass. Boards in use elsewhere in
  the app (.streaming, solo flows) are skipped, and an .idle active
  device is borrowed without being disconnected.
- GroupLoggingView (pushed from the scan screen; badge turns red while
  a fleet records): candidate picker (remembered + fresh nearby + demo
  fleet, off-air boards labelled), ONE shared sensor config, Start
  Logging All, per-board phase rows with live download %, active-group
  section that survives force-quits (derived from group-tagged pending
  records), Stop & Download All behind a confirmation. Boards missed
  by the first pass can be added later and join the SAME batch.

Adversarial review: 13 findings confirmed, all fixed. The two critical:
a cleanup disconnect after a "peripheral not found" connect parked a
continuation nothing resumes (wedging the walk with isBusy stuck — now
guarded by device state, with a regression test), and the collect pass
resolving demo boards through the scanner, minting CoreBluetooth twins
that can never connect in the simulator (now routed demo-fleet-first).
Also: honest per-board states (no green check on kept-board-data
warnings; stopped boards show "Ready To Collect", not a pulsing record
dot), live selection (off-air boards can't be silently dropped while
the footer counts them), the shared scan surviving the push, and solo
Stop no longer resurrecting records a group collect already downloaded.

Validation: 5 end-to-end coordinator tests against the demo fleet
(real SDK connects, logger round-trips, persistence — only the radio
is fake), 1058 SPM + full app suite green. Hardware pass on the desk
MMS + MMR is the remaining gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner feedback: a fleet of stock boards is a screen full of identical
"MetaWear" rows — unpickable. Each candidate row now shows:

- the MAC when known (remembered record, or the MAC-broadcast
  advertisement; demo boards render their transport identity's MAC),
  falling back to the iOS peripheral identifier, middle-truncated,
  in monospaced caption type
- the live RSSI as the standard blue pill while the board is on air
  (suppressed when the advertisement goes stale — the scanner's RSSI
  map freezes for off-air boards, and a frozen number lies)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lkasso

lkasso commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Added per owner feedback: board-picker rows now show the MAC when known (remembered record / MAC-broadcast advertisement / demo identity), falling back to the iOS peripheral identifier, plus the live RSSI pill while the board is on air (hidden once the advertisement goes stale, so a frozen number can't mislead). A fleet of stock "MetaWear" boards is now tellable-apart at a glance.

🤖 Generated with Claude Code

…the group confirm

Field test (two-board group collect): both boards came back "Some log
data could not be decoded" with zero recovered samples. The wire log
shows why — the MMS flushes its in-RAM logging page to flash
ASYNCHRONOUSLY, and the group flow moves at machine speed: stop, flush
command, LOG_LENGTH read within milliseconds. One board read the
pre-flush 0 (download short-circuited), the other read a stale
page-aligned 512 while its fresh samples were still in RAM. Every
human-paced solo flow masked the race with navigation delays between
Stop and Download, which is why this never showed before.

downloadLogs now waits out the flush: 300 ms head start, then polls
LOG_LENGTH until two consecutive reads agree (bounded, ~2 s worst
case). No-op on boards without the flush (revision < 3). The demo
fleet reports MMS revision, so the E2E group tests exercise the settle
path (their timing grew by exactly two settles).

Also: the Stop & Download All confirm becomes a centered alert per the
app-wide convention — confirmation dialogs anchor as popovers in
regular width and pop up wherever their anchor sits (owner feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lkasso

lkasso commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Two updates from the first two-board field test:

The empty-download bug is diagnosed and fixed. The MMS flushes its in-RAM logging page to flash asynchronously — and the group flow runs stop → flush → LOG_LENGTH at machine speed, so one board read the pre-flush 0 and the other a stale page count while both boards' fresh samples were still in RAM. Solo flows never hit this because human navigation between Stop and Download gave the flash time to land. downloadLogs now gives the flush a head start and polls LOG_LENGTH until two consecutive reads agree (~0.5 s typical, bounded at ~2 s).

Retest note: your tryout data is still on both boards (the failed downloads kept it, and the pending records survive) — pull, rebuild, and just tap Stop & Download All again; no need to re-log.

Also per feedback: the Stop & Download All confirm is now a centered alert like every other confirm in the app, instead of an anchor-positioned dialog.

🤖 Generated with Claude Code

lkasso and others added 15 commits July 19, 2026 17:54
Owner feedback: the device names in the group picker showed orange.
The rows are Form buttons, and a button tints its label — hierarchical
.primary resolves against that tint (brand orange), not label black.
Plain button style restores real label colors; the explicit styles
(accent checkmark, warning captions, blue RSSI pill) are unaffected,
and the row keeps a full-width contentShape so taps don't dead-zone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…be true

Second field test: the settle poll ran (two LOG_LENGTH reads visible on
the wire) and both reads agreed on ZERO on both boards — so this is not
a read race. The flush command isn't landing the RAM page in flash in
under a second, or isn't honoured at all in the post-stop state we send
it in. Wire-level audit against ground truth (C++ SDK logging.cpp,
Combine SDK Download.swift, the register spec): our bytes are identical
— [0x0B, 0x10, 0x01] after logging-stop — and the spec gives the flush
no ack and no timing contract. The canonical apps were always driven at
human pace between Stop and Download, so a slow or state-sensitive
flush would never have shown there either; short sub-page sessions may
never have downloaded reliably on MMS anywhere.

Three changes, all belt-and-braces safe:

- stopLogging (both overloads) flushes BEFORE disabling the logging
  module — a write-cache flush is only known to be honoured with the
  module live. downloadLogs still flushes after, as before.
- settledLogLength becomes expectation-aware: when the caller KNOWS the
  board logged (records exist / it reported itself logging), a stable
  zero is treated as "flush hasn't landed", not "board is empty" — it
  keeps polling up to ~8 s, re-issuing the (ack-less) flush every few
  attempts. Without the expectation, the old quick settle applies.
- Every settle read is now mwLog'd, so the next field log shows the
  exact LENGTH curve over time — if the board still reports zero after
  flush-before-stop + 8 s of polling, that's a firmware answer, not a
  timing one.

DownloadViewModel passes the expectation from both paths (pending
records; foreign entryCount/active). 1058 SPM + full app suite pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner request. On group start, each successfully started board gets a
red LED heartbeat (100 ms pulse every 5 s — slow and dim-duty so a
multi-hour log doesn't pay for it): LED playback runs on the board, so
the heartbeat survives the disconnect and a glance at the fleet shows
which pucks are recording. The collect pass clears the LED the moment
it takes a board, before stop/download, so the state can't outlive the
session even on a failed download. Both are best-effort — an LED
hiccup never fails a pass. Group flow only; solo logging untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner requests. A failed download keeps the board's data, so the Last
Run section now offers Retry Failed Downloads right where the failure
shows — it re-collects just the failed boards (device handles resolve
demo-fleet-first). Retry only appears after a COLLECT pass: a failed
START is re-attempted by simply selecting the boards again.

Add To Group is removed: while a group is live the screen shows only
the group's state and its collect button; the picker returns once the
group is collected. (The capability lives in git history if it earns
its way back.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third field test told the whole story. Run 1: boards still carrying
stale trigger slots from earlier abandoned sessions read LOG_LENGTH 0
FOREVER — through flush-before-stop, the 8 s settle, and three flush
re-issues. The owner then cleared both boards from Settings, re-ran,
and run 2 WORKED: one board read a stable 7232 entries, streamed 15
pages, decoded both sensors, and cleared; the other read 3072 and
streamed 6 pages. Stale on-board state — not timing — is what wedges
the MMS flush/LENGTH path.

- startAll therefore clears every board before starting its loggers.
  Boards with local pending sessions are already skipped above, so
  anything reaching the clear has no local claim; unclaimed foreign
  data is wiped by design — the user just chose to record fresh.
- The second board's residual issue was a DECODE gap: the drain
  succeeded but one of two records decoded empty, which rendered as a
  hard failure and offered Retry — and the retry honestly read 0,
  because the readout had already consumed (page-confirm-nulled) the
  entries. New phase .savedWithIssues(count, warning): partial saves
  render as a warning-tinged success, and Retry is only offered for
  true failures (connect/drain errors) where it can actually work.
- The keptBoardData warning no longer promises a retry the drain made
  impossible.

The one-record decode gap on the BMI270 board is still open — needs
the Session History contents from run 2 to identify which sensor's
record came up empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The third field test left an ambiguity the wire log can't resolve: one
board's collect took the kept-data path (no clearLog on the wire), yet
the owner reports both sensors present in Session History — which may
include sessions from older runs. Rather than diagnosing by timestamp
archaeology, the download engine now narrates itself: the drained
entry count with its per-logger-id distribution, each record's outcome
(saved N samples / decoded EMPTY / threw), and each foreign signal's
sample count. Mirrors the SDK's mwLog (stderr, DEBUG-only) so the
lines land in the same console as the wire log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fourth field test falsified the naive clean-slate fix in the most
useful way: the auto-clear IS the new failure. The wire shows why —
clearLog fires Drop Entries and returns immediately, and the group
start arms new loggers milliseconds later. On MMS NAND the drop kicks
off page garbage collection that grinds for seconds; a session started
under it records NOTHING (LENGTH honestly 0, drained 0 entries — the
new [Download] diagnostics confirmed no logger produced a single
entry). The owner's manual clear worked purely because minutes of
human pacing passed before the next start.

The firmware documents the handshake we were ignoring: the spec's
register table says READOUT_PAGE_COMPLETED (0x0D) is "sent … after the
Drop Entries command completes". clearLog now enables that notify,
drops, and WAITS for the completion (MMS-revision boards only; bounded
at 30 s with a logged best-effort fallback so a silent board can't
wedge callers). The demo transport mirrors the notification, so the
E2E group tests exercise the handshake.

Also from this run: the LED heartbeat's pattern + play bytes are
byte-for-byte correct on the wire — the board simply stops LED
playback on disconnect. Surviving that needs the on-board
disconnect-event mechanism; parked as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…marter

Fifth field test, mixed fleet (MMS + MMRL, shared accel+gyro config),
with the new decode diagnostics finally naming names:

- MMS: drained [id 0: 2361, id 1: 2361] — accel perfect, gyro loggers
  produced ZERO entries on the board. Cause: the app derived the gyro
  variant from the ACCELEROMETER's chip generation, arming the gyro
  trigger on the wrong data register for that board's gyro. The C++
  SDK selects every sensor by its own module's implementation byte
  (module_info.at(MODULE).implementation) — now we do too: new
  SensorImplementations(modules:) reads accel/gyro impl bytes straight
  from discovery (values match MBL_MW_MODULE_ACC_TYPE_* and
  GYRO_TYPE_* exactly), threaded through start, stop, rollback,
  recovery, and decode in both view models.
- MMRL: its Drop Entries never signalled completion and outlived the
  30 s bound (dirty NAND, long GC); loggers armed onto the grinding
  board — only one of four ever wrote. clearLog now waits on TWO
  independent completion signals, first one wins: the documented 0x0D
  notification OR LOG_LENGTH reading zero on consecutive polls, with
  the bound raised to 60 s.
- startLogging now logs the firmware-assigned logger ids and
  recoverLoggers the matched ids, so the next field log shows the
  id chain end to end.

Still open: LED heartbeat dies with the link (needs the on-board
disconnect-event mechanism); one anomalous "Packet too short for
CartesianFloat: 6 bytes" decode error seen only in the crippled
single-logger board state the fixes above prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… out

Sixth field test: the pipeline END-TO-END worked for accel on both
boards (3397 + 3831 samples saved, group-tagged, clear handshake
"drop completed", all four logger ids assigned) — and exposed two UX
truths plus kept the one hardware mystery alive:

- "Where are the files?" The group screen never said. Saved sessions
  live in Session History; the Last Run section now links straight to
  it whenever any board saved.
- Boards kept showing "logging" in the scan list after a complete
  collect: records that decoded EMPTY were kept as pending "for
  retry" — but a COMPLETED drain has page-confirmed (nulled) every
  entry, so those records can never be fulfilled. They now close as
  .failed, releasing the pending badges, the eternal "Ready To
  Collect", and the Logging screen.
- Start passes log the per-module implementation bytes so the next
  run pins the remaining mystery: gyro loggers armed with valid ids
  (2,3) on the correct data registers wrote ZERO entries on both
  boards, while byte-identical sequences logged gyro fine in an
  earlier run. Accel is untouched by whatever this is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner report: Clear Logs & Loggers didn't refresh the counts to zero.
The action DID re-read the board — but an MMS reports a sentinel
LOG_LENGTH for up to ~60 s post-clear while GC settles (the same quirk
the orphan detection has documented for ages), so the refresh
faithfully displayed stale numbers. clearLog now waits for the
firmware's drop-completion handshake, so at that moment zero is the
ground truth — display it directly and let the next natural screen
visit re-read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Old field bug: tapping X on the device screen (compact width) landed
on an empty page with only a Back button instead of the scan list. The
disconnect flow was correct — activeDeviceID nils and the onChange
fires — but it reset the navigation path AND flipped the compact
column in the SAME transaction, and NavigationSplitView swallows the
column change while processing the path reset + detail-content swap.

Two-part fix: the sidebar flip now lands on its own main-actor tick,
and the blank pane carries an onAppear safety net that pushes focus
back to the sidebar if it ever becomes the visible compact column
(no-op in regular width, where preferredCompactColumn has no effect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seventh field test unmasked the last accomplice: the LENGTH==0-twice
clear-completion proxy. A board whose previous session was fully
drained READS zero instantly — but its page-confirm-nulled flash still
needs garbage collection, which the very drop we issue kicks off. The
proxy declared the clear complete in two fast polls, the loggers armed
onto the grinding flash, and BOTH boards logged zero entries — accel
only, no gyro involved. All seven field sessions now reduce to one
rule: a session started during NAND GC records nothing, and no
firmware signal reliably marks GC's end.

So the start pass stops trusting proxies and verifies the only signal
that matters: the entry count RISING. While logging is enabled,
LOG_LENGTH includes the RAM page, so a healthy board confirms on the
first 2 s poll; a GC-bound board is simply waited out (90 s bound,
"Confirming data is recording…" in its row). If a board never
confirms, its doomed session is torn down — sensors stopped, records
deleted, no zombies — and the row says plainly to wait a minute and
try again, instead of pretending a dead session succeeded.

E2E group tests exercise the gate against the demo fleet (their
timings grew by exactly the verification polls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eighth field test: COMPLETE END-TO-END SUCCESS on the mixed fleet —
both boards, both sensors, balanced per-logger entry counts, sessions
saved and attributed, boards cleaned after collect. The verification
gate confirmed entries landing before each board was released, closing
the "gyro logs nothing" mystery as one more face of the GC law.

The one remaining break was navigational: "View Saved Sessions" on
the group screen silently did nothing. Cause: GroupLoggingView was
presented via a navigationDestination(isPresented:) binding — a
screen the NavigationPath doesn't contain — and SwiftUI drops value
links tapped inside such a screen. The console also flagged duplicate
MWSessionSnapshot destinations (SessionHistoryView declared its own,
so two history instances in one stack collided).

- Group Logging is now DeviceFeaturePane.groupLogging, pushed by
  value like every other pane; the toolbar Button becomes a
  NavigationLink. Value links inside it resolve normally.
- The session-detail destination is declared ONCE at each stack root
  (sidebar + detail) instead of inside SessionHistoryView.
- The scan handoff no longer needs ScanView to know about the group
  screen: ScanView stops the shared scan unconditionally on
  disappear, and GroupLoggingView re-asserts it after the push
  transition settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner request: "MetaWear · 0648AF" → "MetaWear · <MAC>". The MAC is
the board identity users see everywhere else in the app (device page,
group picker, rename gate), while the DIS serial appears nowhere else.
Sessions store the serial, remembered-device records sync both fields —
so the history screen translates serial → MAC through Remembered.
Grouping still KEYS on the serial (stamped on every record; MAC
knowledge is not guaranteed), and untranslatable boards keep the
serial fallback. Unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lkasso
lkasso merged commit ecd9b59 into main Jul 20, 2026
5 checks passed
@lkasso
lkasso deleted the group-logging branch July 20, 2026 03:55
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.

1 participant