Group logging: log on a whole fleet at once, MetaBase style - #19
Conversation
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>
|
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>
|
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. 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 |
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>
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 SDKdevice.connect()directly — deliberately bypassingAppStore.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