WHOOP MG Heart Screener: ECG capture, store, history and coach reading - #367
WHOOP MG Heart Screener: ECG capture, store, history and coach reading#367DropTabl wants to merge 11 commits into
Conversation
Three additive tables behind a CREATE-IF-NOT-EXISTS rung: ecg_reading (the band-reported result of one user-initiated Labrador reading), ecg_reading_packet (the accepted R17 window, exact signed-i16-LE samples as a BLOB plus the inner hex, one placeholder row per sequence jump) and ecg_raw_packet (the raw R16 records the band saved and ordinary history recovered, byte-exact, keyed by bytes). No foreign keys — this DB never enables them — so the reading→packet cascade is a manual two-statement transaction. Units pinned: domain/strap timestamps in seconds, created_at/captured_at in ms like raw_archive. Raw R16 rides commitSyncBatch inside the same transaction as decoded rows, archives and the trim cursor, so nothing the band trims on ACK lacks a durable home. Ownership: the three tables join the salvage order and the backup-restore list (lifted to _restoreTables so a test can pin it); wipeAll enumerates sqlite_master and needs nothing. Coach: v_ecg_readings is a summary-only view over ecg_reading alone — the structural btree gate admits every base table a view reads, so the packet tables are never joined; ecg_reading/ecg_reading_packet/ecg_raw_packet join reservedTableNames so device_id/notes stay out of run_sql. Tests: fresh schema + view, v50→v51 ladder + idempotent reopen, atomic insert and rollback, signed BLOB / placeholder round trip, manual cascade, R16 in the safe-trim commit (idempotent on bytes), ownership lists + wipe, and the coach guard at both layers.
…EADY recovery The engine owns the transport half of an ECG reading: - EcgLease: exclusive ownership of the command transport for one link, bound to the session and link generation, acquired synchronously, validated on every ECG write and on release, voided at teardown (with an EcgLinkDownEvent naming the old generation). History refresh/backfill are refused while a valid lease is held; the heartbeat's LINK_VALID and the keep-alive's command writes pause under it (the liveness fuse keeps running). - ecgPrepare/ecgStart/ecgRestart/ecgCleanup: the exact docs/mg/05 lists through the protocol builders (cmdSelectWrist, cmdLabradorFiltered / RawSave / DataGeneration, cmdAbortHistorical with the gen5 profile). Attempt-all, one correlated await each — observer before write, seq+opcode match, the common 5 s timeout, no retry. _sendAwaited gains an owner so a stale continuation cannot write onto a replacement link. - ecgCancelHistory: ends an active history task through the one abort boundary (new terminal kind 'preempted') and waits for lifecycle quiescence. START's own opcode 20 stays unconditional. - Live type-43 revision-17 frames are parsed and delivered synchronously as EcgFrameEvent; a frame that claims R17 but does not parse is EcgMalformedR17Event. RAM only. - Historical type-47 revision-16 raw ECG is buffered on the DrainController as a burst count member and rides the safe-trim commit into ecg_raw_packet. Every durable-progress predicate knows about it: HISTORY_END, the HISTORY_START mid-burst discard, aborted-task leftovers, the commit early-return, snapshot/restore/discard; the headless drainer's commit closure forwards it too. - READY recovery: onReadyEcgRecovery runs after bootstrap and BEFORE listening is published or INIT claims history, under a recovery lease, so a retained may-be-active guard can send the cleanup triplet first. Tests: identity, lease, exact bytes/order per list, correlation and timeout, attempt-all after FAILURE and after a failed write, stale lease, history preemption and refusal, R17 parsed/malformed/other-revision/gen4, R16 buffering + commit + restore + discard, link-down event, opcode-safety pin, READY order via the real official connect (cleanup before GET_DATA_RANGE, READY invisible during the hook).
…ry, wiring lib/ecg/ is the phone-side half of a Labrador reading, behind an abstract EcgTransport (BleEngineEcgTransport forwards to the engine): - ecg_policy.dart: the official foreground R17 state machine as a PURE reducer (docs/mg/05 §4 / 06 §6) — WAITING ignores pre-contact packets; ACTIVE appends while the current-S2-state-1 flag is set, clears and counts one interruption per contact-loss transition, fails on progress 255, routes the exact explicit-RESTART predicate (presence, positive nondecreasing nonterminal progress, S2 flag clear) to a restart effect; CONTACT_LOST recovers without a command and fails on 255 or a bad packet with three interruptions banked; one placeholder per sequence jump; the terminal maps result+live HR for the branch and result+average HR for the persisted category; repeated terminals are ignored; first-attempt inconclusive offers exactly one retry. - ecg_controller.dart: lease → retained-guard recovery → history cancel → acknowledged guard write → PREPARE → subscribe → START (frames at the write boundary count) → reducer → durable save BEFORE completed → ONE cleanup on every exit (success, cancel, pause, timeout, malformed R17, link loss, save failure, partial command failure) → guard cleared only when all three cleanup members succeeded → post-reading history sync. Single-flight epoch, frames gated on link generation and dropped while a restart is in flight, screen hold released only when held. - ecg_guard_store.dart: per-serial guard / wrist / remembered-MG flag in SharedPreferences through the awaited API; ecg_recovery.dart: the controller-free retained-guard cleanup used at READY by both the foreground engine and the headless drainer. - ecg_models.dart: the band-result category table, typed rows and the signed-i16 BLOB codec; ecg_waveform_buffer.dart: the bounded live-preview ring plus a per-tick repaint coalescer. - ScreenWake gains owners (hold/releaseOwner); the workout call sites move to their own owner so neither feature can drop the other's hold. - AppState hosts the controller, folds capturing into _hasLiveConsumer, stops a reading on pauseForBackground before the live downgrade, remembers a MAVERICK hello per serial (pairedIsMaverick), and wires the READY recovery hook; runHeadlessSync wires the same recovery. Tests: every reducer rule plus the 86→30→3,000 official shape; the category table at every boundary; row codecs and stats; buffer/scheduler; prefs store; recovery outcomes; the controller over a scripted fake transport (order, guard-before-write, START-boundary frame, failures, retained guard, exits, restart single-flight, retry, save failure, single flight); screen-wake owners.
… now Health → Heart Screener appears only when the paired band has positively identified itself as a WHOOP MG (remembered per serial, so saved readings stay reachable while the band is away); Take ECG additionally needs that MG connected and READY. The wrist choice is remembered per band. Capture screen: the pipeline is armed while the instructions and an original CustomPainter illustration (band on the chosen wrist, both electrode indents, the opposite thumb and index finger, pulsing contact rings, phase owned by the screen — still under reduced motion) are shown; the first valid contact packet moves it to measurement automatically. Band progress, live HR, "Adjust your fingers and keep still" on contact loss, and a "Live signal preview" of the real samples over a bounded ring, repainted at most every Motion.ecgPreviewTick and never per packet. Back and close cancel through the controller; the app's pause path stops a reading before the live downgrade. Every terminal state has its own copy; the result is always labelled band-reported and never a diagnosis. Detail: the complete accepted waveform with visible breaks at placeholder seconds, horizontal scroll and stepped zoom, the band's category and stats, delete, and Analyze now — which routes through Coach setup when unconfigured, shows an explicit disclosure (host + model) before a remote endpoint receives anything, then opens a NEW coach conversation and sends the fixed prompt visibly as the user's turn. Coach: v_ecg_readings on the allow-list (summary only, see the db commit); get_ecg_reading(reading_id) — a bound read returning the band summary and a ≤300-bucket min/max envelope, never raw frames, serial, device id, notes or all samples; the system prompt gains the ECG law (band's HeartKey category, unproven polarity, no diagnosis from the waveform, no confirm/rule-out, clinical/emergency guidance, one Not-medical-advice line) and the view/tool docs. CoachScreen takes initialMessage + startNewSession for the Analyze-now hand-off. Strings in all six locales. ecg_widgets.dart joins the ui2 barrel; the four components are in the gallery, the routes on the screens list. Tests: every capture phase, reduced-motion illustration, preview repaint only on the scheduler tick, waveform width/range and placeholder handling, detail screen with and without a waveform, zoom, the wrist sheet, the Analyze prompt; the coach tool's bound read, envelope determinism and leak checks, and the prompt pins.
The v49→v50 migration test asserted schemaVersion == 50 literally, so every later additive rung (v51 is the WHOOP MG ECG store) would fail it for no reason of its own. The v50 rung is still isolated; the ladder's top is a floor here.
`_analyze` gated on `coachReady`, which reads the config with `watch`. `watch` is only legal during build; from a tap handler provider asserts, and coachReady's own catch turned that assert into a plain "not configured". A fully configured coach therefore sent the user to the setup form every time, and the check after the form returned false again and bailed out — the reading was never analysed. It is an assert, so it bit debug builds only, which is why it survived to hardware. Add `coachReadyNow`, the same predicate over `read`, and use it in the handler. The other watch-based helpers are all called from build. The regression test drives the tap through a NavigatorObserver so it asserts on the pushed route without building the coach screen itself.
get_ecg_reading sent a 300-bucket min/max envelope. At 100 Hz that is 100 ms per bucket and a QRS complex is 80-100 ms wide, so every beat collapsed into one amplitude bar: the model could describe the envelope but never the trace. Send the accepted window itself instead, at the band's own rate. A completed reading is 30 s = 3,000 samples and goes whole; nulls still hold a missing segment's second open. Only a longer window is decimated, by a whole-number stride reported alongside the effective rate, so the result is never clipped mid-number into JSON the model cannot parse. Add `how_to_read` beside it. Without the provenance the model reads the data wrongly in specific ways: it inverts the band's avg_hr into an "RR interval" and presents it as measured, and it judges QRS width as if the trace were a 500 Hz diagnostic ECG. The block states the 500 Hz raw to 100 Hz filtered path, the 10 ms sample resolution and what it does and does not support, that avg_hr is the band's and not measured here, and what quality, interruptions and missing_segments mean. get_ecg_reading also gets its own result ceiling. The shared bound exists because the model widens its own run_sql queries until one result dominates the window; a bound lookup of one reading by id is not that shape, and clipping it would only decimate a waveform to make room for the prose describing it.
Law 7 forbade the coach interpreting the waveform at all: it could explain the band's category, the signal quality, the heart rate and the unreadable reasons, and nothing else. With the trace now in the tool result that left the model holding 3,000 samples it was instructed to say nothing about, and the analysis read as generic no matter how good the data was. Owner's decision, made explicitly: allow the interpretation. The model may now read rate, rhythm and its regularity, beat-to-beat variation, intervals and morphology, give its own impression, and say plainly when its reading and the band's category disagree instead of deferring. What stays is everything that is a fact about the data rather than a restriction on reading it: the anatomical lead polarity is still not proven, so axis and R/S direction remain off limits; the category is still the band's own HeartKey result; the emergency-symptom guidance is untouched; and the standing caveat is now explicit that this is not a cleared diagnostic device and no substitute for a clinician. The Analyze-now message and the tool description asked for the old behaviour in their own words and would otherwise have contradicted the law they sit under.
# Conflicts: # lib/coach/coach_db.dart # lib/data/db.dart # lib/l10n/app_en.arb # lib/state/app_state.dart # lib/sync/background_sync.dart # test/db_alarm_schedule_migration_test.dart
The remote-endpoint dialog promised "a bounded waveform envelope". Since the tool result became the accepted window itself that sentence is wrong in the direction that matters: the user was consenting to a summary and sending a complete 30-second recording. Say what is sent — the summary and every sample the band recorded, 100 per second — in all six locales and in the code fallback. Nothing about the payload changes; only the sentence that asks permission for it. A local endpoint still sends nothing off the device, and the dialog is still shown only for a remote one.
The old drawing did not teach the instruction under it. The forearm was a plain slab, the band a narrow rectangle across it, and the two fingers were detached angled bars floating to one side with nothing joining them to a hand — a user could not tell what to do from the picture. Redraw it as a tapered forearm wearing the band, with the opposite hand reaching across and pinching both electrode indents: thumb on the upper one, index on the lower, enough of the hand visible to read as a hand. The two contact points carry the emphasis and their halos settle once contact is reported. Same painter, same seams: unchanged public API, the left-wrist mirror, theme colours only, no assets, and legible at any frozen `t` for reduced motion. shouldRepaint now compares every input it draws from.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Reviewer's GuideThis PR implements a complete WHOOP MG Heart Screener: it identifies compatible hardware, serializes ECG capture against BLE history traffic, drives the Labrador protocol with crash-safe recovery and cleanup, reduces and persists accepted ECG data plus raw historical packets atomically, exposes history/detail UI and a bounded privacy-aware coach analysis path, and covers the behavior with extensive tests; CI remains blocked until the protocol parser SHA is repinned after upstream protocol#54 merges. Sequence diagram for WHOOP MG ECG capture and persistencesequenceDiagram
actor User
participant UI as EcgCaptureScreen
participant Controller as EcgController
participant BLE as BleEngineEcgTransport
participant Band as WHOOPMG
participant DB as LocalDb
User->>UI: begin(wrist)
UI->>Controller: begin(wrist)
Controller->>BLE: acquire()
Controller->>BLE: cancelHistory(lease)
Controller->>BLE: prepare(lease, wrist)
BLE->>Band: PREPARE command list
Band-->>BLE: SUCCESS responses
Controller->>BLE: start(lease)
BLE->>Band: abortHistorical + generationStart
Band-->>BLE: SUCCESS responses
Band-->>BLE: EcgTransportFrame(R17)
BLE-->>Controller: EcgTransportFrame
Controller->>Controller: reduceEcg(frame)
Controller->>BLE: restart(lease)
BLE->>Band: abortHistorical + generationRestart
Band-->>BLE: SUCCESS responses
Band-->>Controller: terminal R17
Controller->>DB: insertEcgReading(reading, packets)
Controller->>BLE: cleanup(lease)
BLE->>Band: generationStop + filteredOff + rawSaveOff
Band-->>BLE: SUCCESS responses
Controller->>BLE: requestSync()
BLE->>Band: historical sync
Band-->>BLE: raw R16 packets
BLE->>DB: commitSyncBatch(ecgRawPackets)
UI-->>User: Show completed reading
Entity relationship diagram for ECG storageerDiagram
ECG_READING ||--o{ ECG_READING_PACKET : contains
ECG_READING {
string id PK
int start_ts
int end_ts
string category
string status
int sample_count
}
ECG_READING_PACKET {
string reading_id PK
int ordinal PK
int sequence
blob samples
boolean is_placeholder
}
ECG_RAW_PACKET {
string hex PK
int sequence
int strap_seconds
string reading_id
}
State diagram for the ECG reducer and capture lifecyclestateDiagram-v2
[*] --> WAITING
WAITING --> ACTIVE: acceptable R17 packet
ACTIVE --> ACTIVE: accepted packet
ACTIVE --> CONTACT_LOST: contact loss or progress regression
CONTACT_LOST --> ACTIVE: contact recovered
ACTIVE --> ACTIVE: explicit restart condition / EcgSendRestart
ACTIVE --> done: terminal packet
CONTACT_LOST --> done: third interruption or progress_255
ACTIVE --> done: progress_255
done --> [*]
state done <<choice>>
done --> SAVE: completed or final inconclusive
done --> CLEANUP: unreadable or failure
SAVE --> CLEANUP: insertEcgReading succeeds
CLEANUP --> [*]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
WHOOP MG "Heart Screener": a user-initiated ECG reading end to end — capture,
storage, history, detail, and an optional coach reading of the trace.
What lands
db (schema v52) —
ecg_reading,ecg_reading_packet(exact acceptedpackets, samples as signed-i16-LE BLOB) and
ecg_raw_packet(byte-exacthistorical R16). Its own rung,
CREATE TABLE IF NOT EXISTSand nothing else,so a throw has nothing to roll back onto. Raw R16 is committed inside the same
pre-ACK transaction as everything else — it has no other durable home. A coach
view
v_ecg_readingsexposes the summary only: neverdevice_id,notes, hexor samples.
ble — an
EcgLease(session + link generation) serializes the wholecapture against history: refresh/backfill are refused and maintenance writes
pause while it is held, and teardown voids it. PREPARE / START / RESTART /
CLEANUP go through the protocol builders with seq+opcode correlation, a 5 s
timeout, attempt-all and no retry. A READY-time recovery hook cleans up a band
left generating by a dead process BEFORE listening resumes.
owner — a pure reducer (
reduceEcg) implements the official state machine:WAITING/ACTIVE/CONTACT_LOST/done, one interruption per ACTIVE→CONTACT_LOST
transition, failure on progress 255 or a third interruption, one placeholder
per sequence jump, and the distinct explicit-RESTART branch. The controller
owns single-flight, the 120 s timeout, save-before-completed, and exactly one
cleanup on every exit.
ui + coach — the Health entry appears once a band has positively identified
itself as an MG (revision-1 MAVERICK hello), capture screen with live preview,
detail with the full waveform, and "Analyze now".
Hardware
Run on a physical MG. PREPARE (123/139/125),
abortHistorical(20) and START(124) all returned SUCCESS in order; live R17 parsed at 100 samples/s; the
CLEANUP triplet fired exactly once on exit and was followed by the ordinary
sync. Contact handling was exercised for real: the band resets progress and
recovers on its own, and the reducer's interruption budget ended the capture at
the third reset exactly as the contract specifies.
Notes for review
maintook 51 for multi-device attribution while thisbranch was out; the ECG store moved up a rung rather than share one with a
different migration. The rung comment records that.
BandHost.commitNativeBatchnow carriesecgRawPacketsthrough tocommitSyncBatch— without it the raw records have no route to the DBthrough main's commit path.
how_to_readblock stating the 500 Hz → 100 Hz decimation and the 10 msresolution, so the model does not read intervals off
avg_hror judge QRSwidth as if this were a diagnostic ECG. A window too long for one result is
decimated by a whole-number stride rather than clipped mid-number.
that the summary and the full waveform are what leave the device. A local
endpoint sends nothing off the phone.
Verification
flutter analyze lib testclean. Full suite 3,799 passing / 462 skipped, withone expected red:
the sibling pins match the SHAs, which fails only under thelocal
pubspec_overrides.yamland clears at the repin.Summary by Sourcery
Deliver the WHOOP MG Heart Screener from ECG capture through durable storage, history, waveform review, and optional coach analysis.
New Features:
Bug Fixes:
Enhancements:
Tests: