diff --git a/lib/ble/adapters/garmin.dart b/lib/ble/adapters/garmin.dart index a85455697..acd346de7 100644 --- a/lib/ble/adapters/garmin.dart +++ b/lib/ble/adapters/garmin.dart @@ -157,8 +157,9 @@ class GarminAdapter extends BandAdapter { } final handle = gfdiHandle; if (decoded is GarminMlrData && handle != null && decoded.handle == handle) { - // Byte 0 is the routing byte; the COBS/GFDI stream starts after it. - for (final frame in cobs.feed(decoded.payload.sublist(1))) { + // protocol's garminDecodeMlr already strips the routing byte + // (protocol#70) — decoded.payload IS the COBS/GFDI stream. + for (final frame in cobs.feed(decoded.payload)) { archived.add(frame); final gfdi = garminParseGfdiFrame(frame); if (gfdi != null) unawaited(ackAndDispatch(gfdi)); diff --git a/lib/ble/adapters/host.dart b/lib/ble/adapters/host.dart index 0183dd266..b6ec260ad 100644 --- a/lib/ble/adapters/host.dart +++ b/lib/ble/adapters/host.dart @@ -367,6 +367,7 @@ class BandHost { List samples, String? trimTokenHex, { List? archives, + List? ecgRawPackets, String? deviceFamily, }) async { try { @@ -375,6 +376,7 @@ class BandHost { samples, trimToken: trimTokenHex, archives: archives, + ecgRawPackets: ecgRawPackets, deviceFamily: deviceFamily, deviceId: deviceId, onCheckpoint: onLog, diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 339991961..bd49d2cca 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -79,6 +79,7 @@ typedef CommitSyncBatchSink = List samples, String? trimTokenHex, { List? archives, + List? ecgRawPackets, String? deviceFamily, }); @@ -88,6 +89,84 @@ typedef CommitSyncBatchSink = /// safe-trim invariant holds (see [CommitSyncBatchSink]). typedef ArchiveSink = Future Function(ArchiveRecord archive); +// ── WHOOP MG ECG (Labrador) ───────────────────────────────────────────────── +// The engine owns the transport half of an ECG reading: the exact command +// lists, response correlation, history quiescence, the parsed live-R17 +// delivery and the link-generation guards. The reducer, the durable guard, +// persistence and the UI live in lib/ecg/ behind `EcgTransport`. + +/// What the engine tells the ECG owner. Every event carries the link +/// generation it belongs to, so a controller can drop anything from a link +/// it did not start on. +sealed class EcgEngineEvent { + final int linkGeneration; + const EcgEngineEvent(this.linkGeneration); +} + +/// A CRC-valid live type-43 revision-17 packet, parsed. +class EcgFrameEvent extends EcgEngineEvent { + final LabradorR17 r17; + const EcgFrameEvent(this.r17, super.linkGeneration); +} + +/// A CRC-valid type-43 frame that CLAIMS revision 17 but does not parse +/// (declared sample count past the packet, count above 100, …). During an +/// armed capture this is a parse failure the owner must finish on. +class EcgMalformedR17Event extends EcgEngineEvent { + final String reason; + const EcgMalformedR17Event(super.linkGeneration, this.reason); +} + +/// The link the generation belonged to is gone (teardown ran). +class EcgLinkDownEvent extends EcgEngineEvent { + const EcgLinkDownEvent(super.linkGeneration); +} + +typedef EcgEventSink = void Function(EcgEngineEvent event); + +/// Runs once per connection AFTER bootstrap and BEFORE `listening` is +/// published or the INIT drain claims history — the ECG recovery seam. The +/// engine holds a recovery [EcgLease] for the duration, so the hook may call +/// [BleEngine.ecgRecoveryCleanup]; nothing else can claim the transport and +/// no history task can start until it returns. +typedef EcgReadyHook = Future Function(BleEngine engine); + +/// Exclusive ECG ownership of the command transport for ONE link. Issued by +/// [BleEngine.ecgAcquire] (or held internally during READY recovery), bound +/// to the session and link generation it was issued under; every ECG command +/// validates it, and a stale lease (link replaced) is refused everywhere. +class EcgLease { + final Object _owner; // the _Session this lease was issued for + final int linkGeneration; + final bool recovery; + const EcgLease._(this._owner, this.linkGeneration, {this.recovery = false}); +} + +/// One member of a Labrador command list: whether the write left the phone +/// and whether a matching SUCCESS response came back within the timeout. +class EcgCommandOutcome { + final String label; + final int opcode; + final bool written; + final bool succeeded; + const EcgCommandOutcome( + this.label, + this.opcode, { + required this.written, + required this.succeeded, + }); + + @override + String toString() => + '$label(0x${opcode.toRadixString(16)}) written=$written ok=$succeeded'; +} + +typedef _EcgMember = ( + String label, + int opcode, + Uint8List Function(int seq, BandProfile band) build, +); + /// Fired (debounced) after records are persisted so the caller can schedule a /// DerivationEngine pass. Replaces the old "runSync() → SyncReport → derive" /// trigger now that listening is continuous and there's no discrete sync end. @@ -290,8 +369,11 @@ bool isBurstCountMemberType(int packetType) => packetType == PacketType.relativeBatteryPackConsoleLogs; @visibleForTesting -bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => - offloadActive; +bool shouldPauseMaintenanceTraffic({ + required bool offloadActive, + bool ecgLeased = false, +}) => + offloadActive || ecgLeased; /// Whether a HISTORY_END burst's packet accounting matches what the band /// reported sending (`expectedPacketCount`, from the metadata frame). @@ -424,6 +506,11 @@ enum _HpsTerminalKind { /// the wire the task cannot make progress — it ends through the one abort /// boundary ([BleEngine._endHistoryTaskWithAbort]). resultWriteFailed, + + /// The ECG owner took the transport ([BleEngine.ecgCancelHistory]): the + /// task ends through the one abort boundary and the band keeps its + /// checkpoint for the ordinary sync that follows the reading. + preempted, } class _HpsTerminal { @@ -848,6 +935,13 @@ class BleEngine { final LiveFrameSink? onLiveFrame; final OffloadStateSink? onOffloadState; + /// WHOOP MG ECG events (parsed live R17, malformed R17, link down). RAM + /// only — never persisted here. See [EcgEngineEvent]. + final EcgEventSink? onEcgEvent; + + /// The READY-time ECG recovery seam — see [EcgReadyHook]. + final EcgReadyHook? onReadyEcgRecovery; + /// The current live-stream owner set (#287). Read INSIDE the reconcile loop, /// never cached, so a nudge that was missed is healed by the next keep-alive /// tick rather than persisting until the next owner change. Null means no @@ -891,6 +985,8 @@ class BleEngine { this.onDataStored, this.onLiveFrame, this.onOffloadState, + this.onEcgEvent, + this.onReadyEcgRecovery, this.liveOwners, this.onCommitBatch, this.onArchiveRecord, @@ -961,6 +1057,185 @@ class BleEngine { /// gen4 before discovery has run. String? get linkDeviceFamily => state.generation; + // ── WHOOP MG ECG (Labrador) transport ─────────────────────────────────────── + + /// The current ECG lease, if any (capture or READY recovery). + EcgLease? _ecgLease; + + /// Positively identified WHOOP MG: a revision-1 gen5 HELLO whose optical + /// discriminator is in the MAVERICK interval. False for gen4, for the + /// ordinary WHOOP 5.0 and before hello. Never inferred from the UUID, the + /// name or command acceptance. + bool get isMaverick => _gen5Hello?.isMaverick ?? false; + + /// The link generation — bumped once per teardown. ECG work captures it + /// and ignores anything from an older link. + int get linkGeneration => _linkGeneration; + + bool get ecgLeaseHeld => _ecgLease != null; + + /// Claim the transport for an ECG reading. Synchronous, so a caller can + /// claim BEFORE awaiting history cancellation. Null when the link is not + /// connected or the transport is already leased (another capture, or READY + /// recovery still running). + EcgLease? ecgAcquire() { + final session = _session; + if (session == null || !session.connected) return null; + if (_ecgLease != null) return null; + final lease = EcgLease._(session, _linkGeneration); + _ecgLease = lease; + return lease; + } + + /// True while [lease] is the live lease of the live link. + bool ecgLeaseValid(EcgLease lease) { + final session = _session; + return identical(_ecgLease, lease) && + session != null && + session.connected && + identical(lease._owner, session) && + lease.linkGeneration == _linkGeneration; + } + + /// Release [lease]. A stale lease (not the current one) is ignored, so an + /// old controller cannot release a replacement link's lease. + void ecgRelease(EcgLease lease) { + if (identical(_ecgLease, lease)) _ecgLease = null; + } + + /// End the phone-side history owner and wait for its lifecycle to go + /// quiescent (abort delivered or given up, marker handler out of any + /// parked commit). The canonical ECG START list still sends its own + /// opcode 20 afterwards — this is ownership, not the abort itself. + Future ecgCancelHistory(EcgLease lease) async { + if (!ecgLeaseValid(lease)) return; + final session = lease._owner as _Session; + if (_offloadActive && !session.historyTaskEnded) { + await _endHistoryTaskWithAbort( + session: session, + kind: _HpsTerminalKind.preempted, + reason: 'ecg_preempted', + ); + } + await _awaitHistoryLifecycleQuiescence(); + } + + static List<_EcgMember> _ecgPrepareMembers(WristSelection wrist) => [ + ('selectWrist', Cmd.selectWrist, + (seq, band) => cmdSelectWrist(seq, wrist, profile: band)), + ('filteredOn', Cmd.toggleLabradorFiltered, + (seq, band) => cmdLabradorFiltered(seq, true, profile: band)), + ('rawSaveOn', Cmd.toggleLabradorRawSave, + (seq, band) => cmdLabradorRawSave(seq, true, profile: band)), + ]; + + static List<_EcgMember> _ecgStartMembers(LabradorOperation op) => [ + ('abortHistorical', Cmd.abortHistoricalTransmits, + (seq, band) => cmdAbortHistorical(seq, profile: band)), + ( + op == LabradorOperation.restart + ? 'generationRestart' + : 'generationStart', + Cmd.toggleLabradorDataGeneration, + (seq, band) => cmdLabradorDataGeneration(seq, op, profile: band), + ), + ]; + + static final List<_EcgMember> _ecgCleanupMembers = [ + ('generationStop', Cmd.toggleLabradorDataGeneration, + (seq, band) => + cmdLabradorDataGeneration(seq, LabradorOperation.stop, profile: band)), + ('filteredOff', Cmd.toggleLabradorFiltered, + (seq, band) => cmdLabradorFiltered(seq, false, profile: band)), + ('rawSaveOff', Cmd.toggleLabradorRawSave, + (seq, band) => cmdLabradorRawSave(seq, false, profile: band)), + ]; + + /// PREPARE: 123 wrist, 139 filtered ON, 125 raw-save ON. Attempt-all; the + /// caller accepts only when every member succeeded. + Future> ecgPrepare( + EcgLease lease, + WristSelection wrist, + ) => + _runEcgList(lease, _ecgPrepareMembers(wrist)); + + /// START: 20 abort-history (unconditional), 124 generation START. + Future> ecgStart(EcgLease lease) => + _runEcgList(lease, _ecgStartMembers(LabradorOperation.start)); + + /// RESTART: 20, 124 generation RESTART — only for the reducer's exact + /// explicit-restart predicate, never for ordinary contact loss. + Future> ecgRestart(EcgLease lease) => + _runEcgList(lease, _ecgStartMembers(LabradorOperation.restart)); + + /// CLEANUP: 124 STOP, 139 OFF, 125 OFF — every member attempted, in order, + /// whatever an earlier one answered. The caller clears its durable guard + /// only when all three succeeded. + Future> ecgCleanup(EcgLease lease) => + _runEcgList(lease, _ecgCleanupMembers); + + /// The cleanup triplet under the READY recovery lease — callable only from + /// inside [onReadyEcgRecovery]. Empty (nothing written) otherwise. + Future> ecgRecoveryCleanup() { + final lease = _ecgLease; + if (lease == null || !lease.recovery) return Future.value(const []); + return _runEcgList(lease, _ecgCleanupMembers); + } + + /// Attempt every member in order, one correlated await each (observer + /// before write, seq+opcode match, the common five-second timeout, no + /// retry). A member whose lease is no longer valid is recorded unwritten + /// and the rest are still walked, so the outcome list is always complete. + Future> _runEcgList( + EcgLease lease, + List<_EcgMember> members, + ) async { + final out = []; + for (final (label, opcode, build) in members) { + if (!ecgLeaseValid(lease)) { + out.add(EcgCommandOutcome(label, opcode, + written: false, succeeded: false)); + continue; + } + final session = lease._owner as _Session; + final sent = await _sendAwaited( + opcode, + const [], + frameBuilder: (seq) => build(seq, session.band), + owner: session, + ); + if (!sent.written) { + out.add(EcgCommandOutcome(label, opcode, + written: false, succeeded: false)); + continue; + } + final r = await sent.response; + final ok = r != null && r.success; + _log('[ECG] $label opcode=$opcode → ' + '${r == null ? 'no response' : 'status=${r.status}'}'); + out.add(EcgCommandOutcome(label, opcode, written: true, succeeded: ok)); + } + return out; + } + + /// READY recovery: hold a recovery lease around [onReadyEcgRecovery] so + /// the hook can run the cleanup triplet before `listening` is published + /// and before the INIT drain. Returns false when the link died under it. + Future _runEcgReadyRecovery(_Session session) async { + final hook = onReadyEcgRecovery; + if (hook == null) return true; + final lease = EcgLease._(session, _linkGeneration, recovery: true); + _ecgLease = lease; + try { + await hook(this); + } catch (e) { + _log('[ECG] READY recovery hook threw: $e — continuing.'); + } finally { + if (identical(_ecgLease, lease)) _ecgLease = null; + } + return !_sessionIsStale(session); + } + // ── PROCESS-WIDE SINGLE-OWNER GUARD ───────────────────────────────────────── // The strap streams its historical offload to EVERY subscribed central. If two // BleEngine instances in this process are connected at once — the foreground @@ -1434,6 +1709,11 @@ class BleEngine { ); } + /// The armed drain controller (null before a link is set up) — so a test + /// can see what an ingested frame buffered without driving a HISTORY_END. + @visibleForTesting + DrainController? get debugDrain => _drain; + /// Test seam onto the LOWEST-level write, so the dangerous-opcode block that /// lives there can be exercised on a pre-framed frame — which is exactly the /// shape the nine `_send`-bypassing call sites hand it. @@ -2571,7 +2851,10 @@ class BleEngine { // disconnect cancels it — no zombie timer firing into a dead characteristic. session.heartbeat = Timer.periodic(const Duration(seconds: 10), (_) { if (!session.connected || - shouldPauseMaintenanceTraffic(offloadActive: _offloadActive)) { + shouldPauseMaintenanceTraffic( + offloadActive: _offloadActive, + ecgLeased: _ecgLease != null, + )) { return; } // Backgrounded: 60 s cadence. LINK_VALID is an app-level write, not @@ -2629,6 +2912,13 @@ class BleEngine { '$_helloFailures accumulated hello failure(s) at READY.'); _helloFailures = 0; } + // WHOOP MG ECG recovery runs BEFORE READY is visible: a retained + // may-be-active guard gets the transport first, so no capture and no + // history task can start until the cleanup triplet has been attempted. + if (!await _runEcgReadyRecovery(session)) { + _log('[ECG] link died under READY recovery — not reporting ready.'); + return false; + } _setPhase(BleConnState.listening); // The charging-only battery-pack lookup launches strictly AFTER // READY, asynchronously; it never blocks or gates anything. @@ -3466,7 +3756,10 @@ class BleEngine { ); return; } - if (shouldPauseMaintenanceTraffic(offloadActive: _offloadActive)) { + if (shouldPauseMaintenanceTraffic( + offloadActive: _offloadActive, + ecgLeased: _ecgLease != null, + )) { return; } // Proactive RTC recheck: every other clock verification is symptom-driven @@ -3700,6 +3993,11 @@ class BleEngine { } return false; } + if (_ecgLeaseHeldFor(session)) { + _log('[SYNC] refresh($reason) refused — the ECG owner holds the ' + 'transport; history resumes after the reading.'); + return false; + } if (_offloadActive && !d._complete) { _log( '[SYNC] refresh($reason) dropped — strap is already transmitting history.', @@ -3719,10 +4017,13 @@ class BleEngine { // HistoryComplete tail-commit handling) — those rows re-attempt on the // next commit, exactly as documented there. if (session.historyTaskEnded && - (d.bufferedRecords > 0 || d.bufferedArchives > 0)) { + (d.bufferedRecords > 0 || + d.bufferedArchives > 0 || + d.bufferedEcgRaw > 0)) { _log( '[SYNC] refresh($reason) — discarding the aborted previous task\'s ' '${d.bufferedRecords} record(s) + ${d.bufferedArchives} archive(s) ' + '+ ${d.bufferedEcgRaw} raw ECG ' 'of leftover un-ACKed buffer before starting a new task; the band ' 're-delivers them.', ); @@ -4265,6 +4566,7 @@ class BleEngine { List payload, { Duration timeout = CommandAwaiter.defaultTimeout, Uint8List Function(int seq)? frameBuilder, + _Session? owner, }) async { if (_refuseDangerousOpcode(opcode)) { return (written: false, response: Future.value()); @@ -4273,7 +4575,7 @@ class BleEngine { final pending = _awaiter.register(seq, opcode, timeout: timeout); final frame = frameBuilder?.call(seq) ?? buildCommand(seq, opcode, payload, _session?.band ?? BandProfile.gen4); - if (!await _write(frame)) { + if (!await _write(frame, owner: owner)) { pending.cancel(); _log('WRITE FAILED for opcode 0x${opcode.toRadixString(16)} — ' 'command not delivered.'); @@ -4403,17 +4705,23 @@ class BleEngine { List samples, String? trimTokenHex, { List? archives, + List? ecgRawPackets, String? deviceFamily, }) async { final hasArchives = archives != null && archives.isNotEmpty; - if (raws.isEmpty && trimTokenHex == null && !hasArchives) return; + final hasEcgRaw = ecgRawPackets != null && ecgRawPackets.isNotEmpty; + if (raws.isEmpty && trimTokenHex == null && !hasArchives && !hasEcgRaw) { + return; + } // Stamp the family HERE, from the link that produced the chunk: this is the // last point that knows it. Callers may override (tests / a replay that // knows better); null falls back to the live link, which is itself null // before discovery has pinned one. await onCommitBatch!(raws, samples, trimTokenHex, - archives: archives, deviceFamily: deviceFamily ?? linkDeviceFamily); - if (raws.isNotEmpty || hasArchives) _noteStored(); + archives: archives, + ecgRawPackets: ecgRawPackets, + deviceFamily: deviceFamily ?? linkDeviceFamily); + if (raws.isNotEmpty || hasArchives || hasEcgRaw) _noteStored(); } // ── frame handling ───────────────────────────────────────────────────────────── @@ -4481,6 +4789,19 @@ class BleEngine { liveHex, (liveTs != null && liveTs > 0) ? liveTs : null, ); + // WHOOP MG live filtered ECG (type 43, data revision 17). Parsed here, + // delivered synchronously, never persisted by the engine — the ECG + // owner keeps only the accepted window (RAM otherwise). A frame that + // claims revision 17 but does not parse is reported, not dropped. + if (pt == PacketType.realtimeRawData && + (_session?.band.isGen5 ?? false) && + frame.inner.length > 1 && + frame.inner[1] == LabradorR17.revision) { + final r17 = LabradorR17.parse(frame.inner); + onEcgEvent?.call(r17 != null + ? EcgFrameEvent(r17, _linkGeneration) + : EcgMalformedR17Event(_linkGeneration, 'r17_parse')); + } // Fall through to decodeFrame so the UI gets live telemetry (state.liveHr). } if (pt == PacketType.historicalData) { @@ -4683,6 +5004,13 @@ class BleEngine { /// True once [session] is no longer the engine's live session — the guard /// every long-parked offload callback shares. + bool _ecgLeaseHeldFor(_Session session) { + final l = _ecgLease; + return l != null && + identical(l._owner, session) && + l.linkGeneration == _linkGeneration; + } + bool _sessionIsStale(_Session session) => _session != session || !session.connected; @@ -4743,6 +5071,30 @@ class BleEngine { Sample? sample; final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; final isGen5 = _session?.band.isGen5 ?? false; + // WHOOP MG raw ECG (type 47, revision 16): the band saved it under + // raw-save ON and ordinary history delivers it. Not a Sample — it has no + // 1 Hz meaning and skips the plausibility gate — but it IS a burst count + // member and it rides the safe-trim commit into ecg_raw_packet, its only + // durable store. Without a buffered drain it falls through to the + // archive path below, which keeps the bytes. + if (isGen5 && recType == Record.r16) { + final r16 = LabradorR16Raw.tryParse(frame.inner); + final d = _drain; + if (r16 != null && d != null && d.supportsSafeTrim) { + d.onEcgRawPacket( + EcgRawPacket( + hex: _innerHex(frame.inner), + deviceId: LocalDb.kPrimaryDeviceId, + sequence: r16.sequence, + strapSeconds: r16.strapSeconds, + strapSubsec: r16.subseconds, + capturedAt: DateTime.now().millisecondsSinceEpoch, + ), + counter: counter, + ); + return; + } + } if (isGen5) { // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real // gen5 historical-record kinds (v18 per-second summary, v20 optical/ @@ -5850,7 +6202,9 @@ class BleEngine { ); if (m.sub == SyncMeta.historyStart) { final d = _drain; - if (_offloadActive && d != null && d.bufferedRecords > 0) { + if (_offloadActive && + d != null && + (d.bufferedRecords > 0 || d.bufferedEcgRaw > 0)) { _log( '[SYNC] HistoryStart received during active burst — discarding ' 'partial open chunk and restarting burst state.', @@ -6080,15 +6434,17 @@ class BleEngine { // This only decides whether the band may TRIM. Archives are committed in // the same transaction regardless — except on the no-progress path, which // returns before commit precisely because there is nothing to bank. - final hadDurableRows = - d.bufferedRecords > 0 || d.bufferedProgressArchives > 0; + final hadDurableRows = d.bufferedRecords > 0 || + d.bufferedProgressArchives > 0 || + d.bufferedEcgRaw > 0; _log( '[SYNC] HistoryEnd batch=${m.batchId} records=${d.records} ' 'expected=${m.expectedPacketCount} ' 'historical=${d.currentBurstHistoricalPacketCount} ' 'traffic=${d.currentBurstTrafficCount} token=$tokenHex ' 'dropped_this_burst=$droppedThisBurstForLog ' - 'durable_buffered=${d.bufferedRecords}+${d.bufferedArchives} ' + 'durable_buffered=${d.bufferedRecords}+${d.bufferedArchives}' + '+${d.bufferedEcgRaw} ' 'recTs=${r == null ? "none" : "${r.$1}..${r.$2}"}', ); // Non-trimmable wiring (no onCommit): unbuffered fire-and-forget cannot @@ -7664,7 +8020,11 @@ class BleEngine { // caller parked on a 5 s await through a teardown delays whatever the // reconnect wants to do next. Resolve them all as unanswered now. _awaiter.failAll(); + final endedGeneration = _linkGeneration; _linkGeneration++; + // The ECG lease died with its link; tell the owner which generation. + _ecgLease = null; + onEcgEvent?.call(EcgLinkDownEvent(endedGeneration)); _drain?.onLinkDown(); _drain = null; // Fire a final derive for anything stored-but-not-yet-derived, then disarm the @@ -7900,6 +8260,10 @@ class DrainController { // transaction as [_raws]/[_samples]/the trim cursor (see [commit]) so a future // firmware's records are durably set aside BEFORE the band is told to trim. final List _archives = []; + // WHOOP MG raw ECG (R16) records buffered for THIS chunk — same lifecycle + // as [_archives]: committed in the one pre-ACK transaction, restored on a + // failed commit, dropped with a discarded chunk. + final List _ecgRaw = []; // Per-burst packet accounting (per-revision counts + sequence gap detection), // merged into the session totals when a burst validates. final BurstStats burstStats = BurstStats(); @@ -7913,6 +8277,31 @@ class DrainController { int get bufferedRecords => _raws.length; int get bufferedArchives => _archives.length; + int get bufferedEcgRaw => _ecgRaw.length; + + /// A raw ECG record for this chunk. Genuine, ACKable progress and a burst + /// count member (the band counts every type-47 frame it sent). Only the + /// buffered path exists for it: without [onCommit] there is no transaction + /// to ride, and R16 must never be persisted outside the pre-ACK commit. + void onEcgRawPacket(EcgRawPacket p, {required int counter}) { + if (!_buffering) { + throw StateError( + 'DrainController.onEcgRawPacket needs the atomic commit sink — raw ' + 'ECG is persisted only inside the pre-ACK transaction', + ); + } + records++; + recordsThisOffload++; + if (!_burstTallyClosed) { + burstStats.onHistoricalData( + PacketType.historicalData, + counter, + LabradorR16Raw.revision, + ); + } + _lastProgressAt = DateTime.now(); + _ecgRaw.add(p); + } /// Archives that represent real forward progress, i.e. everything EXCEPT the /// plausibility drops. A burst of records we simply cannot decode has still @@ -8226,13 +8615,15 @@ class DrainController { /// is cleared only by [beginBurst] — a fresh HISTORY_START from the band. void discardOpenChunk() { _trimGuard.discardOpenChunk(); - if (_raws.isEmpty && _archives.isEmpty) return; + if (_raws.isEmpty && _archives.isEmpty && _ecgRaw.isEmpty) return; log('discarding ${_raws.length} un-ACKed buffered records + ' - '${_archives.length} archived (idle). This burst\'s HISTORY_END token ' - 'is now un-ACKable — the band keeps the chunk.'); + '${_archives.length} archived + ${_ecgRaw.length} raw ECG (idle). ' + 'This burst\'s HISTORY_END token is now un-ACKable — the band keeps ' + 'the chunk.'); _raws.clear(); _samples.clear(); _archives.clear(); + _ecgRaw.clear(); } /// SAFE-TRIM commit: persist the buffered chunk + the continuation [token] @@ -8260,7 +8651,9 @@ class DrainController { final raws = List.from(_raws); final samples = List.from(_samples); final archives = List.from(_archives); - final hadDurable = raws.isNotEmpty || archives.isNotEmpty; + final ecgRaw = List.from(_ecgRaw); + final hadDurable = + raws.isNotEmpty || archives.isNotEmpty || ecgRaw.isNotEmpty; // Token changed AND we actually banked something — empty ACKs must not // look like cursor progress to auto-continue / stuck-strap. lastTrimAdvanced = @@ -8269,10 +8662,14 @@ class DrainController { _raws.clear(); _samples.clear(); _archives.clear(); + _ecgRaw.clear(); try { // Defense in depth (constructor already rejects onRecordsBatch-only): // never report durable success for buffered content without onCommit. - if (raws.isNotEmpty || archives.isNotEmpty || tokenHex != null) { + if (raws.isNotEmpty || + archives.isNotEmpty || + ecgRaw.isNotEmpty || + tokenHex != null) { final commit = onCommit; if (commit == null) { throw StateError( @@ -8281,7 +8678,8 @@ class DrainController { 'archives=${archives.length}, token=${tokenHex != null})', ); } - await commit(raws, samples, tokenHex, archives: archives); + await commit(raws, samples, tokenHex, + archives: archives, ecgRawPackets: ecgRaw); } return true; } catch (e) { @@ -8290,11 +8688,13 @@ class DrainController { _raws.insertAll(0, raws); _samples.insertAll(0, samples); _archives.insertAll(0, archives); + _ecgRaw.insertAll(0, ecgRaw); // Roll back the trim bookkeeping too — nothing advanced. _lastAckedToken = previousAckedToken; lastTrimAdvanced = previousTrimAdvanced; log('offload commit FAILED ($e) — ${raws.length} records + ' - '${archives.length} archived re-buffered; the caller MUST NOT ACK ' + '${archives.length} archived + ${ecgRaw.length} raw ECG ' + 're-buffered; the caller MUST NOT ACK ' 'this chunk (the band still holds it).'); return false; } diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart index a6648f619..efe7e766e 100644 --- a/lib/coach/coach_actions.dart +++ b/lib/coach/coach_actions.dart @@ -26,6 +26,8 @@ import 'dart:convert'; import 'package:sqflite/sqflite.dart'; import '../data/day_label.dart'; +import '../data/db.dart'; +import '../ecg/ecg_models.dart'; import '../data/journal_fields.dart'; import '../data/local_repository.dart'; import '../data/med_store.dart'; @@ -110,6 +112,127 @@ class CoachActions { return d.millisecondsSinceEpoch ~/ 1000; } + // ── WHOOP MG ECG ─────────────────────────────────────────────────────────── + + /// At most this many waveform buckets leave the device. + /// Character budget for one `get_ecg_reading` result, kept under the + /// engine's `kMaxToolResultChars`. This file cannot import that constant — + /// the engine imports these actions, not the other way round — so + /// `coach_ecg_tool_test` pins the two against each other. The budget exists + /// so a long window is decimated deliberately rather than clipped + /// mid-number into JSON the model cannot parse. + static const int ecgMaxPayloadChars = 22000; + + /// Largest stride the decimation will reach before giving up widening it. + static const int ecgMaxStride = 16; + + /// One saved ECG reading for the coach: the band-reported summary plus the + /// accepted waveform at the band's own sample rate. A BOUND query on the + /// reading id — never model-written SQL — and never the raw frame hex, the + /// band serial, the device id or the notes. A completed reading is 30 s = + /// 3,000 samples and is sent whole; only a longer accepted window is + /// decimated, by a whole-number stride, so the result always parses. + static Future ecgReading(Database db, Object? id) async { + final readingId = str(id); + if (readingId.isEmpty) { + throw CoachActionError('get_ecg_reading needs a reading_id.'); + } + final row = await LocalDb.ecgReading(readingId); + final reading = row == null ? null : EcgReading.fromRow(row); + if (reading == null) { + return jsonEncode({'error': 'No ECG reading with id $readingId.'}); + } + final packets = (await LocalDb.ecgReadingPackets(readingId)) + .map(EcgPacketCodec.fromRow) + .toList(); + final samples = []; + for (final p in packets) { + if (p.placeholder) { + // One second of "no data" keeps the waveform's time axis honest. + samples.addAll(List.filled(kEcgSampleRateHz, null)); + } else { + samples.addAll(p.samples); + } + } + final local = DateTime.fromMillisecondsSinceEpoch(reading.startTs * 1000); + + /// Every `stride`-th sample, keeping nulls so the time axis stays honest. + List strided(int stride) => stride <= 1 + ? samples + : [for (var i = 0; i < samples.length; i += stride) samples[i]]; + + Map payload(int stride) { + final out = strided(stride); + return { + 'id': reading.id, + 'local_time': local.toIso8601String(), + 'date': dayLabelOf(local), + 'status': reading.status.name, + 'band_category': reading.category.name, + 'result_code': reading.resultCode, + 'avg_hr': reading.avgHr, + 'quality': reading.quality, + 'unreadable_reasons': reading.unreadableReasons, + 'interruptions': reading.interruptions, + 'duration_s': reading.durationS, + 'sample_count': reading.sampleCount, + 'missing_segments': reading.missingSegments, + 'min_uv': reading.minUv, + 'max_uv': reading.maxUv, + 'rms_uv': reading.rmsUv, + 'source': 'WHOOP MG band (HeartKey result; category is the band\'s)', + 'unit': reading.sampleCount == 0 ? null : kEcgSampleUnit, + 'sample_rate_hz': kEcgSampleRateHz, + 'waveform': { + 'samples': out, + 'count': out.length, + 'stride': stride, + 'effective_rate_hz': kEcgSampleRateHz / stride, + 'note': 'consecutive samples in filtered input-referred microvolts at ' + 'effective_rate_hz; null where the accepted window has a missing ' + 'segment. stride 1 is every sample the band sent.', + }, + // What the numbers above are and what they cannot support. The model + // otherwise infers intervals from avg_hr and reads QRS width as if the + // trace were a 500 Hz diagnostic ECG. + 'how_to_read': { + 'sample_rate': 'The band acquires at 500 Hz and hands HeartKey those ' + 'raw samples; what you get here is the band\'s own filtered and ' + '5:1 decimated 100 Hz output. One sample is 10 ms, so every ' + 'interval or width you measure is quantised to 10 ms — enough ' + 'for rate and regularity, coarse for QRS width, and marginal ' + 'for P-wave detail. The 500 Hz raw is not available to you.', + 'units': 'Integer input-referred microvolts, already scaled on the ' + 'band. No further conversion.', + 'polarity': 'Anatomical lead orientation is NOT proven. Do not infer ' + 'axis, or read R/S direction as anatomical.', + 'avg_hr': 'The BAND\'s own average over the reading. It is not ' + 'measured from these samples. If you state an RR interval or ' + 'beat-to-beat variation, measure it from the samples and say so ' + '— do not present 60/avg_hr as a measurement.', + 'quality': 'The band\'s own 0-3 signal-quality scale, higher is ' + 'better; it climbs as contact settles.', + 'interruptions': 'Times contact was lost and the band restarted its ' + 'progress. missing_segments are whole seconds absent from the ' + 'accepted window, and appear as null runs in samples.', + 'category': 'The band\'s HeartKey result mapped by the app. Your own ' + 'reading of the trace is your own; say plainly if they differ.', + }, + 'note': 'Band-reported. Not a diagnosis: no lead polarity is proven and ' + 'the phone classifies nothing from the waveform.', + }; + } + + // Widen the stride only if the whole window will not fit one tool result. + var stride = 1; + var encoded = jsonEncode(payload(stride)); + while (encoded.length > ecgMaxPayloadChars && stride < ecgMaxStride) { + stride++; + encoded = jsonEncode(payload(stride)); + } + return encoded; + } + // ── nutrition ────────────────────────────────────────────────────────────── /// One day of food: every entry, plus the totals the app itself computes. diff --git a/lib/coach/coach_db.dart b/lib/coach/coach_db.dart index 6ee92ff37..e93cd404f 100644 --- a/lib/coach/coach_db.dart +++ b/lib/coach/coach_db.dart @@ -65,6 +65,7 @@ class CoachDb { 'v_sessions', 'v_baselines', 'v_insights', + 'v_ecg_readings', }; // Keywords that must never appear as standalone tokens (anything mutating or @@ -98,6 +99,11 @@ class CoachDb { 'raw_records', 'raw_archive', 'decoded_onehz', 'decoded_rr', 'samples', 'events', 'band_events', 'band_battery', 'device_coverage', 'signal_priority', + // WHOOP MG ECG. `ecg_reading` is a base table of an allowed view, so the + // structural gate would admit its btree — this token-level block is what + // keeps `device_id`/`notes` (never in the view) out of run_sql. The two + // packet tables are unreachable at both layers. + 'ecg_reading', 'ecg_reading_packet', 'ecg_raw_packet', // sync / compute bookkeeping 'sync_ledger', 'sync_quarantine', 'sync_cursor', 'sync_ledger_legacy', 'sync_quarantine_legacy', 'sync_cursor_legacy', 'compute_jobs', diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 5cc0489a3..27063ae1f 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -240,15 +240,31 @@ class CoachEngine { /// Max characters of any single tool result kept in the resent history. static const int kMaxToolResultChars = 16000; + /// The ceiling for `get_ecg_reading` alone. + /// + /// The bound above exists because the MODEL widens its own queries — it can + /// keep asking `run_sql` for more until one result dominates the window. + /// `get_ecg_reading` is not that shape: it is a bound lookup of ONE reading + /// by id, and its size is decided by the band (a completed reading is 30 s + /// at 100 Hz), not by the model. Clipping it would not restrain a model, it + /// would only decimate a waveform to make room for the prose describing it. + static const int kMaxEcgToolResultChars = 24000; + /// Max characters of running history resent on each turn. static const int kMaxHistoryChars = 120000; /// Hard ceiling on one serialized provider request body. static const int kMaxRequestBytes = 400 * 1024; - static String _clipToolResult(String s) => s.length <= kMaxToolResultChars - ? s - : '${s.substring(0, kMaxToolResultChars)}…(truncated — narrow the query)'; + static int _capFor(String tool) => + tool == 'get_ecg_reading' ? kMaxEcgToolResultChars : kMaxToolResultChars; + + static String _clipToolResult(String s, String tool) { + final cap = _capFor(tool); + return s.length <= cap + ? s + : '${s.substring(0, cap)}…(truncated — narrow the query)'; + } int _historyChars() { var n = 0; @@ -537,7 +553,7 @@ class CoachEngine { 'role': 'tool', 'tool_call_id': id, 'name': name, - 'content': _clipToolResult(result), + 'content': _clipToolResult(result, name), }); } } @@ -758,6 +774,11 @@ class CoachEngine { await LocalDb.instance, args['date']); case 'get_medications': return await CoachActions.medications(await LocalDb.instance); + // data — one saved ECG reading, by id. Bound query + bounded payload; + // the packet tables stay unreachable through run_sql. + case 'get_ecg_reading': + return await CoachActions.ecgReading( + await LocalDb.instance, args['reading_id']); // plot — legacy bar/line/area figure case 'plot_chart': @@ -951,7 +972,13 @@ class CoachEngine { 'calendar day; filter "today\'s workout" by date, never by converting ' 'start_ts/end_ts yourself; ' 'v_baselines(key,value,mean,z,delta,ratio,n,updated_at); ' - 'v_insights(id,kind,title,body,date,created_at,read). ' + 'v_insights(id,kind,title,body,date,created_at,read); ' + 'v_ecg_readings(id,start_ts,end_ts,date,wrist,status,category,' + 'result_code,avg_hr,quality,unreadable_mask,interruptions,duration_s,' + 'sample_count,sample_rate_hz,sample_unit,min_uv,max_uv,rms_uv,' + 'missing_segments) — WHOOP MG ECG readings, SUMMARY only (the ' + 'category is the band\'s own result); the waveform is in ' + 'get_ecg_reading. ' 'Read-only, derived only — no other tables. Dates are \'YYYY-MM-DD\'; ' 'timestamps are epoch seconds. Prefer aggregates (AVG/MIN/MAX/COUNT) over ' 'SELECT *. Results are capped at 200 rows.', @@ -992,6 +1019,18 @@ class CoachEngine { _fn('get_medications', 'Read the medication/supplement schedule and today\'s doses ' '(taken/skipped/missed/upcoming). Not in run_sql — use this.', {}), + _fn('get_ecg_reading', + 'Read ONE saved WHOOP MG ECG reading by id: local time, status, the ' + 'BAND-REPORTED category and result code, average HR, signal quality, ' + 'unreadable reasons, duration, sample count, missing segments, ' + 'min/max/RMS, and the accepted waveform in microvolts at the band\'s ' + 'own sample rate (null where a segment is missing; a window too long ' + 'for one result is decimated by a whole-number stride, reported as ' + '`stride`). Never returns raw frames, a band serial or the notes. The ' + 'category is the band\'s HeartKey result, not yours; you may read the ' + 'waveform yourself and say if you disagree with it.', + {'reading_id': {'type': 'string', 'description': 'the reading id from v_ecg_readings'}}, + ['reading_id']), _fn('log_food', 'Log something eaten (asks the user to confirm). EVERY nutrient is ' 'optional: an eating occasion with no numbers is a complete log, and ' diff --git a/lib/coach/coach_prompt.dart b/lib/coach/coach_prompt.dart index 4d15a0f0d..980249570 100644 --- a/lib/coach/coach_prompt.dart +++ b/lib/coach/coach_prompt.dart @@ -40,6 +40,21 @@ one friendly sentence declining and steering back. Never write code. NOWHERE — there is no hydration score and you must not invent one. 6. Not a doctor. One "Not medical advice." line ONLY when you actually gave health guidance. +7. ECG READINGS (WHOOP MG only). A reading is user-initiated, single-lead- + like band data; its anatomical lead polarity is NOT proven. The category + (sinus rhythm, possible AFib, low/high heart rate, inconclusive, + unreadable) is the BAND's HeartKey result, not yours and not the app's. + You may explain what the band-reported category means, the signal + quality, the heart rate and the unreadable reasons, and you may read the + waveform itself — rate, rhythm and its regularity, beat-to-beat variation, + intervals and morphology — and give your own impression of it. Say plainly + where the trace, the polarity or the signal quality does not support a + reading, and say when the band's category and your own reading disagree + rather than smoothing it over. You are not a substitute for a clinician + and this is not a cleared diagnostic device: a concerning result, a + disagreement or symptoms → appropriate clinical evaluation. + Chest pain, severe shortness of breath, fainting or other emergency + symptoms → urgent/emergency care, first and plainly. # DON'T RESTATE THE APP They can already see last night's numbers. Repeating them back is noise. Say @@ -75,6 +90,12 @@ no subqueries in FROM (use WITH). Dates are 'YYYY-MM-DD'; timestamps are epoch SECONDS; flags are 1/0. Prefer AVG/MIN/MAX/COUNT + GROUP BY over many rows; results cap at 200. If a query is rejected, read the reason and fix it. +- v_ecg_readings(id, start_ts, end_ts, date, wrist, status, category, + result_code, avg_hr, quality, unreadable_mask, interruptions, duration_s, + sample_count, sample_rate_hz, sample_unit, min_uv, max_uv, rms_uv, + missing_segments) — WHOOP MG ECG readings, summary only; `category` is the + band's. The waveform is in `get_ecg_reading(reading_id)`. + Food and medications are NOT in SQL. Use `get_nutrition(date)` and `get_medications()`. diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 68becea3f..c9e5066a2 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1910,7 +1910,12 @@ const String kAnalyticsPin = '01e8b6e02b2370ae42490a678e6a0a4e3569104c'; // below is THAT SAME PR's merge commit — verified — so main's pin already // carries that wire format too. NO kAlgoVersion bump: ring11m declares no // signal either. -const String kProtocolPin = 'fe1464db98b84ac4d3ce6175d54ada11356d6c62'; +// REPIN (main, superseded further): protocol main @ bc7d8d0 — PR#54 lands +// the Labrador (WHOOP MG ECG) parser this branch's ECG capture pipeline +// calls. NO kAlgoVersion bump: ECG is not a derived `day_result`/ +// `metric_series` output, it is its own store (`ecg_reading` etc., schema +// v54) with nothing feeding the existing metrics. +const String kProtocolPin = 'bc7d8d0df706e40a2546ffde4545263f09d0fecb'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see diff --git a/lib/data/db.dart b/lib/data/db.dart index fa396a1c3..f4ee1ecfe 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -185,6 +185,12 @@ class LocalDb { 'sessions', 'workout_route', 'workout_split', + // User-initiated ECG readings and the band's raw ECG records recovered + // through history — the band trims its flash on ACK, so these too are + // the only copy. Parent before child. + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', // Derived once, from raw that no longer exists. 'day_result', 'metric_series', @@ -343,7 +349,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 53; + static const int schemaVersion = 54; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -447,6 +453,7 @@ class LocalDb { await _createDevice(db); await _createDeviceCoverage(db); await _createSignalPriority(db); + await _createEcgTables(db); await _createWorkoutSuggestions(db); await _createSleepOverride(db); await _createSleepNap(db); @@ -1052,6 +1059,20 @@ class LocalDb { // stop being consulted for new allocations going forward. await _createNotifSlots(db); } + if (oldV < 54) { + // The WHOOP MG ECG store: three new tables, CREATE TABLE IF NOT + // EXISTS and NOTHING else — no backfill, no rewrite, no ADD COLUMN, + // nothing read — so a throw here has nothing to roll back onto + // (invariant 11). The coach view over ecg_reading is created by + // _ensureCoachViews on the onOpen repair pass, after every table + // exists. Ships without a kAlgoVersion bump: nothing derived moves. + // + // This rung is 54: main took 51 for multi-device attribution (M3), + // 52 for Smart Wake Window and 53 for notif-slot allocation while + // this feature was on its own branch, so the store moved up to the + // next free rung rather than collide with any of them. + await _createEcgTables(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1141,6 +1162,7 @@ class LocalDb { db, 'alarm_schedule', 'smart_window_minutes', 'INTEGER NOT NULL DEFAULT 0', ); + await _createEcgTables(db); // Views LAST — they depend on metric_series / day_result / baselines / sessions // / notifications all existing. DROP+CREATE so a shape change takes effect. await _ensureCoachViews(db); @@ -1728,6 +1750,187 @@ class LocalDb { return db.query('alarm_schedule'); } + // ── WHOOP MG ECG ──────────────────────────────────────────────────────────── + // Three tables. `ecg_reading` is one user-initiated Labrador reading — the + // band-reported result, not a phone-side diagnosis. `ecg_reading_packet` is + // the accepted R17 window, one row per accepted packet in order, with the + // exact signed-i16-LE samples as a BLOB (the first BLOB column in this + // schema — every other payload is hex TEXT; the sample block is the one + // place a compact binary earns it) plus the exact inner hex, and a + // placeholder row for the ONE empty segment the official accumulator + // inserts at a sequence jump. `ecg_raw_packet` is the raw R16 record the + // band saved and ordinary history later delivered — kept byte-exact, keyed + // by its bytes, never decoded here. + // + // UNITS. start_ts / end_ts / strap_terminal_ts / strap_seconds are SECONDS + // (epoch or strap); created_at / captured_at are epoch MILLISECONDS — the + // same split raw_archive / RawRecord already use. `sample_unit` names what + // the samples are (filtered, input-referred integer microvolts); no lead or + // polarity is claimed anywhere. + // + // NO FOREIGN KEYS: this database never enables PRAGMA foreign_keys, so a + // REFERENCES clause would be inert. The reading→packet cascade is manual, + // inside one transaction ([deleteEcgReading]) — the same discipline + // decoded_onehz/decoded_rr use. + static Future _createEcgTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS ecg_reading ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + source TEXT NOT NULL, + wrist TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + strap_terminal_ts INTEGER, + strap_terminal_subsec INTEGER, + result_code INTEGER NOT NULL, + category TEXT NOT NULL, + avg_hr INTEGER, + quality INTEGER, + unreadable_mask INTEGER NOT NULL DEFAULT 0, + interruptions INTEGER NOT NULL DEFAULT 0, + sample_rate_hz INTEGER NOT NULL DEFAULT 100, + sample_unit TEXT NOT NULL DEFAULT 'filtered_input_referred_uv', + sample_count INTEGER NOT NULL, + min_uv INTEGER, + max_uv INTEGER, + rms_uv REAL, + missing_segments INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + notes TEXT, + created_at INTEGER NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_ecg_reading_start ' + 'ON ecg_reading(start_ts DESC)', + ); + await db.execute(''' + CREATE TABLE IF NOT EXISTS ecg_reading_packet ( + reading_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + sequence INTEGER NOT NULL, + strap_seconds INTEGER, + strap_subsec INTEGER, + sample_count INTEGER NOT NULL, + samples BLOB NOT NULL, + inner_hex TEXT NOT NULL, + is_placeholder INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (reading_id, ordinal) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS ecg_raw_packet ( + hex TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + sequence INTEGER, + strap_seconds INTEGER, + strap_subsec INTEGER, + captured_at INTEGER NOT NULL, + reading_id TEXT + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_ecg_raw_packet_strap ' + 'ON ecg_raw_packet(strap_seconds)', + ); + } + + /// Persist one accepted reading and its packets ATOMICALLY. Throws on any + /// failure (including an id collision — a re-save of the same reading is a + /// bug, not a merge), and the caller must not present a completed reading + /// unless this returned. [packets] are inserted in list order as ordinals + /// 0..n-1; a placeholder packet carries an empty BLOB and empty inner_hex. + static Future insertEcgReading( + Map reading, + List> packets, + ) async { + final db = await instance; + await db.transaction((txn) async { + await txn.insert( + 'ecg_reading', + reading, + conflictAlgorithm: ConflictAlgorithm.fail, + ); + final batch = txn.batch(); + for (var i = 0; i < packets.length; i++) { + batch.insert( + 'ecg_reading_packet', + {...packets[i], 'reading_id': reading['id'], 'ordinal': i}, + conflictAlgorithm: ConflictAlgorithm.fail, + ); + } + await batch.commit(noResult: true); + }); + } + + /// Saved readings, newest first, WITHOUT packets. + static Future>> listEcgReadings({ + int limit = 200, + }) async { + final db = await instance; + return db.query('ecg_reading', orderBy: 'start_ts DESC', limit: limit); + } + + /// One reading row, or null. + static Future?> ecgReading(String id) async { + final db = await instance; + final r = await db.query('ecg_reading', where: 'id = ?', whereArgs: [id]); + return r.isEmpty ? null : r.first; + } + + /// The accepted packets of [id] in ordinal order (placeholders included). + static Future>> ecgReadingPackets( + String id, + ) async { + final db = await instance; + return db.query( + 'ecg_reading_packet', + where: 'reading_id = ?', + whereArgs: [id], + orderBy: 'ordinal ASC', + ); + } + + /// Delete a reading and ITS packets in one transaction (manual cascade — + /// see [_createEcgTables]). Raw R16 rows are independent history evidence + /// and are not deleted with a reading; their association is cleared. + static Future deleteEcgReading(String id) async { + final db = await instance; + await db.transaction((txn) async { + await txn.delete( + 'ecg_reading_packet', + where: 'reading_id = ?', + whereArgs: [id], + ); + await txn.update( + 'ecg_raw_packet', + {'reading_id': null}, + where: 'reading_id = ?', + whereArgs: [id], + ); + await txn.delete('ecg_reading', where: 'id = ?', whereArgs: [id]); + }); + } + + /// Update the free-text notes of a reading. + static Future setEcgReadingNotes(String id, String? notes) async { + final db = await instance; + await db.update( + 'ecg_reading', + {'notes': notes}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// How many raw R16 records history has recovered (diagnostics). + static Future ecgRawPacketCount() async { + final db = await instance; + final r = await db.rawQuery('SELECT COUNT(*) AS n FROM ecg_raw_packet'); + return (r.first['n'] as num?)?.toInt() ?? 0; + } + /// Upsert one weekday's slot. [weekday] is 0=Mon..6=Sun (see /// `_createAlarmSchedule`'s doc); out-of-range values are the caller's bug, /// not something this layer silently clamps. @@ -3212,6 +3415,9 @@ class LocalDb { /// path and the one conflict policy. Null on every WHOOP commit, where it /// costs exactly one null check. List? neutrals, + // WHOOP MG raw ECG (R16) records recovered by this burst. Same + // transaction as everything else here — they have no other durable home. + List? ecgRawPackets, void Function(String)? onCheckpoint, String? deviceFamily, String deviceId = kPrimaryDeviceId, @@ -3229,6 +3435,7 @@ class LocalDb { extraCursors: extraCursors, archives: archives, neutrals: neutrals, + ecgRawPackets: ecgRawPackets, onCheckpoint: onCheckpoint, deviceFamily: deviceFamily, deviceId: deviceId, @@ -3242,6 +3449,9 @@ class LocalDb { Map? extraCursors, List? archives, List? neutrals, + // WHOOP MG raw ECG (R16) records recovered by this burst. Same + // transaction as everything else here — they have no other durable home. + List? ecgRawPackets, void Function(String)? onCheckpoint, // Which strap this batch came off, from the LIVE LINK (the engine pins it at // service discovery). Null = the caller could not name it, which lands as @@ -3388,6 +3598,21 @@ class LocalDb { if (++ops >= chunkOps) await flushChunk(); } } + // SAFE-TRIM INVARIANT, same rule: the raw ECG records land in this + // transaction, before the ACK that lets the band trim them. + if (ecgRawPackets != null) { + for (final e in ecgRawPackets) { + batch.insert('ecg_raw_packet', { + 'hex': e.hex, + 'device_id': e.deviceId, + 'sequence': e.sequence, + 'strap_seconds': e.strapSeconds, + 'strap_subsec': e.strapSubsec, + 'captured_at': e.capturedAt, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + if (++ops >= chunkOps) await flushChunk(); + } + } for (var i = 0; i < raws.length; i++) { final raw = raws[i]; final recTs = _recTsFor(raw); @@ -3434,7 +3659,8 @@ class LocalDb { } checkpoint( 'decoded_archive_queued raws=${raws.length} ' - 'archives=${archives?.length ?? 0}', + 'archives=${archives?.length ?? 0} ' + 'ecg_raw=${ecgRawPackets?.length ?? 0}', ); await flushChunk(); checkpoint('decoded_archive_committed'); @@ -4253,6 +4479,7 @@ class LocalDb { 'v_sessions', 'v_baselines', 'v_insights', + 'v_ecg_readings', ]; for (final v in views) { await db.execute('DROP VIEW IF EXISTS $v'); @@ -4425,6 +4652,24 @@ class LocalDb { CREATE VIEW v_insights AS SELECT id, kind, title, body, date, created_at, read FROM notifications '''); + // WHOOP MG ECG readings — SUMMARY ONLY, and from `ecg_reading` ALONE. + // The coach's structural (btree) guard admits every base table a view + // reads, so joining ecg_reading_packet or ecg_raw_packet here would make + // the exact sample bytes / raw frames reachable through run_sql. The + // bounded waveform envelope is served by the typed get_ecg_reading tool + // instead. No device_id (band identity) and no free-text notes. `date` is + // the LOCAL day like v_sessions; start_ts/end_ts are epoch SECONDS. + await db.execute(''' + CREATE VIEW v_ecg_readings AS + SELECT id, start_ts, end_ts, + strftime('%Y-%m-%d', start_ts, 'unixepoch', 'localtime') AS date, + wrist, status, category, result_code, avg_hr, quality, + unreadable_mask, interruptions, + (end_ts - start_ts) AS duration_s, + sample_count, sample_rate_hz, sample_unit, + min_uv, max_uv, rms_uv, missing_segments + FROM ecg_reading + '''); } /// Run a rename → recreate → copy legacy-shape migration ATOMICALLY and @@ -8336,15 +8581,10 @@ class LocalDb { /// behind its own catch: a rebuild salvaging a genuinely damaged file must /// lose that table and keep going, where a user-initiated restore of a file /// they chose must still fail loudly rather than report a partial success. - static Future> _mergeFromDbFile( - String path, { - List? only, - bool tolerant = false, - }) async { - final src = await openDatabase(path, readOnly: true); - final db = await instance; - // Order: independent tables; all use INSERT OR REPLACE so re-import is safe. - const tables = [ + /// Every table a backup restore (and the tolerant rebuild salvage) + /// merges, in order: independent tables first; all use INSERT OR + /// REPLACE so re-import is safe. + static const List _restoreTables = [ // Hand-entered rows first. Nothing regenerates these, so if a merge is // ever cut short (an OOM, a damaged source) they are the ones already // banked. They were also simply MISSING here until now — nutrition, @@ -8413,8 +8653,30 @@ class LocalDb { 'device', 'device_coverage', 'signal_priority', + // WHOOP MG ECG: a user-initiated reading, its exact accepted packets + // and the raw R16 records history recovered for it. None regenerates — + // the band trimmed its copy on ACK. Parent before child so a restore + // cut short never leaves packets without their reading. + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', 'sync_cursor', - ]; + ]; + + @visibleForTesting + static List get restoreTablesForTest => _restoreTables; + + @visibleForTesting + static List get salvageTablesForTest => _salvageTables; + + static Future> _mergeFromDbFile( + String path, { + List? only, + bool tolerant = false, + }) async { + final src = await openDatabase(path, readOnly: true); + final db = await instance; + const tables = _restoreTables; // Columns this app's schema actually has, per table — so a row from a NEWER // export carrying extra columns this build doesn't know about is filtered // down (dropped) instead of throwing "no such column". A column the source diff --git a/lib/data/models.dart b/lib/data/models.dart index a6438c5e4..ed619c12d 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -320,6 +320,30 @@ class ArchiveRecord { }); } +/// A historical WHOOP MG raw-ECG record (type 47, revision 16) exactly as it +/// came off the band. Persisted to `ecg_raw_packet` inside the SAME durable +/// commit that precedes the HISTORY_END ACK — it is the only durable store of +/// these bytes, so it rides the safe-trim transaction like raw_archive does. +/// The body is not decoded; only the common header (sequence, strap time) is +/// read, and [hex] (the full inner) is the idempotency key. +class EcgRawPacket { + final String hex; // full inner bytes, hex — identity + final String deviceId; + final int sequence; // inner[3..6] + final int strapSeconds; // inner[7..10], strap seconds + final int strapSubsec; // inner[11..12], 1/32768 s + final int capturedAt; // epoch ms we received it + + const EcgRawPacket({ + required this.hex, + required this.deviceId, + required this.sequence, + required this.strapSeconds, + required this.strapSubsec, + required this.capturedAt, + }); +} + /// Live, in-memory device state (not persisted; rebuilt each connection). class DeviceState { String? address; diff --git a/lib/ecg/ble_ecg_transport.dart b/lib/ecg/ble_ecg_transport.dart new file mode 100644 index 000000000..c49857e1a --- /dev/null +++ b/lib/ecg/ble_ecg_transport.dart @@ -0,0 +1,104 @@ +// EcgTransport over the real BleEngine — a 1:1 forwarder plus a broadcast +// stream fed by the engine's ECG event sink. + +import 'dart:async'; + +import '../ble/ble_engine.dart'; +import 'ecg_models.dart'; +import 'ecg_transport.dart'; + +class BleEngineEcgTransport implements EcgTransport { + final BleEngine engine; + final String? Function() serialOf; + final Future Function() onRequestSync; + final _events = StreamController.broadcast(); + + BleEngineEcgTransport({ + required this.engine, + required this.serialOf, + required this.onRequestSync, + }); + + /// Wire this to `BleEngine.onEcgEvent`. + void onEngineEvent(EcgEngineEvent e) { + _events.add(switch (e) { + EcgFrameEvent(:final r17, :final linkGeneration) => EcgTransportFrame( + r17, + linkGeneration, + ), + EcgMalformedR17Event(:final reason, :final linkGeneration) => + EcgTransportMalformed(linkGeneration, reason), + EcgLinkDownEvent(:final linkGeneration) => EcgTransportLinkDown( + linkGeneration, + ), + }); + } + + @override + bool get isReady => engine.isConnected; + + @override + bool get isMaverick => engine.isMaverick; + + @override + int get linkGeneration => engine.linkGeneration; + + @override + String? get serial => serialOf(); + + @override + Stream get events => _events.stream; + + @override + EcgLeaseHandle? acquire() { + final l = engine.ecgAcquire(); + return l == null ? null : EcgLeaseHandle(l, l.linkGeneration); + } + + @override + bool leaseValid(EcgLeaseHandle lease) => + engine.ecgLeaseValid(lease.token as EcgLease); + + @override + void release(EcgLeaseHandle lease) => + engine.ecgRelease(lease.token as EcgLease); + + @override + Future cancelHistory(EcgLeaseHandle lease) => + engine.ecgCancelHistory(lease.token as EcgLease); + + static EcgCommandListResult _wrap(List out) => + EcgCommandListResult([ + for (final o in out) + EcgMemberOutcome(o.label, written: o.written, succeeded: o.succeeded), + ]); + + @override + Future prepare( + EcgLeaseHandle lease, + EcgWrist wrist, + ) async => + _wrap(await engine.ecgPrepare(lease.token as EcgLease, wrist.selection)); + + @override + Future start(EcgLeaseHandle lease) async => + _wrap(await engine.ecgStart(lease.token as EcgLease)); + + @override + Future restart(EcgLeaseHandle lease) async => + _wrap(await engine.ecgRestart(lease.token as EcgLease)); + + @override + Future cleanup(EcgLeaseHandle lease) async => + _wrap(await engine.ecgCleanup(lease.token as EcgLease)); + + /// The cleanup triplet under the engine's READY recovery lease — for the + /// `onReadyEcgRecovery` hook only. + Future recoveryCleanup() async => + _wrap(await engine.ecgRecoveryCleanup()); + + @override + Future requestSync() => onRequestSync(); + + void dispose() => _events.close(); +} diff --git a/lib/ecg/ecg_controller.dart b/lib/ecg/ecg_controller.dart new file mode 100644 index 000000000..5a554a214 --- /dev/null +++ b/lib/ecg/ecg_controller.dart @@ -0,0 +1,557 @@ +// WHOOP MG ECG — the lifecycle owner of one reading. +// +// Owns: the transport lease, the PREPARE/START/RESTART/CLEANUP sequence +// through [EcgTransport], the durable may-be-active guard, the pure reducer +// ([reduceEcg]) fed with live R17, the accepted window, the live-preview +// ring, the capture timeout, cancel/pause/link-loss/parse-failure exits, the +// durable save (before "completed" is ever shown), cleanup on EVERY exit, +// and the ordinary history sync request that follows. +// +// Single-flight: every public entry bumps an epoch, every continuation after +// an await re-checks it, and there is exactly ONE cleanup per capture. +// Frames are gated on the lease's link generation, on `_armed`, and are +// dropped while a RESTART list is in flight. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'ecg_guard_store.dart'; +import 'ecg_models.dart'; +import 'ecg_policy.dart'; +import 'ecg_recovery.dart'; +import 'ecg_transport.dart'; +import 'ecg_waveform_buffer.dart'; + +enum EcgCapturePhase { + idle, + incompatible, + disconnected, + busy, + recovering, + preparing, + starting, + waiting, + active, + contactLost, + restarting, + saving, + cleaningUp, + completed, + unreadable, + inconclusiveRetry, + cancelled, + failed, +} + +/// What the capture screen renders. Immutable snapshot. +class EcgCaptureState { + final EcgCapturePhase phase; + final EcgWrist? wrist; + final int progress; + final int? liveHr; + final int quality; + final int interruptions; + + /// Why the phase is [EcgCapturePhase.busy] / [EcgCapturePhase.failed]. + final String? reason; + + /// The saved reading, once [EcgCapturePhase.completed]. + final String? readingId; + + /// The band's unreadable-reason mask for [EcgCapturePhase.unreadable]. + final int unreadableMask; + + /// True when a cleanup member failed: the durable guard is retained and + /// the next connection retries the cleanup triplet. + final bool cleanupIncomplete; + + const EcgCaptureState({ + this.phase = EcgCapturePhase.idle, + this.wrist, + this.progress = 0, + this.liveHr, + this.quality = 0, + this.interruptions = 0, + this.reason, + this.readingId, + this.unreadableMask = 0, + this.cleanupIncomplete = false, + }); + + EcgCaptureState copyWith({ + EcgCapturePhase? phase, + EcgWrist? wrist, + int? progress, + int? liveHr, + bool clearLiveHr = false, + int? quality, + int? interruptions, + String? reason, + String? readingId, + int? unreadableMask, + bool? cleanupIncomplete, + }) => EcgCaptureState( + phase: phase ?? this.phase, + wrist: wrist ?? this.wrist, + progress: progress ?? this.progress, + liveHr: clearLiveHr ? null : (liveHr ?? this.liveHr), + quality: quality ?? this.quality, + interruptions: interruptions ?? this.interruptions, + reason: reason ?? this.reason, + readingId: readingId ?? this.readingId, + unreadableMask: unreadableMask ?? this.unreadableMask, + cleanupIncomplete: cleanupIncomplete ?? this.cleanupIncomplete, + ); + + /// The phases in which the band may be generating: from the first ON + /// write until cleanup finished. + bool get capturing => switch (phase) { + EcgCapturePhase.recovering || + EcgCapturePhase.preparing || + EcgCapturePhase.starting || + EcgCapturePhase.waiting || + EcgCapturePhase.active || + EcgCapturePhase.contactLost || + EcgCapturePhase.restarting || + EcgCapturePhase.saving || + EcgCapturePhase.cleaningUp => true, + _ => false, + }; +} + +typedef EcgSave = + Future Function(EcgReading reading, List packets); + +class EcgController extends ChangeNotifier { + final EcgTransport transport; + final EcgGuardStore guard; + final EcgSave save; + + /// A reason the app is busy with another live feature (workout, breathing + /// session), or null when ECG may start. + final String? Function() busyReason; + final Future Function(String owner) holdScreen; + final Future Function(String owner) releaseScreen; + final void Function(String) log; + final Duration captureTimeout; + final int Function() nowMs; + + static const String screenOwner = 'ecg'; + + EcgController({ + required this.transport, + required this.guard, + required this.save, + required this.busyReason, + required this.holdScreen, + required this.releaseScreen, + void Function(String)? log, + this.captureTimeout = const Duration(seconds: 120), + int Function()? nowMs, + }) : log = log ?? ((_) {}), + nowMs = nowMs ?? (() => DateTime.now().millisecondsSinceEpoch); + + EcgCaptureState _state = const EcgCaptureState(); + EcgCaptureState get state => _state; + + /// The live-preview ring (RAM only) and its repaint coalescer. + final EcgWaveformBuffer live = EcgWaveformBuffer(); + final EcgPreviewScheduler preview = EcgPreviewScheduler(); + + int _epoch = 0; + EcgLeaseHandle? _lease; + int _gen = -1; + String? _serial; + EcgWrist? _wrist; + bool _armed = false; + bool _restartInFlight = false; + bool _cleanupDone = false; + bool _screenHeld = false; + int _retriesUsed = 0; + int? _windowStartMs; + EcgReducerState _reducer = const EcgReducerState.initial(); + StreamSubscription? _sub; + Timer? _timer; + + bool get isCapturing => _lease != null; + + @visibleForTesting + EcgReducerState get reducerState => _reducer; + + void _set(EcgCaptureState s) { + _state = s; + notifyListeners(); + } + + bool _stale(int epoch) => _epoch != epoch || _lease == null; + + /// Start a reading on [wrist]. Every precondition failure lands in a + /// terminal phase with a reason; nothing is written to the band before + /// the durable guard is acknowledged. + Future begin(EcgWrist wrist) async { + if (_lease != null) return; // single-flight + final epoch = ++_epoch; + live.clear(); + preview.markDirty(); + if (!transport.isReady) { + _set(EcgCaptureState(phase: EcgCapturePhase.disconnected, wrist: wrist)); + return; + } + if (!transport.isMaverick) { + _set(EcgCaptureState(phase: EcgCapturePhase.incompatible, wrist: wrist)); + return; + } + final busy = busyReason(); + if (busy != null) { + _set( + EcgCaptureState( + phase: EcgCapturePhase.busy, + wrist: wrist, + reason: busy, + ), + ); + return; + } + final serial = transport.serial; + if (serial == null || serial.isEmpty) { + _set( + EcgCaptureState( + phase: EcgCapturePhase.failed, + wrist: wrist, + reason: 'no_serial', + ), + ); + return; + } + final lease = transport.acquire(); + if (lease == null) { + _set( + EcgCaptureState( + phase: EcgCapturePhase.busy, + wrist: wrist, + reason: 'transport', + ), + ); + return; + } + _lease = lease; + _gen = lease.linkGeneration; + _serial = serial; + _wrist = wrist; + _cleanupDone = false; + _armed = false; + _restartInFlight = false; + _windowStartMs = null; + final retries = _retriesUsed; + _retriesUsed = 0; + _reducer = EcgReducerState.initial(retriesUsed: retries); + _set(EcgCaptureState(phase: EcgCapturePhase.preparing, wrist: wrist)); + try { + await guard.setWrist(serial, wrist); + if (await guard.isActive(serial)) { + _set(_state.copyWith(phase: EcgCapturePhase.recovering)); + final r = await ecgRecoverRetainedGuard( + guard: guard, + serial: serial, + cleanup: () => transport.cleanup(lease), + log: log, + ); + if (_stale(epoch)) return; + if (r == EcgRecoveryOutcome.retained) { + await _finish(epoch, EcgCapturePhase.failed, reason: 'recovery'); + return; + } + _set(_state.copyWith(phase: EcgCapturePhase.preparing)); + } + await transport.cancelHistory(lease); + if (_stale(epoch)) return; + // The durable guard goes down BEFORE the first ON write and is + // acknowledged; a write that did not land does not get a band enabled. + if (!await guard.setActive(serial)) { + await _finish(epoch, EcgCapturePhase.failed, reason: 'guard'); + return; + } + if (_stale(epoch)) return; + // Subscribe BEFORE any generation write: the first post-START packet + // can arrive at the write/response boundary. + _sub ??= transport.events.listen(_onEvent); + final prep = await transport.prepare(lease, wrist); + if (_stale(epoch)) return; + if (!prep.allSucceeded) { + log('[ECG] PREPARE not accepted: $prep'); + await _finish(epoch, EcgCapturePhase.failed, reason: 'prepare'); + return; + } + _armed = true; + _set(_state.copyWith(phase: EcgCapturePhase.starting)); + _screenHeld = true; + await holdScreen(screenOwner); + final st = await transport.start(lease); + if (_stale(epoch)) return; + if (!st.allSucceeded) { + log('[ECG] START not accepted: $st'); + _armed = false; + await _finish(epoch, EcgCapturePhase.failed, reason: 'start'); + return; + } + _timer = Timer(captureTimeout, () { + unawaited(_finish(epoch, EcgCapturePhase.failed, reason: 'timeout')); + }); + if (_state.phase == EcgCapturePhase.starting) { + _set(_state.copyWith(phase: EcgCapturePhase.waiting)); + } + } catch (e, st) { + log('[ECG] begin failed: $e\n$st'); + if (!_stale(epoch)) { + await _finish(epoch, EcgCapturePhase.failed, reason: 'error'); + } + } + } + + /// After a first-attempt inconclusive result: one more reading, with the + /// retry budget spent so a second inconclusive is final. + Future retry() async { + if (_lease != null || _state.phase != EcgCapturePhase.inconclusiveRetry) { + return; + } + final wrist = _wrist; + if (wrist == null) return; + _retriesUsed = 1; + await begin(wrist); + } + + /// Back / explicit cancel. Cleans up; the band stops generating. + Future cancel() async { + if (_lease == null) return; + final epoch = ++_epoch; + await _finish(epoch, EcgCapturePhase.cancelled, reason: 'cancelled'); + } + + /// The app went to the background mid-capture — same as cancel (the + /// official screen stops on ON_PAUSE too). + Future onAppPaused() async { + if (_lease == null) return; + final epoch = ++_epoch; + await _finish(epoch, EcgCapturePhase.cancelled, reason: 'paused'); + } + + /// Awaited teardown for the owner (AppState shutdown, tests). + Future shutdown() async { + await cancel(); + await _sub?.cancel(); + _sub = null; + } + + @override + void dispose() { + _timer?.cancel(); + _sub?.cancel(); + super.dispose(); + } + + void _onEvent(EcgTransportEvent e) { + if (e.linkGeneration != _gen || _lease == null) return; + final epoch = _epoch; + switch (e) { + case EcgTransportLinkDown(): + unawaited( + _finish(epoch, EcgCapturePhase.failed, reason: 'disconnected'), + ); + case EcgTransportMalformed(): + if (_armed) { + unawaited( + _finish(epoch, EcgCapturePhase.failed, reason: 'malformed'), + ); + } + case EcgTransportFrame(): + _onFrame(epoch, e); + } + } + + void _onFrame(int epoch, EcgTransportFrame e) { + if (!_armed || _restartInFlight) return; + final r17 = e.r17; + // The live preview shows real samples from the moment the pipeline is + // armed (zeros before contact); only the accepted window is ever saved. + live.push(r17.samples); + preview.markDirty(); + final before = _reducer; + final step = reduceEcg(before, r17); + _reducer = step.state; + if (before.accepted.isEmpty && step.state.accepted.isNotEmpty) { + _windowStartMs = nowMs(); + } + var next = _state.copyWith( + progress: r17.progress == 255 ? _state.progress : r17.progress, + liveHr: r17.liveHr > 0 ? r17.liveHr : null, + clearLiveHr: r17.liveHr == 0, + quality: r17.quality, + interruptions: step.state.interruptions, + ); + switch (step.state.phase) { + case EcgPhase.waiting: + if (_state.phase != EcgCapturePhase.starting) { + next = next.copyWith(phase: EcgCapturePhase.waiting); + } + case EcgPhase.active: + next = next.copyWith(phase: EcgCapturePhase.active); + case EcgPhase.contactLost: + next = next.copyWith(phase: EcgCapturePhase.contactLost); + case EcgPhase.done: + break; + } + _set(next); + for (final effect in step.effects) { + switch (effect) { + case EcgAppend(): + case EcgAppendPlaceholder(): + case EcgClear(): + break; + case EcgSendRestart(): + unawaited(_restart(epoch)); + case EcgFail(:final reason): + unawaited(_finish(epoch, EcgCapturePhase.failed, reason: reason)); + case EcgTerminal(:final outcome): + unawaited(_handleTerminal(epoch, outcome)); + } + } + } + + Future _restart(int epoch) async { + final lease = _lease; + if (lease == null || _restartInFlight) return; + _restartInFlight = true; + _set(_state.copyWith(phase: EcgCapturePhase.restarting)); + final res = await transport.restart(lease); + if (_stale(epoch) || !identical(_lease, lease)) return; + if (!res.allSucceeded) { + log('[ECG] RESTART not accepted: $res'); + await _finish(epoch, EcgCapturePhase.failed, reason: 'restart'); + return; + } + _restartInFlight = false; + _set(_state.copyWith(phase: EcgCapturePhase.active)); + } + + Future _handleTerminal(int epoch, EcgTerminalOutcome outcome) async { + _armed = false; + _timer?.cancel(); + switch (outcome.kind) { + case EcgTerminalKind.unreadable: + await _finish( + epoch, + EcgCapturePhase.unreadable, + unreadableMask: outcome.unreadableMask, + ); + case EcgTerminalKind.inconclusiveOfferRetry: + await _finish(epoch, EcgCapturePhase.inconclusiveRetry); + case EcgTerminalKind.completed: + case EcgTerminalKind.inconclusiveFinal: + _set(_state.copyWith(phase: EcgCapturePhase.saving)); + final packets = List.from(_reducer.accepted); + final reading = _buildReading(outcome, packets); + try { + await save(reading, packets); + } catch (e) { + log('[ECG] save failed: $e'); + if (_stale(epoch)) return; + await _finish(epoch, EcgCapturePhase.failed, reason: 'save'); + return; + } + if (_stale(epoch)) return; + await _finish(epoch, EcgCapturePhase.completed, readingId: reading.id); + // The band saved raw R16 under raw-save ON; ordinary incremental + // history brings it back through the normal safe path. + try { + await transport.requestSync(); + } catch (e) { + log('[ECG] post-reading sync request failed: $e'); + } + } + } + + EcgReading _buildReading( + EcgTerminalOutcome outcome, + List packets, + ) { + final now = nowMs(); + final startMs = _windowStartMs ?? now; + final stats = EcgWindowStats.of(packets); + final t = outcome.terminal; + return EcgReading( + id: ecgReadingId(startEpochMs: startMs, terminalStrapS: t.strapSeconds), + deviceId: '', + wrist: _wrist ?? EcgWrist.right, + startTs: startMs ~/ 1000, + endTs: now ~/ 1000, + strapTerminalTs: t.strapSeconds, + strapTerminalSubsec: t.subseconds, + resultCode: t.result, + category: outcome.persistedCategory, + avgHr: t.averageHr > 0 ? t.averageHr : null, + quality: t.quality, + unreadableMask: t.unreadable.raw, + interruptions: _reducer.interruptions, + sampleCount: stats.sampleCount, + minUv: stats.minUv, + maxUv: stats.maxUv, + rmsUv: stats.rmsUv, + missingSegments: stats.missingSegments, + status: outcome.kind == EcgTerminalKind.inconclusiveFinal + ? EcgReadingStatus.inconclusive + : EcgReadingStatus.completed, + notes: null, + createdAt: now, + ); + } + + /// THE one exit path. Idempotent per capture: cleanup runs once, the + /// screen hold and the lease are released, and the final phase is set + /// only after cleanup so a "completed" never precedes a stopped band. + Future _finish( + int epoch, + EcgCapturePhase phase, { + String? reason, + String? readingId, + int? unreadableMask, + }) async { + final lease = _lease; + if (lease == null || _epoch != epoch) return; + _armed = false; + _restartInFlight = false; + _timer?.cancel(); + _timer = null; + var incomplete = false; + if (!_cleanupDone) { + _cleanupDone = true; + _set(_state.copyWith(phase: EcgCapturePhase.cleaningUp, reason: reason)); + final res = await transport.cleanup(lease); + final serial = _serial; + if (res.allSucceeded && serial != null) { + if (!await guard.clear(serial)) { + log('[ECG] guard clear was not acknowledged — retained.'); + incomplete = true; + } + } else { + log('[ECG] cleanup incomplete ($res) — guard retained.'); + incomplete = true; + } + } + if (_screenHeld) { + _screenHeld = false; + await releaseScreen(screenOwner); + } + transport.release(lease); + if (identical(_lease, lease)) _lease = null; + _set( + _state.copyWith( + phase: phase, + reason: reason, + readingId: readingId, + unreadableMask: unreadableMask, + cleanupIncomplete: incomplete, + ), + ); + } +} diff --git a/lib/ecg/ecg_guard_store.dart b/lib/ecg/ecg_guard_store.dart new file mode 100644 index 000000000..3fca1e5ae --- /dev/null +++ b/lib/ecg/ecg_guard_store.dart @@ -0,0 +1,120 @@ +// The durable per-band ECG facts, keyed by band serial: +// +// • the MAY-BE-ACTIVE GUARD — set (and acknowledged) before every Labrador +// ON/START write, cleared only after all three cleanup responses +// succeeded. Physical MG firmware keeps generation, raw-save and the live +// filtered publisher running through process death and disconnect until a +// client sends the cleanup triplet, so this must survive the process. +// • the wrist the user wears this band on; +// • whether this serial was ever positively identified as a WHOOP MG. +// +// SharedPreferences through the awaited API (not the fire-and-forget Prefs +// façade): a guard write that did not land must be reported, because the +// caller refuses to enable the band on a guard it cannot trust. + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'ecg_models.dart'; + +abstract class EcgGuardStore { + Future isActive(String serial); + + /// Returns whether the write was acknowledged durable. + Future setActive(String serial); + Future clear(String serial); + + Future wrist(String serial); + Future setWrist(String serial, EcgWrist wrist); + + Future isRememberedMaverick(String serial); + Future rememberMaverick(String serial); +} + +class PrefsEcgGuardStore implements EcgGuardStore { + static String guardKey(String serial) => 'ecg.guard.$serial'; + static String wristKey(String serial) => 'ecg.wrist.$serial'; + static String maverickKey(String serial) => 'ecg.maverick.$serial'; + + Future get _p => SharedPreferences.getInstance(); + + @override + Future isActive(String serial) async => + (await _p).getBool(guardKey(serial)) ?? false; + + @override + Future setActive(String serial) async { + try { + return await (await _p).setBool(guardKey(serial), true); + } catch (_) { + return false; + } + } + + @override + Future clear(String serial) async { + try { + return await (await _p).setBool(guardKey(serial), false); + } catch (_) { + return false; + } + } + + @override + Future wrist(String serial) async => + EcgWrist.parse((await _p).getString(wristKey(serial))); + + @override + Future setWrist(String serial, EcgWrist wrist) async => + (await _p).setString(wristKey(serial), wrist.name); + + @override + Future isRememberedMaverick(String serial) async => + (await _p).getBool(maverickKey(serial)) ?? false; + + @override + Future rememberMaverick(String serial) async => + (await _p).setBool(maverickKey(serial), true); +} + +/// In-memory store for tests. [failWrites] makes every guard write report +/// not-acknowledged. +class MemoryEcgGuardStore implements EcgGuardStore { + final Set active = {}; + final Map wrists = {}; + final Set maverick = {}; + bool failWrites = false; + final List log = []; + + @override + Future isActive(String serial) async => active.contains(serial); + + @override + Future setActive(String serial) async { + log.add('set:$serial'); + if (failWrites) return false; + active.add(serial); + return true; + } + + @override + Future clear(String serial) async { + log.add('clear:$serial'); + if (failWrites) return false; + active.remove(serial); + return true; + } + + @override + Future wrist(String serial) async => wrists[serial]; + + @override + Future setWrist(String serial, EcgWrist wrist) async => + wrists[serial] = wrist; + + @override + Future isRememberedMaverick(String serial) async => + maverick.contains(serial); + + @override + Future rememberMaverick(String serial) async => maverick.add(serial); +} diff --git a/lib/ecg/ecg_models.dart b/lib/ecg/ecg_models.dart new file mode 100644 index 000000000..b31619d77 --- /dev/null +++ b/lib/ecg/ecg_models.dart @@ -0,0 +1,358 @@ +// WHOOP MG ECG — domain models shared by the reducer, the controller, the +// store and the UI. Pure Dart. +// +// Everything here is the BAND'S result. The category comes from the band's +// HeartKey result code plus a heart rate through the official app's fixed +// mapping; nothing on the phone classifies the waveform, and no anatomical +// lead or polarity is claimed for the samples. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Which wrist the band is worn on — the official opcode-123 selector. +enum EcgWrist { + left, + right; + + WristSelection get selection => + this == EcgWrist.left ? WristSelection.left : WristSelection.right; + + static EcgWrist? parse(String? s) => switch (s) { + 'left' => EcgWrist.left, + 'right' => EcgWrist.right, + _ => null, + }; +} + +/// The official app's user-facing categories, from HeartKey result code plus +/// a heart rate (docs/mg/05 §5). Always presented as "band-reported". +enum EcgCategory { + unreadable, + sinusRhythm, + lowHeartRate, + possibleAfib, + afibHighHeartRate, + highHeartRate, + highHeartRateNoAfib, + inconclusive; + + static EcgCategory? parse(String? s) { + for (final c in values) { + if (c.name == s) return c; + } + return null; + } +} + +/// The exact result-plus-HR table. Unknown codes and known codes outside +/// their accepted HR range fall back to unreadable, like the official app. +EcgCategory categoryFor(int result, int hr) { + switch (result) { + case 0: + case 2: + return EcgCategory.unreadable; + case 1: + return (hr >= 51 && hr <= 99) + ? EcgCategory.sinusRhythm + : EcgCategory.unreadable; + case 3: + return hr <= 50 ? EcgCategory.lowHeartRate : EcgCategory.unreadable; + case 4: + if (hr >= 51 && hr <= 99) return EcgCategory.possibleAfib; + if (hr >= 100 && hr <= 150) return EcgCategory.afibHighHeartRate; + if (hr >= 151 && hr <= 200) return EcgCategory.highHeartRate; + return EcgCategory.unreadable; + case 5: + if (hr >= 100 && hr <= 150) return EcgCategory.highHeartRateNoAfib; + if (hr >= 151 && hr <= 200) return EcgCategory.highHeartRate; + return EcgCategory.unreadable; + case 6: + return EcgCategory.inconclusive; + default: + return EcgCategory.unreadable; + } +} + +/// One entry of the accepted window: an accepted R17 packet, or the ONE +/// empty placeholder the official accumulator inserts at a sequence jump. +class EcgAcceptedPacket { + final int sequence; + final int strapSeconds; + final int strapSubsec; + final Int16List samples; + final Uint8List inner; + final bool placeholder; + + const EcgAcceptedPacket({ + required this.sequence, + required this.strapSeconds, + required this.strapSubsec, + required this.samples, + required this.inner, + this.placeholder = false, + }); + + factory EcgAcceptedPacket.of(LabradorR17 r) => EcgAcceptedPacket( + sequence: r.sequence, + strapSeconds: r.strapSeconds, + strapSubsec: r.subseconds, + samples: r.samples, + inner: r.inner, + ); + + factory EcgAcceptedPacket.placeholder(int sequence) => EcgAcceptedPacket( + sequence: sequence, + strapSeconds: 0, + strapSubsec: 0, + samples: Int16List(0), + inner: Uint8List(0), + placeholder: true, + ); +} + +/// Persisted status of a reading. Unreadable and first-attempt-inconclusive +/// terminals are not persisted (official behaviour); a retried inconclusive +/// is. +enum EcgReadingStatus { + completed, + inconclusive; + + static EcgReadingStatus? parse(String? s) { + for (final v in values) { + if (v.name == s) return v; + } + return null; + } +} + +/// Sample statistics over the accepted window (placeholders contribute +/// nothing but a missing-segment count). +class EcgWindowStats { + final int sampleCount; + final int? minUv; + final int? maxUv; + final double? rmsUv; + final int missingSegments; + + const EcgWindowStats({ + required this.sampleCount, + required this.minUv, + required this.maxUv, + required this.rmsUv, + required this.missingSegments, + }); + + static EcgWindowStats of(List packets) { + var n = 0; + var missing = 0; + int? lo; + int? hi; + var sumSq = 0.0; + for (final p in packets) { + if (p.placeholder) { + missing++; + continue; + } + for (final s in p.samples) { + n++; + lo = lo == null ? s : math.min(lo, s); + hi = hi == null ? s : math.max(hi, s); + sumSq += s * s; + } + } + return EcgWindowStats( + sampleCount: n, + minUv: lo, + maxUv: hi, + rmsUv: n == 0 ? null : math.sqrt(sumSq / n), + missingSegments: missing, + ); + } +} + +/// The unit every stored sample carries: 100 Hz filtered/decimated +/// input-referred integer microvolts, exactly as the band sends them. +const String kEcgSampleUnit = 'filtered_input_referred_uv'; +const int kEcgSampleRateHz = 100; +const String kEcgSource = 'mg_labrador'; + +/// One saved reading — `ecg_reading` as a typed row. +class EcgReading { + final String id; + final String deviceId; + final EcgWrist wrist; + final int startTs; // epoch seconds + final int endTs; // epoch seconds + final int? strapTerminalTs; // strap seconds + final int? strapTerminalSubsec; + final int resultCode; + final EcgCategory category; + final int? avgHr; + final int? quality; + final int unreadableMask; + final int interruptions; + final int sampleCount; + final int? minUv; + final int? maxUv; + final double? rmsUv; + final int missingSegments; + final EcgReadingStatus status; + final String? notes; + final int createdAt; // epoch ms + + const EcgReading({ + required this.id, + required this.deviceId, + required this.wrist, + required this.startTs, + required this.endTs, + required this.strapTerminalTs, + required this.strapTerminalSubsec, + required this.resultCode, + required this.category, + required this.avgHr, + required this.quality, + required this.unreadableMask, + required this.interruptions, + required this.sampleCount, + required this.minUv, + required this.maxUv, + required this.rmsUv, + required this.missingSegments, + required this.status, + required this.notes, + required this.createdAt, + }); + + int get durationS => endTs - startTs; + + List get unreadableReasons => + LabradorUnreadableMask(unreadableMask).reasons; + + Map toRow() => { + 'id': id, + 'device_id': deviceId, + 'source': kEcgSource, + 'wrist': wrist.name, + 'start_ts': startTs, + 'end_ts': endTs, + 'strap_terminal_ts': strapTerminalTs, + 'strap_terminal_subsec': strapTerminalSubsec, + 'result_code': resultCode, + 'category': category.name, + 'avg_hr': avgHr, + 'quality': quality, + 'unreadable_mask': unreadableMask, + 'interruptions': interruptions, + 'sample_rate_hz': kEcgSampleRateHz, + 'sample_unit': kEcgSampleUnit, + 'sample_count': sampleCount, + 'min_uv': minUv, + 'max_uv': maxUv, + 'rms_uv': rmsUv, + 'missing_segments': missingSegments, + 'status': status.name, + 'notes': notes, + 'created_at': createdAt, + }; + + static EcgReading? fromRow(Map r) { + final wrist = EcgWrist.parse(r['wrist'] as String?); + final category = EcgCategory.parse(r['category'] as String?); + final status = EcgReadingStatus.parse(r['status'] as String?); + final id = r['id'] as String?; + if (id == null || wrist == null || category == null || status == null) { + return null; + } + int? i(String k) => (r[k] as num?)?.toInt(); + return EcgReading( + id: id, + deviceId: (r['device_id'] as String?) ?? '', + wrist: wrist, + startTs: i('start_ts') ?? 0, + endTs: i('end_ts') ?? 0, + strapTerminalTs: i('strap_terminal_ts'), + strapTerminalSubsec: i('strap_terminal_subsec'), + resultCode: i('result_code') ?? 0, + category: category, + avgHr: i('avg_hr'), + quality: i('quality'), + unreadableMask: i('unreadable_mask') ?? 0, + interruptions: i('interruptions') ?? 0, + sampleCount: i('sample_count') ?? 0, + minUv: i('min_uv'), + maxUv: i('max_uv'), + rmsUv: (r['rms_uv'] as num?)?.toDouble(), + missingSegments: i('missing_segments') ?? 0, + status: status, + notes: r['notes'] as String?, + createdAt: i('created_at') ?? 0, + ); + } +} + +/// `ecg_reading_packet` row codec. Samples are the exact signed-i16-LE bytes. +class EcgPacketCodec { + static Uint8List encodeSamples(Int16List samples) { + final out = Uint8List(samples.length * 2); + final bd = ByteData.sublistView(out); + for (var i = 0; i < samples.length; i++) { + bd.setInt16(2 * i, samples[i], Endian.little); + } + return out; + } + + static Int16List decodeSamples(Uint8List bytes) { + final n = bytes.length ~/ 2; + final out = Int16List(n); + final bd = ByteData.sublistView(bytes); + for (var i = 0; i < n; i++) { + out[i] = bd.getInt16(2 * i, Endian.little); + } + return out; + } + + static String hex(Uint8List b) { + final sb = StringBuffer(); + for (final x in b) { + sb.write(x.toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); + } + + static Map toRow(EcgAcceptedPacket p) => { + 'sequence': p.sequence, + 'strap_seconds': p.placeholder ? null : p.strapSeconds, + 'strap_subsec': p.placeholder ? null : p.strapSubsec, + 'sample_count': p.samples.length, + 'samples': encodeSamples(p.samples), + 'inner_hex': hex(p.inner), + 'is_placeholder': p.placeholder ? 1 : 0, + }; + + static EcgAcceptedPacket fromRow(Map r) { + final placeholder = ((r['is_placeholder'] as num?)?.toInt() ?? 0) != 0; + final raw = r['samples']; + final bytes = raw is Uint8List + ? raw + : raw is List + ? Uint8List.fromList(raw) + : Uint8List(0); + return EcgAcceptedPacket( + sequence: (r['sequence'] as num?)?.toInt() ?? 0, + strapSeconds: (r['strap_seconds'] as num?)?.toInt() ?? 0, + strapSubsec: (r['strap_subsec'] as num?)?.toInt() ?? 0, + samples: decodeSamples(bytes), + inner: hexToBytes((r['inner_hex'] as String?) ?? ''), + placeholder: placeholder, + ); + } +} + +/// The reading id: text, derived from when the accepted window opened and +/// the terminal strap second, so two devices' exports cannot collide on an +/// autoincrement. +String ecgReadingId({required int startEpochMs, required int terminalStrapS}) => + 'ecg_${startEpochMs}_$terminalStrapS'; diff --git a/lib/ecg/ecg_policy.dart b/lib/ecg/ecg_policy.dart new file mode 100644 index 000000000..c953e0035 --- /dev/null +++ b/lib/ecg/ecg_policy.dart @@ -0,0 +1,301 @@ +// WHOOP MG ECG — the official foreground R17 state machine as a PURE reducer +// (docs/mg/05 §4, docs/mg/06 §6). No I/O, no clock, no BLE: one packet in, +// a new state plus a list of effects out. The controller performs the +// effects (restart command, persistence, cleanup); the UI renders the state. +// +// Transport frames are not the reading. Entering ECG produces zero / +// progress-zero packets for tens of seconds before the fingers touch; the +// accepted window starts at the first presence-positive, positive, non-255 +// progress packet, clears on contact loss / progress regression, and ends at +// the first terminal packet. The frozen official capture is the oracle: 86 +// transport frames → 30 accepted → 3,000 samples. + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import 'ecg_models.dart'; + +enum EcgPhase { waiting, active, contactLost, done } + +/// The reducer's whole memory. Immutable; every step returns a new one. +class EcgReducerState { + final EcgPhase phase; + + /// The accepted window so far, placeholders included, in order. + final List accepted; + + /// The last ACCEPTED packet (progress regression and sequence gaps are + /// judged against it). Null after every clear — like the official + /// accumulator, a window that restarts has no previous packet. + final LabradorR17? previous; + + /// ACTIVE → CONTACT_LOST transitions so far. Once per transition, never per + /// bad packet; a later loss while already lost does not count. + final int interruptions; + + /// Inconclusive retries already offered and taken (0 or 1). + final int retriesUsed; + + const EcgReducerState({ + required this.phase, + required this.accepted, + required this.previous, + required this.interruptions, + required this.retriesUsed, + }); + + /// A fresh WAITING state. [retriesUsed] is 1 on the single inconclusive + /// retry, which is what makes a second inconclusive terminal final. + const EcgReducerState.initial({int retriesUsed = 0}) + : this( + phase: EcgPhase.waiting, + accepted: const [], + previous: null, + interruptions: 0, + retriesUsed: retriesUsed, + ); + + EcgReducerState _with({ + EcgPhase? phase, + List? accepted, + LabradorR17? previous, + bool clearPrevious = false, + int? interruptions, + }) => EcgReducerState( + phase: phase ?? this.phase, + accepted: accepted ?? this.accepted, + previous: clearPrevious ? null : (previous ?? this.previous), + interruptions: interruptions ?? this.interruptions, + retriesUsed: retriesUsed, + ); +} + +/// What the controller must do after a step, in order. +sealed class EcgEffect { + const EcgEffect(); +} + +/// The packet was appended to the accepted window. +class EcgAppend extends EcgEffect { + final LabradorR17 packet; + const EcgAppend(this.packet); +} + +/// One empty segment was inserted at [sequence] (previous + 1) before the +/// packet that jumped — never one per missing sequence. +class EcgAppendPlaceholder extends EcgEffect { + final int sequence; + const EcgAppendPlaceholder(this.sequence); +} + +/// The accepted window was cleared. +class EcgClear extends EcgEffect { + const EcgClear(); +} + +/// Send the explicit RESTART list (opcode 20, then 124 body 01 03). Only the +/// exact predicate reaches this: active, presence, positive nondecreasing +/// nonterminal progress, current-S2-state-1 flag clear. +class EcgSendRestart extends EcgEffect { + const EcgSendRestart(); +} + +/// The reading failed; the window is gone. +class EcgFail extends EcgEffect { + final String reason; + const EcgFail(this.reason); +} + +enum EcgTerminalKind { + /// A category worth persisting (anything but unreadable / inconclusive- + /// with-a-retry-available). + completed, + + /// Band says unreadable; the mask says why. Not persisted. + unreadable, + + /// Inconclusive on the first attempt: offer ONE retry. Not persisted. + inconclusiveOfferRetry, + + /// Inconclusive on the retry: persisted as inconclusive. + inconclusiveFinal, +} + +/// The terminal packet's verdict. [liveCategory] used live HR (offset 20) — +/// what the state machine branches on; [persistedCategory] used the final +/// average HR (offset 19) — what a saved reading carries. +class EcgTerminalOutcome { + final EcgTerminalKind kind; + final EcgCategory liveCategory; + final EcgCategory persistedCategory; + final LabradorR17 terminal; + const EcgTerminalOutcome({ + required this.kind, + required this.liveCategory, + required this.persistedCategory, + required this.terminal, + }); + + int get resultCode => terminal.result; + int get averageHr => terminal.averageHr; + int get liveHr => terminal.liveHr; + int get unreadableMask => terminal.unreadable.raw; + int get quality => terminal.quality; +} + +/// The reading reached a terminal packet. +class EcgTerminal extends EcgEffect { + final EcgTerminalOutcome outcome; + const EcgTerminal(this.outcome); +} + +class EcgReducerStep { + final EcgReducerState state; + final List effects; + const EcgReducerStep(this.state, this.effects); +} + +bool _acceptableStart(LabradorR17 f) => + f.presence && f.progress > 0 && f.progress != 255; + +/// Append [f] to [s]'s window, inserting the single official placeholder on +/// a sequence jump. Returns the new packet list and the effects to report. +(List, List) _append( + EcgReducerState s, + LabradorR17 f, +) { + final out = List.from(s.accepted); + final effects = []; + final prev = s.previous; + if (prev != null && f.sequence != prev.sequence + 1) { + out.add(EcgAcceptedPacket.placeholder(prev.sequence + 1)); + effects.add(EcgAppendPlaceholder(prev.sequence + 1)); + } + out.add(EcgAcceptedPacket.of(f)); + effects.add(EcgAppend(f)); + return (out, effects); +} + +/// One R17 packet through the official state machine. +EcgReducerStep reduceEcg(EcgReducerState s, LabradorR17 f) { + switch (s.phase) { + case EcgPhase.done: + // A repeated terminal (the band re-sends it) is not part of the result. + return EcgReducerStep(s, const []); + + case EcgPhase.waiting: + if (!_acceptableStart(f)) return EcgReducerStep(s, const []); + final (accepted, effects) = _append(s._with(accepted: const []), f); + return EcgReducerStep( + s._with(phase: EcgPhase.active, accepted: accepted, previous: f), + [const EcgClear(), ...effects], + ); + + case EcgPhase.active: + final prev = s.previous; + final lost = + !f.presence || + f.progress == 0 || + (prev != null && f.progress < prev.progress); + if (lost) { + return EcgReducerStep( + s._with( + phase: EcgPhase.contactLost, + accepted: const [], + clearPrevious: true, + interruptions: s.interruptions + 1, + ), + const [EcgClear()], + ); + } + if (f.isTerminal) return _terminal(s, f); + if (f.isInvalid) { + return EcgReducerStep( + s._with( + phase: EcgPhase.done, + accepted: const [], + clearPrevious: true, + ), + const [EcgClear(), EcgFail('progress_255')], + ); + } + if (f.flags.currentS2One) { + final (accepted, effects) = _append(s, f); + return EcgReducerStep( + s._with(accepted: accepted, previous: f), + effects, + ); + } + // Valid, nondecreasing, nonterminal, presence set, S2-state-1 flag + // clear: the distinct explicit-RESTART branch. Only the unfinished + // window is discarded; interruptions and the retry budget stay. + return EcgReducerStep( + s._with(accepted: const [], clearPrevious: true), + const [EcgClear(), EcgSendRestart()], + ); + + case EcgPhase.contactLost: + if (_acceptableStart(f)) { + final (accepted, effects) = _append(s, f); + return EcgReducerStep( + s._with(phase: EcgPhase.active, accepted: accepted, previous: f), + effects, + ); + } + if (f.isInvalid || s.interruptions >= 3) { + return EcgReducerStep( + s._with( + phase: EcgPhase.done, + accepted: const [], + clearPrevious: true, + ), + [ + const EcgClear(), + EcgFail(f.isInvalid ? 'progress_255' : 'interruptions'), + ], + ); + } + return EcgReducerStep(s, const []); + } +} + +EcgReducerStep _terminal(EcgReducerState s, LabradorR17 f) { + final live = categoryFor(f.result, f.liveHr); + final persisted = categoryFor(f.result, f.averageHr); + EcgReducerStep finish( + EcgTerminalKind kind, + List accepted, + List pre, + ) { + return EcgReducerStep( + s._with(phase: EcgPhase.done, accepted: accepted, previous: f), + [ + ...pre, + EcgTerminal( + EcgTerminalOutcome( + kind: kind, + liveCategory: live, + persistedCategory: persisted, + terminal: f, + ), + ), + ], + ); + } + + if (live == EcgCategory.unreadable) { + return finish(EcgTerminalKind.unreadable, const [], const [EcgClear()]); + } + if (live == EcgCategory.inconclusive && s.retriesUsed == 0) { + return finish(EcgTerminalKind.inconclusiveOfferRetry, const [], const [ + EcgClear(), + ]); + } + final (accepted, effects) = _append(s, f); + return finish( + live == EcgCategory.inconclusive + ? EcgTerminalKind.inconclusiveFinal + : EcgTerminalKind.completed, + accepted, + effects, + ); +} diff --git a/lib/ecg/ecg_recovery.dart b/lib/ecg/ecg_recovery.dart new file mode 100644 index 000000000..39384b270 --- /dev/null +++ b/lib/ecg/ecg_recovery.dart @@ -0,0 +1,47 @@ +// Retained-guard recovery, controller-free so the headless background +// drainer can run it too. Physical MG firmware keeps generation, raw-save +// and the live filtered publisher active through process death until a +// client sends the cleanup triplet — so on the next READY, before history or +// another reading, a retained guard gets the transport first. + +import 'ecg_guard_store.dart'; +import 'ecg_transport.dart'; + +enum EcgRecoveryOutcome { + /// No guard for this band; nothing to do. + noGuard, + + /// All three cleanup members succeeded; the guard was cleared. + cleared, + + /// A cleanup member failed (or the guard write did not land); the guard + /// stays set and the next READY tries again. + retained, + + /// No serial to look the guard up under. + noSerial, +} + +Future ecgRecoverRetainedGuard({ + required EcgGuardStore guard, + required String? serial, + required Future Function() cleanup, + required void Function(String) log, +}) async { + if (serial == null || serial.isEmpty) return EcgRecoveryOutcome.noSerial; + if (!await guard.isActive(serial)) return EcgRecoveryOutcome.noGuard; + log( + '[ECG] retained may-be-active guard for $serial — sending the cleanup ' + 'triplet before history.', + ); + final res = await cleanup(); + if (res.allSucceeded && await guard.clear(serial)) { + log('[ECG] recovery cleanup succeeded — guard cleared.'); + return EcgRecoveryOutcome.cleared; + } + log( + '[ECG] recovery cleanup incomplete ($res) — guard retained; the next ' + 'connection tries again.', + ); + return EcgRecoveryOutcome.retained; +} diff --git a/lib/ecg/ecg_transport.dart b/lib/ecg/ecg_transport.dart new file mode 100644 index 000000000..3ce035f9a --- /dev/null +++ b/lib/ecg/ecg_transport.dart @@ -0,0 +1,101 @@ +// The seam between the ECG controller and the BLE engine. The controller +// depends on THIS — a fake implements it in tests — and the adapter in +// ble_ecg_transport.dart forwards to BleEngine 1:1. + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import 'ecg_models.dart'; + +/// Exclusive ownership of the command transport for one link. Opaque to the +/// controller; the transport validates it. +class EcgLeaseHandle { + final Object token; + final int linkGeneration; + const EcgLeaseHandle(this.token, this.linkGeneration); +} + +/// What the transport reports about the link and the live stream. +sealed class EcgTransportEvent { + final int linkGeneration; + const EcgTransportEvent(this.linkGeneration); +} + +class EcgTransportFrame extends EcgTransportEvent { + final LabradorR17 r17; + const EcgTransportFrame(this.r17, super.linkGeneration); +} + +/// A frame that claimed to be R17 but did not parse. +class EcgTransportMalformed extends EcgTransportEvent { + final String reason; + const EcgTransportMalformed(super.linkGeneration, this.reason); +} + +/// The link of [linkGeneration] is gone. +class EcgTransportLinkDown extends EcgTransportEvent { + const EcgTransportLinkDown(super.linkGeneration); +} + +class EcgMemberOutcome { + final String label; + final bool written; + final bool succeeded; + const EcgMemberOutcome( + this.label, { + required this.written, + required this.succeeded, + }); + + @override + String toString() => '$label(written=$written ok=$succeeded)'; +} + +class EcgCommandListResult { + final List outcomes; + const EcgCommandListResult(this.outcomes); + + bool get allSucceeded => + outcomes.isNotEmpty && outcomes.every((o) => o.succeeded); + List get failed => [ + for (final o in outcomes) + if (!o.succeeded) o, + ]; + + @override + String toString() => outcomes.join(', '); +} + +abstract class EcgTransport { + /// Connected and READY. + bool get isReady; + + /// Positively identified WHOOP MG (revision-1 HELLO, MAVERICK interval). + bool get isMaverick; + + int get linkGeneration; + + /// The connected band's serial, or null before it is known. + String? get serial; + + Stream get events; + + /// Claim the transport. Synchronous. Null when not connected or already + /// leased (another capture, or READY recovery). + EcgLeaseHandle? acquire(); + bool leaseValid(EcgLeaseHandle lease); + void release(EcgLeaseHandle lease); + + /// End the phone-side history owner and wait for it to go quiescent. + Future cancelHistory(EcgLeaseHandle lease); + + Future prepare(EcgLeaseHandle lease, EcgWrist wrist); + Future start(EcgLeaseHandle lease); + Future restart(EcgLeaseHandle lease); + + /// Always attempts all three members. + Future cleanup(EcgLeaseHandle lease); + + /// Ask for an ordinary incremental history sync (after cleanup, so the + /// saved raw R16 comes back through the normal safe path). + Future requestSync(); +} diff --git a/lib/ecg/ecg_waveform_buffer.dart b/lib/ecg/ecg_waveform_buffer.dart new file mode 100644 index 000000000..47d0441ec --- /dev/null +++ b/lib/ecg/ecg_waveform_buffer.dart @@ -0,0 +1,94 @@ +// A bounded ring of the most recent live samples for the capture screen's +// "Live signal preview". RAM only, fixed size, no per-push allocation and +// no whole-buffer copies: the painter reads the ring through [operator []] +// in oldest-first order. Repaints are scheduled by [EcgPreviewScheduler], +// not by pushes — a burst of packets inside one tick is one repaint. +// +// Pure Dart (a ChangeNotifier-free Listenable would drag Flutter in; the +// scheduler is a tiny listener set instead). + +import 'dart:typed_data'; + +class EcgWaveformBuffer { + /// 8 s at 100 Hz. + static const int defaultCapacity = 800; + + final Int16List _ring; + int _write = 0; + int _length = 0; + int _version = 0; + + EcgWaveformBuffer({int capacity = defaultCapacity}) + : _ring = Int16List(capacity); + + int get capacity => _ring.length; + + /// Samples currently held (≤ capacity). + int get length => _length; + + /// Bumps on every push; a painter can skip a repaint when unchanged. + int get version => _version; + + bool get isEmpty => _length == 0; + + /// The i-th oldest sample of the visible window. + int operator [](int i) { + if (i < 0 || i >= _length) throw RangeError.index(i, this); + final start = (_write - _length + _ring.length) % _ring.length; + return _ring[(start + i) % _ring.length]; + } + + /// Append [samples] (oldest first), dropping the oldest held samples when + /// the ring is full. + void push(Int16List samples) { + if (samples.isEmpty) return; + for (final s in samples) { + _ring[_write] = s; + _write = (_write + 1) % _ring.length; + if (_length < _ring.length) _length++; + } + _version++; + } + + void clear() { + _write = 0; + _length = 0; + _version++; + } + + /// Largest |sample| in the window, or 0 when empty. + int maxAbs() { + var m = 0; + for (var i = 0; i < _length; i++) { + final v = this[i].abs(); + if (v > m) m = v; + } + return m; + } +} + +/// Coalesces "the buffer changed" into at most one notification per tick. +/// The SCREEN drives [tick] from its clock (a Ticker when motion is on, a +/// 1 Hz timer otherwise); frames only [markDirty]. +class EcgPreviewScheduler { + final List _listeners = []; + bool _dirty = false; + + bool get isDirty => _dirty; + + void addListener(void Function() l) => _listeners.add(l); + void removeListener(void Function() l) => _listeners.remove(l); + + void markDirty() => _dirty = true; + + /// Notify once if anything changed since the last tick. Returns whether a + /// notification went out. + bool tick() { + if (!_dirty) return false; + _dirty = false; + for (final l in List.of(_listeners)) { + l(); + } + return true; + } +} diff --git a/lib/gps/screen_wake.dart b/lib/gps/screen_wake.dart index a7b36b1eb..edcb3c01c 100644 --- a/lib/gps/screen_wake.dart +++ b/lib/gps/screen_wake.dart @@ -1,4 +1,4 @@ -// ScreenWake — hold the display awake for the duration of a live workout. +// ScreenWake — hold the display awake for the duration of a live session. // // Every serious run/ride app does this: the athlete has the phone on a bar // mount or an armband and glances at it, they do not tap it every 30 s to stop @@ -15,6 +15,14 @@ // the background. Background *recording* is a separate mechanism entirely (the // location background mode / FGS location type — see gps_source.dart). // +// OWNERS. More than one feature can want the screen (a workout, an ECG +// reading). Each holds and releases under its own name; the display is held +// while ANY owner remains, so one feature's release can never drop another's +// hold. The owner set is reconciled to the platform through the same +// serialized chain as before, and the confirmed state still moves only after +// a successful platform call — a failed enable leaves the owner recorded, so +// the next hold or release retries it. +// // Failure is always silent: a screen that sleeps is a papercut, never a reason // to interrupt a workout. @@ -35,6 +43,9 @@ class ScreenWake { /// short-circuited every later retry. static bool _on = false; + /// Who currently wants the display held. + static final Set _owners = {}; + /// Test seam for the platform switch below. /// /// `Platform.isAndroid` and `Platform.isIOS` are BOTH false on the host VM @@ -53,6 +64,9 @@ class ScreenWake { @visibleForTesting static bool get isHeld => _on; + @visibleForTesting + static Set get owners => Set.unmodifiable(_owners); + /// Serializes transitions so each one sees the state the previous one left. /// /// Without this, `_on` is only updated AFTER the platform await, so a @@ -64,15 +78,33 @@ class ScreenWake { /// hit it. static Future _chain = Future.value(); - /// Keep the display awake. Safe to call repeatedly. - static Future enable() => _set(true); + /// Keep the display awake on behalf of [owner]. Safe to call repeatedly. + static Future hold(String owner) { + _owners.add(owner); + return _reconcile(); + } + + /// [owner] no longer needs the display. The display is released only when + /// no owner remains. MUST be called when the owner's session ends — + /// including on the error/abort paths. + static Future releaseOwner(String owner) { + _owners.remove(owner); + return _reconcile(); + } - /// Release the display. MUST be called when the session ends — including on - /// the error/abort paths, or the screen stays awake until the app is killed. - static Future release() => _set(false); + /// The workout's hold under the original single-owner names — kept so the + /// reliability tests written against them still describe real behaviour. + /// New call sites use [hold]/[releaseOwner] with their own name. + static const String workoutOwner = 'workout'; + static Future enable() => hold(workoutOwner); + static Future release() => releaseOwner(workoutOwner); - static Future _set(bool on) { - final next = _chain.then((_) => _apply(on)); + static Future _reconcile() { + // The owner set as of THIS call — so a hold followed by a release still + // reaches the platform as enable-then-release, in order, exactly as the + // reliability tests pin, and the last word is the release. + final want = _owners.isNotEmpty; + final next = _chain.then((_) => _apply(want)); // Keep the chain alive even if a link fails; _apply already swallows, this // is belt-and-braces so one bad transition can't wedge every later one. _chain = next.catchError((_) {}); @@ -101,6 +133,7 @@ class ScreenWake { @visibleForTesting static void resetForTest() { _on = false; + _owners.clear(); platformOverride = null; _chain = Future.value(); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 53824cb52..e1c2e6125 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2309,5 +2309,77 @@ "deviceActionLogWaterLabel": "Wasser protokollieren", "deviceActionLogWaterBlurb": "Ein Glas zum heutigen Wasser hinzufügen — derselbe Schritt wie das + auf dem Ernährungsbildschirm.", "deviceActionBroadcastToTaskerLabel": "An Tasker senden", - "deviceActionBroadcastToTaskerBlurb": "Einen Broadcast-Intent auslösen, damit Tasker jede beliebige Automatisierung starten kann." + "deviceActionBroadcastToTaskerBlurb": "Einen Broadcast-Intent auslösen, damit Tasker jede beliebige Automatisierung starten kann.", + "ecgTitle": "EKG", + "ecgHeartScreener": "Herz-Screener", + "ecgEntryMeta": "WHOOP MG · vom Band gemeldet", + "ecgOpen": "Öffnen", + "ecgTakeEcg": "EKG aufnehmen", + "ecgNeedsMg": "Für ein EKG muss ein WHOOP MG verbunden sein.", + "ecgHistoryEmpty": "Noch keine Aufzeichnungen.", + "ecgHistoryEmptyWhy": "Aufgenommene EKGs werden hier gespeichert und bleiben auch ohne Band lesbar.", + "ecgWristPrompt": "An welchem Handgelenk trägst du das Band?", + "ecgWristLeft": "Linkes Handgelenk", + "ecgWristRight": "Rechtes Handgelenk", + "ecgInstruction": "Leg den Arm ab. Berühre beide Metallseiten mit Daumen und Zeigefinger der anderen Hand. Halte still.", + "ecgIllustration": "Illustration: das Band am Handgelenk, Daumen und Zeigefinger der anderen Hand berühren die beiden Metallseiten.", + "ecgContactLost": "Finger neu auflegen und still halten", + "ecgPreparing": "Band wird vorbereitet…", + "ecgRecovering": "Vorherige Aufnahme wird zuerst beendet…", + "ecgWaiting": "Warte auf Kontakt", + "ecgMeasuring": "Messung läuft", + "ecgRestarting": "Neustart…", + "ecgSaving": "Speichern…", + "ecgCleaningUp": "Band wird gestoppt…", + "ecgCompleted": "Aufzeichnung gespeichert", + "ecgUnreadableTitle": "Das Band konnte nichts auswerten", + "ecgInconclusiveTitle": "Nicht eindeutig", + "ecgInconclusiveRetryHint": "Das Band konnte sich nicht entscheiden. Du kannst es noch einmal versuchen.", + "ecgTryOnceMore": "Noch einmal versuchen", + "ecgTakeAnother": "Neue Aufnahme", + "ecgDone": "Fertig", + "ecgViewReading": "Aufzeichnung ansehen", + "ecgCancelledTitle": "Aufzeichnung abgebrochen", + "ecgFailedTitle": "Aufzeichnung fehlgeschlagen", + "ecgFailedDisconnected": "Die Verbindung zum Band wurde getrennt.", + "ecgFailedTimeout": "Kein Ergebnis innerhalb von zwei Minuten.", + "ecgFailedGeneric": "Das Band hat die Aufnahme nicht angenommen ({reason}).", + "ecgBusy": "Beende zuerst die andere Live-Sitzung.", + "ecgIncompatible": "Dieses Band ist kein WHOOP MG.", + "ecgDisconnected": "Verbinde zuerst dein WHOOP MG.", + "ecgCleanupIncomplete": "Das Band misst möglicherweise weiter. Es wird bei der nächsten Verbindung gestoppt.", + "ecgLivePreview": "Live-Signalvorschau", + "ecgBandReported": "Vom Band gemeldetes Ergebnis", + "ecgNotDiagnosis": "Die Kategorie stammt vom Band. Das ist keine Diagnose.", + "ecgCategorySinus": "Sinusrhythmus", + "ecgCategoryLowHr": "Niedrige Herzfrequenz", + "ecgCategoryPossibleAfib": "Mögliches Vorhofflimmern", + "ecgCategoryAfibHighHr": "Vorhofflimmern mit hoher Herzfrequenz", + "ecgCategoryHighHr": "Hohe Herzfrequenz", + "ecgCategoryHighHrNoAfib": "Hohe Herzfrequenz, kein Vorhofflimmern erkannt", + "ecgCategoryInconclusive": "Nicht eindeutig", + "ecgCategoryUnreadable": "Nicht lesbar", + "ecgReasonLowAmplitude": "Geringe Amplitude", + "ecgReasonNoise": "Starkes Rauschen", + "ecgReasonUnstable": "Instabiles Signal", + "ecgReasonNotEnoughData": "Zu wenige Daten", + "ecgAnalyzeNow": "Jetzt analysieren", + "ecgAnalyzeCloudTitle": "Diese Aufzeichnung an dein Modell senden?", + "ecgAnalyzeCloudBody": "Die Zusammenfassung und die vollständige Kurve (jeder vom Band aufgezeichnete Messwert, 100 pro Sekunde) werden an {host} als {model} gesendet. Keine Rohdaten, keine Seriennummer.", + "ecgContinue": "Weiter", + "ecgCancel": "Abbrechen", + "ecgDelete": "Aufzeichnung löschen", + "ecgAvgHr": "Durchschnittliche Herzfrequenz", + "ecgQuality": "Signalqualität", + "ecgInterruptions": "Unterbrechungen", + "ecgDuration": "Dauer", + "ecgMissingSegments": "Fehlende Abschnitte", + "ecgWristLabel": "Handgelenk", + "ecgZoomIn": "Vergrößern", + "ecgZoomOut": "Verkleinern", + "ecgClose": "EKG schließen", + "ecgProgress": "{pct} % abgeschlossen", + "ecgWaveformEmpty": "Zu dieser Aufzeichnung wurde keine Kurve gespeichert.", + "ecgWaveformLabel": "Akzeptierte Kurve in Mikrovolt, wie vom Band gesendet. Lücken sind fehlende Sekunden.", + "ecgSampleNote": "{count} Messwerte bei {rate} Hz, gefiltert, eingangsbezogene µV. Keine Ableitung oder Polarität wird behauptet." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d1c1cce9a..084789599 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12119,5 +12119,319 @@ "devicesRequestNotSaved": "That request could not be saved. Please try again.", "@devicesRequestNotSaved": { "description": "Shown when the add-a-band restart request could not be persisted." + }, + "ecgTitle": "ECG", + "@ecgTitle": { + "description": "WHOOP MG ECG: ECG" + }, + "ecgHeartScreener": "Heart Screener", + "@ecgHeartScreener": { + "description": "WHOOP MG ECG: Heart Screener" + }, + "ecgEntryMeta": "WHOOP MG · band-reported", + "@ecgEntryMeta": { + "description": "WHOOP MG ECG: WHOOP MG · band-reported" + }, + "ecgOpen": "Open", + "@ecgOpen": { + "description": "WHOOP MG ECG: Open" + }, + "ecgTakeEcg": "Take ECG", + "@ecgTakeEcg": { + "description": "WHOOP MG ECG: Take ECG" + }, + "ecgNeedsMg": "Take ECG needs a connected WHOOP MG.", + "@ecgNeedsMg": { + "description": "WHOOP MG ECG: Take ECG needs a connected WHOOP MG." + }, + "ecgHistoryEmpty": "No readings yet.", + "@ecgHistoryEmpty": { + "description": "WHOOP MG ECG: No readings yet." + }, + "ecgHistoryEmptyWhy": "Readings you take are saved here and stay readable while the band is away.", + "@ecgHistoryEmptyWhy": { + "description": "WHOOP MG ECG: Readings you take are saved here and stay readable while the band is away." + }, + "ecgWristPrompt": "Which wrist is the band on?", + "@ecgWristPrompt": { + "description": "WHOOP MG ECG: Which wrist is the band on?" + }, + "ecgWristLeft": "Left wrist", + "@ecgWristLeft": { + "description": "WHOOP MG ECG: Left wrist" + }, + "ecgWristRight": "Right wrist", + "@ecgWristRight": { + "description": "WHOOP MG ECG: Right wrist" + }, + "ecgInstruction": "Rest your arm. Touch both metal sides with your opposite thumb and index finger. Keep still.", + "@ecgInstruction": { + "description": "WHOOP MG ECG: Rest your arm. Touch both metal sides with your opposite thumb and index finger. Keep still." + }, + "ecgIllustration": "Illustration: the band on your wrist, and the thumb and index finger of your other hand touching its two metal sides.", + "@ecgIllustration": { + "description": "WHOOP MG ECG: Illustration: the band on your wrist, and the thumb and index finger of your other hand touching its two metal sides." + }, + "ecgContactLost": "Adjust your fingers and keep still", + "@ecgContactLost": { + "description": "WHOOP MG ECG: Adjust your fingers and keep still" + }, + "ecgPreparing": "Preparing the band…", + "@ecgPreparing": { + "description": "WHOOP MG ECG: Preparing the band…" + }, + "ecgRecovering": "Stopping a previous reading first…", + "@ecgRecovering": { + "description": "WHOOP MG ECG: Stopping a previous reading first…" + }, + "ecgWaiting": "Waiting for contact", + "@ecgWaiting": { + "description": "WHOOP MG ECG: Waiting for contact" + }, + "ecgMeasuring": "Measuring", + "@ecgMeasuring": { + "description": "WHOOP MG ECG: Measuring" + }, + "ecgRestarting": "Restarting…", + "@ecgRestarting": { + "description": "WHOOP MG ECG: Restarting…" + }, + "ecgSaving": "Saving…", + "@ecgSaving": { + "description": "WHOOP MG ECG: Saving…" + }, + "ecgCleaningUp": "Stopping the band…", + "@ecgCleaningUp": { + "description": "WHOOP MG ECG: Stopping the band…" + }, + "ecgCompleted": "Reading saved", + "@ecgCompleted": { + "description": "WHOOP MG ECG: Reading saved" + }, + "ecgUnreadableTitle": "The band could not read this", + "@ecgUnreadableTitle": { + "description": "WHOOP MG ECG: The band could not read this" + }, + "ecgInconclusiveTitle": "Inconclusive", + "@ecgInconclusiveTitle": { + "description": "WHOOP MG ECG: Inconclusive" + }, + "ecgInconclusiveRetryHint": "The band could not decide. You can try once more.", + "@ecgInconclusiveRetryHint": { + "description": "WHOOP MG ECG: The band could not decide. You can try once more." + }, + "ecgTryOnceMore": "Try once more", + "@ecgTryOnceMore": { + "description": "WHOOP MG ECG: Try once more" + }, + "ecgTakeAnother": "Take another", + "@ecgTakeAnother": { + "description": "WHOOP MG ECG: Take another" + }, + "ecgDone": "Done", + "@ecgDone": { + "description": "WHOOP MG ECG: Done" + }, + "ecgViewReading": "View reading", + "@ecgViewReading": { + "description": "WHOOP MG ECG: View reading" + }, + "ecgCancelledTitle": "Reading cancelled", + "@ecgCancelledTitle": { + "description": "WHOOP MG ECG: Reading cancelled" + }, + "ecgFailedTitle": "Reading failed", + "@ecgFailedTitle": { + "description": "WHOOP MG ECG: Reading failed" + }, + "ecgFailedDisconnected": "The band disconnected.", + "@ecgFailedDisconnected": { + "description": "WHOOP MG ECG: The band disconnected." + }, + "ecgFailedTimeout": "No result within two minutes.", + "@ecgFailedTimeout": { + "description": "WHOOP MG ECG: No result within two minutes." + }, + "ecgFailedGeneric": "The band did not accept the reading ({reason}).", + "@ecgFailedGeneric": { + "description": "WHOOP MG ECG: The band did not accept the reading ({reason}).", + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "ecgBusy": "Finish the other live session first.", + "@ecgBusy": { + "description": "WHOOP MG ECG: Finish the other live session first." + }, + "ecgIncompatible": "This band is not a WHOOP MG.", + "@ecgIncompatible": { + "description": "WHOOP MG ECG: This band is not a WHOOP MG." + }, + "ecgDisconnected": "Connect your WHOOP MG first.", + "@ecgDisconnected": { + "description": "WHOOP MG ECG: Connect your WHOOP MG first." + }, + "ecgCleanupIncomplete": "The band may still be generating. It will be stopped on the next connection.", + "@ecgCleanupIncomplete": { + "description": "WHOOP MG ECG: The band may still be generating. It will be stopped on the next connection." + }, + "ecgLivePreview": "Live signal preview", + "@ecgLivePreview": { + "description": "WHOOP MG ECG: Live signal preview" + }, + "ecgBandReported": "Band-reported result", + "@ecgBandReported": { + "description": "WHOOP MG ECG: Band-reported result" + }, + "ecgNotDiagnosis": "The category comes from the band. This is not a diagnosis.", + "@ecgNotDiagnosis": { + "description": "WHOOP MG ECG: The category comes from the band. This is not a diagnosis." + }, + "ecgCategorySinus": "Sinus rhythm", + "@ecgCategorySinus": { + "description": "WHOOP MG ECG: Sinus rhythm" + }, + "ecgCategoryLowHr": "Low heart rate", + "@ecgCategoryLowHr": { + "description": "WHOOP MG ECG: Low heart rate" + }, + "ecgCategoryPossibleAfib": "Possible AFib", + "@ecgCategoryPossibleAfib": { + "description": "WHOOP MG ECG: Possible AFib" + }, + "ecgCategoryAfibHighHr": "AFib with high heart rate", + "@ecgCategoryAfibHighHr": { + "description": "WHOOP MG ECG: AFib with high heart rate" + }, + "ecgCategoryHighHr": "High heart rate", + "@ecgCategoryHighHr": { + "description": "WHOOP MG ECG: High heart rate" + }, + "ecgCategoryHighHrNoAfib": "High heart rate, no AFib detected", + "@ecgCategoryHighHrNoAfib": { + "description": "WHOOP MG ECG: High heart rate, no AFib detected" + }, + "ecgCategoryInconclusive": "Inconclusive", + "@ecgCategoryInconclusive": { + "description": "WHOOP MG ECG: Inconclusive" + }, + "ecgCategoryUnreadable": "Unreadable", + "@ecgCategoryUnreadable": { + "description": "WHOOP MG ECG: Unreadable" + }, + "ecgReasonLowAmplitude": "Low amplitude", + "@ecgReasonLowAmplitude": { + "description": "WHOOP MG ECG: Low amplitude" + }, + "ecgReasonNoise": "Significant noise", + "@ecgReasonNoise": { + "description": "WHOOP MG ECG: Significant noise" + }, + "ecgReasonUnstable": "Unstable signal", + "@ecgReasonUnstable": { + "description": "WHOOP MG ECG: Unstable signal" + }, + "ecgReasonNotEnoughData": "Not enough data", + "@ecgReasonNotEnoughData": { + "description": "WHOOP MG ECG: Not enough data" + }, + "ecgAnalyzeNow": "Analyze now", + "@ecgAnalyzeNow": { + "description": "WHOOP MG ECG: Analyze now" + }, + "ecgAnalyzeCloudTitle": "Send this reading to your model?", + "@ecgAnalyzeCloudTitle": { + "description": "WHOOP MG ECG: Send this reading to your model?" + }, + "ecgAnalyzeCloudBody": "The reading summary and the full waveform (every sample the band recorded, 100 per second) will be sent to {host} as {model}. No raw frames, no band serial.", + "@ecgAnalyzeCloudBody": { + "description": "WHOOP MG ECG: The reading summary and a bounded waveform envelope will be sent to {host} as {model}. No raw frames, no band serial.", + "placeholders": { + "host": { + "type": "String" + }, + "model": { + "type": "String" + } + } + }, + "ecgContinue": "Continue", + "@ecgContinue": { + "description": "WHOOP MG ECG: Continue" + }, + "ecgCancel": "Cancel", + "@ecgCancel": { + "description": "WHOOP MG ECG: Cancel" + }, + "ecgDelete": "Delete reading", + "@ecgDelete": { + "description": "WHOOP MG ECG: Delete reading" + }, + "ecgAvgHr": "Average heart rate", + "@ecgAvgHr": { + "description": "WHOOP MG ECG: Average heart rate" + }, + "ecgQuality": "Signal quality", + "@ecgQuality": { + "description": "WHOOP MG ECG: Signal quality" + }, + "ecgInterruptions": "Interruptions", + "@ecgInterruptions": { + "description": "WHOOP MG ECG: Interruptions" + }, + "ecgDuration": "Duration", + "@ecgDuration": { + "description": "WHOOP MG ECG: Duration" + }, + "ecgMissingSegments": "Missing segments", + "@ecgMissingSegments": { + "description": "WHOOP MG ECG: Missing segments" + }, + "ecgWristLabel": "Wrist", + "@ecgWristLabel": { + "description": "WHOOP MG ECG: Wrist" + }, + "ecgZoomIn": "Zoom in", + "@ecgZoomIn": { + "description": "WHOOP MG ECG: Zoom in" + }, + "ecgZoomOut": "Zoom out", + "@ecgZoomOut": { + "description": "WHOOP MG ECG: Zoom out" + }, + "ecgClose": "Close ECG", + "@ecgClose": { + "description": "WHOOP MG ECG: Close ECG" + }, + "ecgProgress": "{pct}% complete", + "@ecgProgress": { + "description": "WHOOP MG ECG: {pct}% complete", + "placeholders": { + "pct": { + "type": "int" + } + } + }, + "ecgWaveformEmpty": "No waveform was saved with this reading.", + "@ecgWaveformEmpty": { + "description": "WHOOP MG ECG: No waveform was saved with this reading." + }, + "ecgWaveformLabel": "Accepted waveform, microvolts as the band sent them. Gaps are missing seconds.", + "@ecgWaveformLabel": { + "description": "WHOOP MG ECG: Accepted waveform, microvolts as the band sent them. Gaps are missing seconds." + }, + "ecgSampleNote": "{count} samples at {rate} Hz, filtered, input-referred µV. No lead or polarity is claimed.", + "@ecgSampleNote": { + "description": "WHOOP MG ECG: {count} samples at {rate} Hz, filtered, input-referred µV. No lead or polarity is claimed.", + "placeholders": { + "count": { + "type": "int" + }, + "rate": { + "type": "int" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f5555de7b..8612c4529 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2338,5 +2338,77 @@ "nudgeDonateBody": "Es un proyecto libre y de código abierto, sin suscripción. Patrocinarlo permite mantenerlo.", "nudgeDonateCta": "Apoyar el proyecto", "nudgeNotNow": "Ahora no", - "nudgeDontShowAgain": "No volver a mostrar" + "nudgeDontShowAgain": "No volver a mostrar", + "ecgTitle": "ECG", + "ecgHeartScreener": "Escáner cardíaco", + "ecgEntryMeta": "WHOOP MG · informado por la banda", + "ecgOpen": "Abrir", + "ecgTakeEcg": "Tomar ECG", + "ecgNeedsMg": "Tomar ECG requiere un WHOOP MG conectado.", + "ecgHistoryEmpty": "Aún no hay lecturas.", + "ecgHistoryEmptyWhy": "Las lecturas se guardan aquí y siguen disponibles sin la banda.", + "ecgWristPrompt": "¿En qué muñeca llevas la banda?", + "ecgWristLeft": "Muñeca izquierda", + "ecgWristRight": "Muñeca derecha", + "ecgInstruction": "Apoya el brazo. Toca ambos lados metálicos con el pulgar y el índice de la otra mano. No te muevas.", + "ecgIllustration": "Ilustración: la banda en la muñeca y el pulgar e índice de la otra mano tocando sus dos lados metálicos.", + "ecgContactLost": "Ajusta los dedos y no te muevas", + "ecgPreparing": "Preparando la banda…", + "ecgRecovering": "Deteniendo una lectura anterior…", + "ecgWaiting": "Esperando contacto", + "ecgMeasuring": "Midiendo", + "ecgRestarting": "Reiniciando…", + "ecgSaving": "Guardando…", + "ecgCleaningUp": "Deteniendo la banda…", + "ecgCompleted": "Lectura guardada", + "ecgUnreadableTitle": "La banda no pudo leer esto", + "ecgInconclusiveTitle": "No concluyente", + "ecgInconclusiveRetryHint": "La banda no pudo decidir. Puedes intentarlo una vez más.", + "ecgTryOnceMore": "Intentar una vez más", + "ecgTakeAnother": "Tomar otra", + "ecgDone": "Listo", + "ecgViewReading": "Ver lectura", + "ecgCancelledTitle": "Lectura cancelada", + "ecgFailedTitle": "La lectura falló", + "ecgFailedDisconnected": "La banda se desconectó.", + "ecgFailedTimeout": "Sin resultado en dos minutos.", + "ecgFailedGeneric": "La banda no aceptó la lectura ({reason}).", + "ecgBusy": "Termina primero la otra sesión en vivo.", + "ecgIncompatible": "Esta banda no es un WHOOP MG.", + "ecgDisconnected": "Conecta tu WHOOP MG primero.", + "ecgCleanupIncomplete": "La banda puede seguir generando. Se detendrá en la próxima conexión.", + "ecgLivePreview": "Vista previa de la señal", + "ecgBandReported": "Resultado informado por la banda", + "ecgNotDiagnosis": "La categoría la da la banda. Esto no es un diagnóstico.", + "ecgCategorySinus": "Ritmo sinusal", + "ecgCategoryLowHr": "Frecuencia cardíaca baja", + "ecgCategoryPossibleAfib": "Posible fibrilación auricular", + "ecgCategoryAfibHighHr": "Fibrilación auricular con frecuencia alta", + "ecgCategoryHighHr": "Frecuencia cardíaca alta", + "ecgCategoryHighHrNoAfib": "Frecuencia alta, sin fibrilación detectada", + "ecgCategoryInconclusive": "No concluyente", + "ecgCategoryUnreadable": "Ilegible", + "ecgReasonLowAmplitude": "Amplitud baja", + "ecgReasonNoise": "Ruido significativo", + "ecgReasonUnstable": "Señal inestable", + "ecgReasonNotEnoughData": "Datos insuficientes", + "ecgAnalyzeNow": "Analizar ahora", + "ecgAnalyzeCloudTitle": "¿Enviar esta lectura a tu modelo?", + "ecgAnalyzeCloudBody": "El resumen y la onda completa (todas las muestras que registró la banda, 100 por segundo) se enviarán a {host} como {model}. Sin tramas en bruto ni número de serie.", + "ecgContinue": "Continuar", + "ecgCancel": "Cancelar", + "ecgDelete": "Eliminar lectura", + "ecgAvgHr": "Frecuencia cardíaca media", + "ecgQuality": "Calidad de la señal", + "ecgInterruptions": "Interrupciones", + "ecgDuration": "Duración", + "ecgMissingSegments": "Segmentos faltantes", + "ecgWristLabel": "Muñeca", + "ecgZoomIn": "Acercar", + "ecgZoomOut": "Alejar", + "ecgClose": "Cerrar ECG", + "ecgProgress": "{pct}% completado", + "ecgWaveformEmpty": "No se guardó ninguna onda con esta lectura.", + "ecgWaveformLabel": "Onda aceptada en microvoltios tal como la envió la banda. Los huecos son segundos faltantes.", + "ecgSampleNote": "{count} muestras a {rate} Hz, filtradas, µV referidos a la entrada. No se afirma derivación ni polaridad." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 16e8610c5..c760566d0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2309,5 +2309,77 @@ "deviceActionLogWaterLabel": "Enregistrer de l’eau", "deviceActionLogWaterBlurb": "Ajoute un verre à l’eau du jour, la même action que le + sur l’écran nutrition.", "deviceActionBroadcastToTaskerLabel": "Diffuser vers Tasker", - "deviceActionBroadcastToTaskerBlurb": "Envoyer un intent de diffusion pour que Tasker puisse déclencher n’importe quelle automatisation." + "deviceActionBroadcastToTaskerBlurb": "Envoyer un intent de diffusion pour que Tasker puisse déclencher n’importe quelle automatisation.", + "ecgTitle": "ECG", + "ecgHeartScreener": "Dépistage cardiaque", + "ecgEntryMeta": "WHOOP MG · rapporté par le bracelet", + "ecgOpen": "Ouvrir", + "ecgTakeEcg": "Faire un ECG", + "ecgNeedsMg": "Un ECG nécessite un WHOOP MG connecté.", + "ecgHistoryEmpty": "Aucune lecture pour l’instant.", + "ecgHistoryEmptyWhy": "Les lectures sont enregistrées ici et restent lisibles sans le bracelet.", + "ecgWristPrompt": "À quel poignet portez-vous le bracelet ?", + "ecgWristLeft": "Poignet gauche", + "ecgWristRight": "Poignet droit", + "ecgInstruction": "Posez votre bras. Touchez les deux côtés métalliques avec le pouce et l’index de l’autre main. Restez immobile.", + "ecgIllustration": "Illustration : le bracelet au poignet, le pouce et l’index de l’autre main touchant ses deux côtés métalliques.", + "ecgContactLost": "Repositionnez vos doigts et restez immobile", + "ecgPreparing": "Préparation du bracelet…", + "ecgRecovering": "Arrêt d’une lecture précédente…", + "ecgWaiting": "En attente de contact", + "ecgMeasuring": "Mesure en cours", + "ecgRestarting": "Redémarrage…", + "ecgSaving": "Enregistrement…", + "ecgCleaningUp": "Arrêt du bracelet…", + "ecgCompleted": "Lecture enregistrée", + "ecgUnreadableTitle": "Le bracelet n’a pas pu lire", + "ecgInconclusiveTitle": "Non concluant", + "ecgInconclusiveRetryHint": "Le bracelet n’a pas pu conclure. Vous pouvez réessayer une fois.", + "ecgTryOnceMore": "Réessayer une fois", + "ecgTakeAnother": "Refaire une lecture", + "ecgDone": "Terminé", + "ecgViewReading": "Voir la lecture", + "ecgCancelledTitle": "Lecture annulée", + "ecgFailedTitle": "Échec de la lecture", + "ecgFailedDisconnected": "Le bracelet s’est déconnecté.", + "ecgFailedTimeout": "Aucun résultat en deux minutes.", + "ecgFailedGeneric": "Le bracelet n’a pas accepté la lecture ({reason}).", + "ecgBusy": "Terminez d’abord l’autre session en direct.", + "ecgIncompatible": "Ce bracelet n’est pas un WHOOP MG.", + "ecgDisconnected": "Connectez d’abord votre WHOOP MG.", + "ecgCleanupIncomplete": "Le bracelet génère peut-être encore. Il sera arrêté à la prochaine connexion.", + "ecgLivePreview": "Aperçu du signal en direct", + "ecgBandReported": "Résultat rapporté par le bracelet", + "ecgNotDiagnosis": "La catégorie vient du bracelet. Ce n’est pas un diagnostic.", + "ecgCategorySinus": "Rythme sinusal", + "ecgCategoryLowHr": "Fréquence cardiaque basse", + "ecgCategoryPossibleAfib": "Fibrillation auriculaire possible", + "ecgCategoryAfibHighHr": "Fibrillation auriculaire avec fréquence élevée", + "ecgCategoryHighHr": "Fréquence cardiaque élevée", + "ecgCategoryHighHrNoAfib": "Fréquence élevée, pas de fibrillation détectée", + "ecgCategoryInconclusive": "Non concluant", + "ecgCategoryUnreadable": "Illisible", + "ecgReasonLowAmplitude": "Faible amplitude", + "ecgReasonNoise": "Bruit important", + "ecgReasonUnstable": "Signal instable", + "ecgReasonNotEnoughData": "Données insuffisantes", + "ecgAnalyzeNow": "Analyser maintenant", + "ecgAnalyzeCloudTitle": "Envoyer cette lecture à votre modèle ?", + "ecgAnalyzeCloudBody": "Le résumé et le tracé complet (chaque échantillon enregistré par le bracelet, 100 par seconde) seront envoyés à {host} en tant que {model}. Aucune trame brute, aucun numéro de série.", + "ecgContinue": "Continuer", + "ecgCancel": "Annuler", + "ecgDelete": "Supprimer la lecture", + "ecgAvgHr": "Fréquence cardiaque moyenne", + "ecgQuality": "Qualité du signal", + "ecgInterruptions": "Interruptions", + "ecgDuration": "Durée", + "ecgMissingSegments": "Segments manquants", + "ecgWristLabel": "Poignet", + "ecgZoomIn": "Zoom avant", + "ecgZoomOut": "Zoom arrière", + "ecgClose": "Fermer l’ECG", + "ecgProgress": "{pct} % terminé", + "ecgWaveformEmpty": "Aucun tracé n’a été enregistré avec cette lecture.", + "ecgWaveformLabel": "Tracé accepté en microvolts tel qu’envoyé par le bracelet. Les trous sont des secondes manquantes.", + "ecgSampleNote": "{count} échantillons à {rate} Hz, filtrés, µV rapportés à l’entrée. Aucune dérivation ni polarité n’est revendiquée." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b06db2f6d..a41b9a60b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2309,5 +2309,77 @@ "deviceActionLogWaterLabel": "पानी दर्ज करें", "deviceActionLogWaterBlurb": "आज के पानी में एक गिलास जोड़ें, न्यूट्रिशन स्क्रीन पर + जैसा ही कदम।", "deviceActionBroadcastToTaskerLabel": "Tasker को ब्रॉडकास्ट करें", - "deviceActionBroadcastToTaskerBlurb": "एक ब्रॉडकास्ट इंटेंट भेजें ताकि Tasker कोई भी ऑटोमेशन चला सके।" + "deviceActionBroadcastToTaskerBlurb": "एक ब्रॉडकास्ट इंटेंट भेजें ताकि Tasker कोई भी ऑटोमेशन चला सके।", + "ecgTitle": "ईसीजी", + "ecgHeartScreener": "हार्ट स्क्रीनर", + "ecgEntryMeta": "WHOOP MG · बैंड द्वारा रिपोर्ट", + "ecgOpen": "खोलें", + "ecgTakeEcg": "ईसीजी लें", + "ecgNeedsMg": "ईसीजी लेने के लिए जुड़ा हुआ WHOOP MG चाहिए।", + "ecgHistoryEmpty": "अभी तक कोई रीडिंग नहीं।", + "ecgHistoryEmptyWhy": "आपकी रीडिंग यहाँ सहेजी जाती हैं और बैंड न होने पर भी पढ़ी जा सकती हैं।", + "ecgWristPrompt": "बैंड किस कलाई पर है?", + "ecgWristLeft": "बायीं कलाई", + "ecgWristRight": "दायीं कलाई", + "ecgInstruction": "अपनी बाँह टिकाएँ। दूसरे हाथ के अंगूठे और तर्जनी से दोनों धातु के किनारों को छुएँ। स्थिर रहें।", + "ecgIllustration": "चित्र: कलाई पर बैंड, और दूसरे हाथ का अंगूठा और तर्जनी उसके दोनों धातु किनारों को छू रहे हैं।", + "ecgContactLost": "उँगलियाँ ठीक करें और स्थिर रहें", + "ecgPreparing": "बैंड तैयार हो रहा है…", + "ecgRecovering": "पहले पिछली रीडिंग रोकी जा रही है…", + "ecgWaiting": "संपर्क की प्रतीक्षा", + "ecgMeasuring": "माप जारी", + "ecgRestarting": "पुनः आरंभ…", + "ecgSaving": "सहेजा जा रहा है…", + "ecgCleaningUp": "बैंड रोका जा रहा है…", + "ecgCompleted": "रीडिंग सहेजी गई", + "ecgUnreadableTitle": "बैंड इसे पढ़ नहीं सका", + "ecgInconclusiveTitle": "अनिर्णायक", + "ecgInconclusiveRetryHint": "बैंड निर्णय नहीं ले सका। आप एक बार और कोशिश कर सकते हैं।", + "ecgTryOnceMore": "एक बार और कोशिश करें", + "ecgTakeAnother": "एक और लें", + "ecgDone": "हो गया", + "ecgViewReading": "रीडिंग देखें", + "ecgCancelledTitle": "रीडिंग रद्द", + "ecgFailedTitle": "रीडिंग विफल", + "ecgFailedDisconnected": "बैंड डिस्कनेक्ट हो गया।", + "ecgFailedTimeout": "दो मिनट में कोई परिणाम नहीं।", + "ecgFailedGeneric": "बैंड ने रीडिंग स्वीकार नहीं की ({reason})।", + "ecgBusy": "पहले दूसरा लाइव सत्र समाप्त करें।", + "ecgIncompatible": "यह बैंड WHOOP MG नहीं है।", + "ecgDisconnected": "पहले अपना WHOOP MG कनेक्ट करें।", + "ecgCleanupIncomplete": "बैंड अभी भी डेटा बना सकता है। अगली बार कनेक्ट होने पर इसे रोका जाएगा।", + "ecgLivePreview": "लाइव सिग्नल पूर्वावलोकन", + "ecgBandReported": "बैंड द्वारा रिपोर्ट किया गया परिणाम", + "ecgNotDiagnosis": "श्रेणी बैंड से आती है। यह निदान नहीं है।", + "ecgCategorySinus": "साइनस लय", + "ecgCategoryLowHr": "कम हृदय गति", + "ecgCategoryPossibleAfib": "संभावित एट्रियल फिब्रिलेशन", + "ecgCategoryAfibHighHr": "उच्च हृदय गति के साथ एट्रियल फिब्रिलेशन", + "ecgCategoryHighHr": "उच्च हृदय गति", + "ecgCategoryHighHrNoAfib": "उच्च हृदय गति, एट्रियल फिब्रिलेशन नहीं मिला", + "ecgCategoryInconclusive": "अनिर्णायक", + "ecgCategoryUnreadable": "अपठनीय", + "ecgReasonLowAmplitude": "कम आयाम", + "ecgReasonNoise": "अत्यधिक शोर", + "ecgReasonUnstable": "अस्थिर सिग्नल", + "ecgReasonNotEnoughData": "पर्याप्त डेटा नहीं", + "ecgAnalyzeNow": "अभी विश्लेषण करें", + "ecgAnalyzeCloudTitle": "यह रीडिंग अपने मॉडल को भेजें?", + "ecgAnalyzeCloudBody": "सारांश और पूरी तरंग (बैंड द्वारा रिकॉर्ड किया गया हर सैंपल, 100 प्रति सेकंड) {host} को {model} के रूप में भेजी जाएगी। कोई कच्चा डेटा या सीरियल नंबर नहीं।", + "ecgContinue": "जारी रखें", + "ecgCancel": "रद्द करें", + "ecgDelete": "रीडिंग हटाएँ", + "ecgAvgHr": "औसत हृदय गति", + "ecgQuality": "सिग्नल गुणवत्ता", + "ecgInterruptions": "व्यवधान", + "ecgDuration": "अवधि", + "ecgMissingSegments": "अनुपलब्ध खंड", + "ecgWristLabel": "कलाई", + "ecgZoomIn": "ज़ूम इन", + "ecgZoomOut": "ज़ूम आउट", + "ecgClose": "ईसीजी बंद करें", + "ecgProgress": "{pct}% पूर्ण", + "ecgWaveformEmpty": "इस रीडिंग के साथ कोई तरंग सहेजी नहीं गई।", + "ecgWaveformLabel": "स्वीकृत तरंग, माइक्रोवोल्ट में जैसा बैंड ने भेजा। रिक्त स्थान अनुपलब्ध सेकंड हैं।", + "ecgSampleNote": "{rate} Hz पर {count} सैंपल, फ़िल्टर्ड, इनपुट-संदर्भित µV। कोई लीड या ध्रुवता का दावा नहीं।" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f656ddc03..f31d7ab86 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2309,5 +2309,77 @@ "deviceActionLogWaterLabel": "记录饮水", "deviceActionLogWaterBlurb": "为今天的饮水量增加一杯,与营养页面上的“+”步骤相同。", "deviceActionBroadcastToTaskerLabel": "广播给 Tasker", - "deviceActionBroadcastToTaskerBlurb": "发送一条广播 intent,让 Tasker 可以触发任意自动化流程。" + "deviceActionBroadcastToTaskerBlurb": "发送一条广播 intent,让 Tasker 可以触发任意自动化流程。", + "ecgTitle": "心电图", + "ecgHeartScreener": "心脏筛查", + "ecgEntryMeta": "WHOOP MG · 手环报告", + "ecgOpen": "打开", + "ecgTakeEcg": "测量心电图", + "ecgNeedsMg": "测量心电图需要连接 WHOOP MG。", + "ecgHistoryEmpty": "还没有读数。", + "ecgHistoryEmptyWhy": "您的读数会保存在这里,即使手环不在也可查看。", + "ecgWristPrompt": "手环戴在哪只手腕上?", + "ecgWristLeft": "左手腕", + "ecgWristRight": "右手腕", + "ecgInstruction": "放松手臂。用另一只手的拇指和食指同时触碰两侧金属。保持不动。", + "ecgIllustration": "插图:手腕上的手环,另一只手的拇指和食指触碰其两侧金属。", + "ecgContactLost": "调整手指位置并保持不动", + "ecgPreparing": "正在准备手环…", + "ecgRecovering": "正在先停止上一次读数…", + "ecgWaiting": "等待接触", + "ecgMeasuring": "测量中", + "ecgRestarting": "正在重新开始…", + "ecgSaving": "正在保存…", + "ecgCleaningUp": "正在停止手环…", + "ecgCompleted": "读数已保存", + "ecgUnreadableTitle": "手环无法读取", + "ecgInconclusiveTitle": "无法确定", + "ecgInconclusiveRetryHint": "手环无法判定。您可以再试一次。", + "ecgTryOnceMore": "再试一次", + "ecgTakeAnother": "再测一次", + "ecgDone": "完成", + "ecgViewReading": "查看读数", + "ecgCancelledTitle": "读数已取消", + "ecgFailedTitle": "读数失败", + "ecgFailedDisconnected": "手环已断开连接。", + "ecgFailedTimeout": "两分钟内没有结果。", + "ecgFailedGeneric": "手环未接受此次读数({reason})。", + "ecgBusy": "请先结束另一个实时会话。", + "ecgIncompatible": "此手环不是 WHOOP MG。", + "ecgDisconnected": "请先连接您的 WHOOP MG。", + "ecgCleanupIncomplete": "手环可能仍在生成数据。下次连接时将停止。", + "ecgLivePreview": "实时信号预览", + "ecgBandReported": "手环报告的结果", + "ecgNotDiagnosis": "类别由手环给出。这不是诊断。", + "ecgCategorySinus": "窦性心律", + "ecgCategoryLowHr": "心率过低", + "ecgCategoryPossibleAfib": "疑似房颤", + "ecgCategoryAfibHighHr": "房颤伴心率过高", + "ecgCategoryHighHr": "心率过高", + "ecgCategoryHighHrNoAfib": "心率过高,未检测到房颤", + "ecgCategoryInconclusive": "无法确定", + "ecgCategoryUnreadable": "无法读取", + "ecgReasonLowAmplitude": "振幅过低", + "ecgReasonNoise": "噪声过大", + "ecgReasonUnstable": "信号不稳定", + "ecgReasonNotEnoughData": "数据不足", + "ecgAnalyzeNow": "立即分析", + "ecgAnalyzeCloudTitle": "将此读数发送到您的模型?", + "ecgAnalyzeCloudBody": "读数摘要和完整波形(手环记录的每个采样点,每秒 100 个)将作为 {model} 发送到 {host}。不含原始帧和手环序列号。", + "ecgContinue": "继续", + "ecgCancel": "取消", + "ecgDelete": "删除读数", + "ecgAvgHr": "平均心率", + "ecgQuality": "信号质量", + "ecgInterruptions": "中断次数", + "ecgDuration": "时长", + "ecgMissingSegments": "缺失段", + "ecgWristLabel": "手腕", + "ecgZoomIn": "放大", + "ecgZoomOut": "缩小", + "ecgClose": "关闭心电图", + "ecgProgress": "已完成 {pct}%", + "ecgWaveformEmpty": "此读数未保存波形。", + "ecgWaveformLabel": "接受的波形,单位为手环发送的微伏。空隙为缺失的秒。", + "ecgSampleNote": "{rate} Hz 下 {count} 个样本,已滤波,输入参考 µV。不声明导联或极性。" } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 1ef44b07c..130d2647b 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -65,6 +65,11 @@ import 'prefs.dart'; import '../ble/adapters/signals.dart' show InputSignal; import '../ui2/profile/devices.dart' show liveSources, rankSources; import '../data/db.dart'; +import '../ecg/ble_ecg_transport.dart'; +import '../ecg/ecg_controller.dart'; +import '../ecg/ecg_guard_store.dart'; +import '../ecg/ecg_models.dart'; +import '../ecg/ecg_recovery.dart'; import '../data/live_coverage_policy.dart'; import '../data/local_repository.dart'; import '../gps/gps_source.dart'; @@ -164,6 +169,65 @@ class AppState extends ChangeNotifier { /// delegates to it. late final BandHost _bandHost; PairedDevice? paired; + + // ── WHOOP MG ECG ────────────────────────────────────────────────────────── + // The controller owns one reading's lifecycle (lib/ecg/); this object only + // hosts it, hands it the engine through the transport adapter, and folds + // its "capturing" into the live-consumer and pause paths. + final EcgGuardStore _ecgGuard = PrefsEcgGuardStore(); + BleEngineEcgTransport? _ecgTransport; + EcgController? _ecg; + + /// The ECG owner. Built on first use in the real app; injectable (or + /// absent) under [AppState.forTesting]. + EcgController get ecg => _ecg ??= _buildEcg(); + + /// Whether the paired band was ever positively identified as a WHOOP MG + /// (a revision-1 HELLO in the MAVERICK interval). Loaded at startup from + /// the per-serial flag and set the moment an MG identifies itself; the + /// Health ECG entry is gated on exactly this, so saved readings stay + /// reachable while the band is away. + bool pairedIsMaverick = false; + + EcgController _buildEcg() { + final t = _ecgTransport ??= BleEngineEcgTransport( + engine: engine, + serialOf: () => paired?.serial ?? engine.state.serial, + onRequestSync: () => engine.requestHistorySync(), + ); + return EcgController( + transport: t, + guard: _ecgGuard, + save: (r, p) => LocalDb.insertEcgReading( + r.toRow(), + [for (final x in p) EcgPacketCodec.toRow(x)], + ), + busyReason: () => activeWorkout != null + ? 'workout' + : (breathingActive || breathingWindowOpen) + ? 'breathing' + : null, + holdScreen: ScreenWake.hold, + releaseScreen: ScreenWake.releaseOwner, + log: _log, + ); + } + + /// READY-time recovery of a retained ECG guard — controller-free, before + /// the engine publishes READY or claims history (see BleEngine.onReadyEcgRecovery). + Future _recoverEcgGuardOnReady(BleEngine e) async { + final t = _ecgTransport ??= BleEngineEcgTransport( + engine: e, + serialOf: () => paired?.serial ?? e.state.serial, + onRequestSync: () => engine.requestHistorySync(), + ); + await ecgRecoverRetainedGuard( + guard: _ecgGuard, + serial: paired?.serial ?? e.state.serial, + cleanup: t.recoveryCleanup, + log: _log, + ); + } BandLease? _foregroundLease; /// SEAM: the screen data layer. Wired to [LocalRepositoryImpl] in the ctor — @@ -1278,6 +1342,8 @@ class AppState extends ChangeNotifier { // engine's callback shape for a value it does not have. onEvent: (id, ts, hex) => _onLiveEvent(id, ts, hex, LocalDb.kPrimaryDeviceId), + onEcgEvent: (e) => _ecgTransport?.onEngineEvent(e), + onReadyEcgRecovery: _recoverEcgGuardOnReady, // Gated for the same reason as [_onRecord] — this one is wired straight // to LocalDb, so it bypasses every check AppState makes. onRecordsBatch: (raws, samples) async { @@ -1292,7 +1358,7 @@ class AppState extends ChangeNotifier { // failure contract: `commitNativeBatch` rethrows so // `DrainController.commit` still reads durability from a throw. onCommitBatch: (raws, samples, trimTokenHex, - {archives, deviceFamily}) async { + {archives, ecgRawPackets, deviceFamily}) async { // THROWS, never silently succeeds. This is the ACK gate: only // `onCommit` can bank raws + archives + trim cursor in one // transaction, and DrainController reads durability FROM A THROW @@ -1306,7 +1372,9 @@ class AppState extends ChangeNotifier { throw StateError('data reset in progress — refusing to commit'); } return _bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, deviceFamily: deviceFamily); + archives: archives, + ecgRawPackets: ecgRawPackets, + deviceFamily: deviceFamily); }, // Pre-setup fallback only: the drain path archives inside commitSyncBatch. onArchiveRecord: (raw) async { @@ -1397,8 +1465,9 @@ class AppState extends ChangeNotifier { /// stream arming throws). When supplied it is used AS GIVEN — its callbacks /// are the test's responsibility, not wired back into this AppState. @visibleForTesting - AppState.forTesting({BleEngine? engine}) { + AppState.forTesting({BleEngine? engine, EcgController? ecg}) { _background = false; + _ecg = ecg; _gestureDispatcher = GestureDispatcher( settings: gestureSettings, log: _log, @@ -1487,6 +1556,8 @@ class AppState extends ChangeNotifier { _syncQuietTimer?.cancel(); _syncQuietTimer = null; _disposed = true; + _ecg?.dispose(); + _ecgTransport?.dispose(); // EVERY timer this object owns, not just three of them. // _breathingRecomputeTimer and _workoutTimer used to survive dispose, and // each of their callbacks ends in notifyListeners() on a disposed @@ -2330,6 +2401,9 @@ class AppState extends ChangeNotifier { Future _initSteps() async { paired = await PairedDevice.load(); + final pairedSerial = paired?.serial; + pairedIsMaverick = pairedSerial != null && + await _ecgGuard.isRememberedMaverick(pairedSerial); await refreshSensors(); await _loadProfile(); await _refreshNightlyRhr(); @@ -2785,6 +2859,10 @@ class AppState extends ChangeNotifier { /// On Android the Edge Tracking foreground service keeps the process + connection alive. Future pauseForBackground() async { _background = true; + // A WHOOP MG ECG reading stops on pause (the official screen does the + // same on ON_PAUSE) — BEFORE the live-stream downgrade below, so its + // cleanup triplet is on the wire first. + await _ecg?.onAppPaused(); // Step the Android link down to a power-saving connection interval — see // `desiredLinkPriority` (issue #200). engine.setBackground(true); @@ -2847,10 +2925,16 @@ class AppState extends ChangeNotifier { // gen4 keeps its previous behaviour: a foreground connection owns HR plus // the R10/R11 + IMU + optical bundle (see `LiveStreamOwners.foreground`). - /// A feature session (workout, breathing) is running — the "nothing else in - /// flight" bar the one-off VACUUM waits for. + /// A feature session (workout, breathing, ECG capture) is running — the + /// "nothing else in flight" bar the one-off VACUUM waits for. ECG matters + /// here specifically: a VACUUM takes an exclusive DB lock and rewrites the + /// whole file, and an ECG capture in progress is actively writing captured + /// packets — the two must never overlap. bool get _liveSessionActive => - activeWorkout != null || breathingActive || breathingWindowOpen; + activeWorkout != null || + breathingActive || + breathingWindowOpen || + (_ecg?.isCapturing ?? false); /// Screens showing the live BPM that are mounted right now. int _liveHrViewers = 0; @@ -3894,6 +3978,14 @@ class AppState extends ChangeNotifier { unawaited( PairedDevice.save(p.remoteId, p.serial, generation: s.generation)); } + // WHOOP MG identity, remembered per serial so the ECG entry survives a + // disconnect. Set only from a positive revision-1 MAVERICK hello — never + // from the family, the name or a command's acceptance. + final mgSerial = paired?.serial; + if (!pairedIsMaverick && engine.isMaverick && mgSerial != null) { + pairedIsMaverick = true; + unawaited(_ecgGuard.rememberMaverick(mgSerial)); + } // Keep the lock-screen Band Battery widget current — only when it changed. final battPct = roundedPct ?? -1; if (battPct != _widgetBattPct || @@ -4320,6 +4412,7 @@ class AppState extends ChangeNotifier { await engine.disconnect(); _releaseForegroundLease(); await PairedDevice.clear(); + pairedIsMaverick = false; // Everything the old band told us about itself. The engine's DeviceState // lives as long as the process and the persisted strap name outlives even // that, so without both of these a re-pair — with a DIFFERENT band — @@ -5888,7 +5981,7 @@ class AppState extends ChangeNotifier { // Arming this from _maybeStartRouteTracking meant an indoor workout, a // location-denied run, and a resumed non-route session all watched the // screen sleep mid-set. Released unconditionally on both teardown paths. - ScreenWake.enable(); + ScreenWake.hold('workout'); activeWorkout = LiveWorkoutState( startTime: start, targetKcal: targetKcal, @@ -6302,7 +6395,7 @@ class AppState extends ChangeNotifier { // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); await PolarPmdLink.instance.disarm(); - ScreenWake.release(); + ScreenWake.releaseOwner('workout'); _deriveScheduler.setWorkoutActive(false); final w = activeWorkout!; // Nullable for the same reason `steps` below is: an unanchored profile @@ -6435,7 +6528,7 @@ class AppState extends ChangeNotifier { // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); await PolarPmdLink.instance.disarm(); - ScreenWake.release(); + ScreenWake.releaseOwner('workout'); _deriveScheduler.setWorkoutActive(false); activeWorkout = null; _nudgeLive(); // the workout's stream ownership ends with it diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index ba4827437..8d7537425 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -51,6 +51,9 @@ import '../ble/zetime_link.dart'; import '../compute/derivation_engine.dart'; import '../compute/profile.dart'; import '../data/db.dart'; +import '../ecg/ecg_guard_store.dart'; +import '../ecg/ecg_recovery.dart'; +import '../ecg/ecg_transport.dart'; import '../notify/notification_center.dart'; import '../notify/notification_event.dart'; import '../state/alarm_schedule.dart'; @@ -154,7 +157,7 @@ Future runHeadlessSync({BandLease? lease}) async { // `commitNativeBatch` rethrows so `DrainController.commit` still reads // durability from a throw and `TrimAckPolicy` still blocks the ACK. onCommitBatch: (raws, samples, trimTokenHex, - {archives, deviceFamily}) async { + {archives, ecgRawPackets, deviceFamily}) async { // THROWS, never silently succeeds. This is the ACK gate: only // `onCommit` can bank raws + archives + trim cursor in one // transaction, and DrainController reads durability FROM A THROW @@ -168,12 +171,30 @@ Future runHeadlessSync({BandLease? lease}) async { throw StateError('data reset in progress — refusing to commit'); } return bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, deviceFamily: deviceFamily); + archives: archives, + ecgRawPackets: ecgRawPackets, + deviceFamily: deviceFamily); }, onArchiveRecord: (raw) async { if (ResetGate.active) return; await LocalDb.archiveRawRecord(raw); }, + // A WHOOP MG left generating by a dead process must be cleaned up + // BEFORE this drainer claims history — same rule as the foreground + // engine, controller-free. + onReadyEcgRecovery: (e) => ecgRecoverRetainedGuard( + guard: PrefsEcgGuardStore(), + serial: paired.serial, + cleanup: () async { + final out = await e.ecgRecoveryCleanup(); + return EcgCommandListResult([ + for (final o in out) + EcgMemberOutcome(o.label, + written: o.written, succeeded: o.succeeded), + ]); + }, + log: (l) => debugPrint('[bgsync] $l'), + ), cursorReader: (base) => LocalDb.getCursorInt(LocalDb.cursorKeyFor(base, LocalDb.kPrimaryDeviceId)), // Mark this as the background drainer: if the foreground app engine already diff --git a/lib/ui2/ecg_widgets.dart b/lib/ui2/ecg_widgets.dart new file mode 100644 index 000000000..730d3375d --- /dev/null +++ b/lib/ui2/ecg_widgets.dart @@ -0,0 +1,497 @@ +// WHOOP MG ECG — the drawn parts of the capture and detail screens. Original +// vector art (no WHOOP assets), driven by a caller-owned phase so reduced +// motion can freeze it, and two waveform painters that draw ONLY the samples +// they are handed: the live ring (a bounded preview, never persisted) and the +// saved accepted window (breaks at placeholders, never bridged). +// +// Nothing here claims a lead or a polarity: the axis is microvolts as the +// band sends them. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../ecg/ecg_models.dart'; +import '../ecg/ecg_waveform_buffer.dart'; +import 'grammar.dart'; +import 'theme.dart'; + +/// The band on the selected wrist, both electrode indents, and the opposite +/// hand's thumb and index finger touching them, with soft contact rings. +/// [t] is the pulse phase in [0, 1) — the SCREEN owns the clock; a frozen +/// [t] is a still illustration under reduced motion. The pinch stays in place; +/// [contact] settles the electrode halos once the band detects both fingers. +class EcgTouchIllustration extends StatelessWidget { + final EcgWrist wrist; + final double t; + final bool contact; + final String semanticLabel; + + const EcgTouchIllustration({ + super.key, + required this.wrist, + required this.t, + required this.contact, + required this.semanticLabel, + }); + + @override + Widget build(BuildContext context) { + final p = P.of(context); + return Semantics( + label: semanticLabel, + image: true, + child: RepaintBoundary( + child: CustomPaint( + painter: _TouchPainter( + wrist: wrist, + t: t, + contact: contact, + ink: p.ink, + ink2: p.ink3, + band: p.ink, + accent: C.domHealth, + skin: p.card2, + ), + size: const Size(double.infinity, 200), + ), + ), + ); + } +} + +class _TouchPainter extends CustomPainter { + final EcgWrist wrist; + final double t; + final bool contact; + final Color ink, ink2, band, accent, skin; + + _TouchPainter({ + required this.wrist, + required this.t, + required this.contact, + required this.ink, + required this.ink2, + required this.band, + required this.accent, + required this.skin, + }); + + @override + void paint(Canvas cv, Size s) { + // A fixed drawing space preserves the hand's proportions on narrow phones. + final scale = math.min(s.width / 360, s.height / 200); + cv.save(); + cv.clipRect(Offset.zero & s); + // Mirror the entire composition, including the opposite hand. + if (wrist == EcgWrist.left) { + cv.translate(s.width, 0); + cv.scale(-1, 1); + } + cv.translate((s.width - 360 * scale) / 2, (s.height - 200 * scale) / 2); + cv.scale(scale); + final outline = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = ink2; + final fill = Paint()..color = skin; + final crease = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4 + ..strokeCap = StrokeCap.round + ..color = ink2.withValues(alpha: .65); + + // Resting arm: tapered wrist, then the heel and softly curled fingers of + // the wearing hand. These contours remain behind the pinching hand. + final arm = Path() + ..moveTo(-12, 98) + ..cubicTo(44, 98, 94, 108, 129, 108) + ..cubicTo(156, 108, 172, 99, 187, 101) + ..cubicTo(205, 102, 217, 113, 227, 122) + ..cubicTo(237, 130, 247, 133, 247, 142) + ..cubicTo(247, 149, 240, 152, 232, 150) + ..cubicTo(236, 163, 225, 170, 213, 165) + ..cubicTo(193, 160, 175, 150, 151, 150) + ..cubicTo(110, 149, 49, 172, -12, 171) + ..close(); + cv.drawPath(arm, fill); + cv.drawPath(arm, outline); + cv.drawPath( + Path() + ..moveTo(179, 116) + ..quadraticBezierTo(192, 113, 202, 123) + ..lineTo(224, 144) + ..quadraticBezierTo(230, 150, 236, 150) + ..moveTo(196, 143) + ..quadraticBezierTo(204, 155, 218, 157), + crease, + ); + + // Wide fabric wrap with a raised, screenless capsule. Short cross-lines + // suggest the woven strap; the two inset metal pads sit on opposing edges. + cv.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(118, 99, 60, 64), + const Radius.circular(11), + ), + Paint()..color = band, + ); + final weave = Paint() + ..color = skin.withValues(alpha: .35) + ..strokeWidth = 1; + for (var y = 104.0; y <= 156; y += 5) { + cv.drawLine(Offset(122, y), Offset(174, y), weave); + } + final capsule = RRect.fromRectAndRadius( + const Rect.fromLTWH(126, 94, 45, 64), + const Radius.circular(12), + ); + cv.drawRRect(capsule, Paint()..color = band); + cv.drawPath( + Path() + ..moveTo(137, 103) + ..quadraticBezierTo(132, 104, 132, 111) + ..lineTo(132, 141) + ..moveTo(165, 111) + ..lineTo(165, 141) + ..quadraticBezierTo(165, 148, 160, 149), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = skin.withValues(alpha: .5), + ); + final metal = Paint()..color = Color.lerp(skin, ink2, .35)!; + for (final y in [91.0, 153.0]) { + cv.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(138, y, 21, 8), + const Radius.circular(4), + ), + metal, + ); + } + + // One continuous opposite-hand silhouette: bent index above, palm and + // wrist at the right, and a shorter, broader thumb below. The open web + // between index and thumb exposes the band and the resting wrist. + final hand = Path() + ..moveTo(372, 68) + ..lineTo(306, 68) + ..cubicTo(289, 68, 278, 52, 260, 44) + ..cubicTo(237, 33, 207, 30, 185, 38) + ..cubicTo(164, 45, 145, 61, 139, 79) + ..cubicTo(135, 89, 140, 94, 148, 94) + ..cubicTo(155, 94, 159, 89, 163, 82) + ..cubicTo(172, 68, 190, 60, 207, 60) + ..cubicTo(225, 60, 241, 71, 250, 88) + ..cubicTo(259, 104, 260, 120, 249, 134) + ..cubicTo(238, 148, 216, 158, 194, 159) + ..cubicTo(177, 160, 166, 151, 153, 156) + ..cubicTo(144, 159, 144, 168, 151, 173) + ..cubicTo(165, 184, 189, 187, 211, 183) + ..cubicTo(238, 179, 262, 169, 283, 156) + ..quadraticBezierTo(299, 147, 317, 149) + ..lineTo(372, 159) + ..close(); + cv.drawPath(hand, fill); + cv.drawPath(hand, outline); + + // Nails at the two tips, finger-joint folds and the thumb's thenar crease + // give the pinch anatomical cues without competing with the contact pads. + cv.drawPath( + Path() + ..moveTo(143, 80) + ..quadraticBezierTo(144, 73, 150, 69) + ..quadraticBezierTo(156, 70, 158, 75) + ..lineTo(152, 85) + ..quadraticBezierTo(146, 87, 143, 80) + ..moveTo(153, 164) + ..quadraticBezierTo(160, 159, 170, 164) + ..lineTo(174, 172) + ..quadraticBezierTo(162, 175, 155, 170) + ..moveTo(181, 44) + ..quadraticBezierTo(187, 48, 189, 54) + ..moveTo(226, 43) + ..quadraticBezierTo(224, 48, 225, 52) + ..moveTo(200, 166) + ..quadraticBezierTo(202, 171, 201, 176) + ..moveTo(271, 114) + ..cubicTo(280, 133, 263, 151, 245, 158) + ..moveTo(308, 81) + ..quadraticBezierTo(300, 91, 303, 103), + crease, + ); + + // Always-visible targets teach the same pose at every frozen phase. A + // seamless, gentle breath draws attention before contact; then it settles. + final pulse = .5 - .5 * math.cos(t * 2 * math.pi); + final radius = contact ? 8.0 : 10.0 + 4 * pulse; + final halo = Paint() + ..color = accent.withValues(alpha: contact ? .12 : .10 + .06 * pulse); + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = contact ? 1.5 : 1.8 + ..color = accent.withValues(alpha: contact ? .55 : .45 + .25 * pulse); + for (final point in [const Offset(148, 95), const Offset(148, 157)]) { + cv.drawCircle(point, radius, halo); + cv.drawCircle(point, radius, ring); + cv.drawCircle(point, 4, Paint()..color = ink); + cv.drawCircle(point, 2.6, Paint()..color = accent); + } + cv.restore(); + } + + @override + bool shouldRepaint(_TouchPainter o) => + o.t != t || + o.contact != contact || + o.wrist != wrist || + o.ink != ink || + o.ink2 != ink2 || + o.band != band || + o.accent != accent || + o.skin != skin; +} + +/// The live preview: the newest few seconds of real samples, a stable +/// symmetric range, one repaint per scheduler tick. Labelled as a preview — +/// it is not the reading and not an analysis. +class EcgLivePreview extends StatelessWidget { + final EcgWaveformBuffer buffer; + final EcgPreviewScheduler scheduler; + final String label; + final String unit; + + const EcgLivePreview({ + super.key, + required this.buffer, + required this.scheduler, + required this.label, + required this.unit, + }); + + @override + Widget build(BuildContext context) { + final p = P.of(context); + return Semantics( + label: label, + child: Surface( + pad: const EdgeInsets.all(S.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(label, style: F.cap.copyWith(color: p.ink3)), + ), + Text(unit, style: F.cap.copyWith(color: p.ink3)), + ], + ), + const SizedBox(height: S.x2), + SizedBox( + height: 96, + child: RepaintBoundary( + child: _SchedulerRepaint( + scheduler: scheduler, + builder: (_) => CustomPaint( + painter: EcgLivePainter( + buffer: buffer, + version: buffer.version, + color: C.domHealth, + grid: p.line, + ), + size: Size.infinite, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Rebuilds its child when the scheduler ticks (and only then). +class _SchedulerRepaint extends StatefulWidget { + final EcgPreviewScheduler scheduler; + final WidgetBuilder builder; + const _SchedulerRepaint({required this.scheduler, required this.builder}); + + @override + State<_SchedulerRepaint> createState() => _SchedulerRepaintState(); +} + +class _SchedulerRepaintState extends State<_SchedulerRepaint> { + @override + void initState() { + super.initState(); + widget.scheduler.addListener(_onTick); + } + + @override + void dispose() { + widget.scheduler.removeListener(_onTick); + super.dispose(); + } + + void _onTick() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => widget.builder(context); +} + +/// Symmetric ±range in µV for a window whose largest magnitude is [maxAbs]: +/// stepped so it does not jitter packet to packet, never below the floor. +int ecgPreviewRange(int maxAbs, {int step = 250, int floor = 500}) { + final stepped = ((maxAbs + step - 1) ~/ step) * step; + return math.max(floor, stepped); +} + +class EcgLivePainter extends CustomPainter { + final EcgWaveformBuffer buffer; + final int version; + final Color color; + final Color grid; + + EcgLivePainter({ + required this.buffer, + required this.version, + required this.color, + required this.grid, + }); + + @override + void paint(Canvas cv, Size s) { + final gridPaint = Paint() + ..color = grid + ..strokeWidth = 1; + cv.drawLine( + Offset(0, s.height / 2), + Offset(s.width, s.height / 2), + gridPaint, + ); + final n = buffer.length; + if (n < 2 || s.width <= 0) return; + final range = ecgPreviewRange(buffer.maxAbs()).toDouble(); + final cap = buffer.capacity; + // The window is the ring's capacity; a partly-filled ring draws from the + // right so the trace scrolls in rather than stretching. + final dx = s.width / (cap - 1); + final x0 = s.width - (n - 1) * dx; + final path = Path(); + for (var i = 0; i < n; i++) { + final v = buffer[i].clamp(-range, range); + final y = s.height / 2 - v / range * (s.height / 2 - 2); + final x = x0 + i * dx; + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + cv.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeJoin = StrokeJoin.round + ..color = color, + ); + } + + @override + bool shouldRepaint(EcgLivePainter o) => + o.version != version || o.buffer != buffer || o.color != color; +} + +/// The complete accepted window of a saved reading. A placeholder packet is +/// a visible break — a one-second hole in the trace, never a line across it. +/// Horizontal scale is [pxPerSecond]; the caller wraps it in a horizontal +/// scroll view at the width [widthFor] reports. +class EcgWaveformPainter extends CustomPainter { + final List packets; + final double pxPerSecond; + final Color color; + final Color grid; + final Color gap; + + EcgWaveformPainter({ + required this.packets, + required this.pxPerSecond, + required this.color, + required this.grid, + required this.gap, + }); + + /// One second per packet (100 samples at 100 Hz), placeholders included. + static double widthFor(List packets, double pxPerSecond) => + math.max(1, packets.length) * pxPerSecond; + + static int rangeFor(List packets) { + var m = 0; + for (final p in packets) { + for (final v in p.samples) { + if (v.abs() > m) m = v.abs(); + } + } + return ecgPreviewRange(m); + } + + @override + void paint(Canvas cv, Size s) { + if (packets.isEmpty) return; + final range = rangeFor(packets).toDouble(); + final mid = s.height / 2; + final gridPaint = Paint() + ..color = grid + ..strokeWidth = 1; + // One-second grid. + for (var i = 0; i <= packets.length; i++) { + final x = i * pxPerSecond; + cv.drawLine(Offset(x, 0), Offset(x, s.height), gridPaint); + } + cv.drawLine(Offset(0, mid), Offset(s.width, mid), gridPaint); + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeJoin = StrokeJoin.round + ..color = color; + final gapPaint = Paint()..color = gap; + var x = 0.0; + Path? path; + for (final p in packets) { + if (p.placeholder || p.samples.isEmpty) { + // Break the trace and wash the missing second. + if (path != null) cv.drawPath(path, stroke); + path = null; + cv.drawRect(Rect.fromLTWH(x, 0, pxPerSecond, s.height), gapPaint); + x += pxPerSecond; + continue; + } + final n = p.samples.length; + final dx = pxPerSecond / kEcgSampleRateHz; + for (var i = 0; i < n; i++) { + final v = p.samples[i].clamp(-range, range); + final y = mid - v / range * (mid - 2); + final px = x + i * dx; + if (path == null) { + path = Path()..moveTo(px, y); + } else { + path.lineTo(px, y); + } + } + x += pxPerSecond; + } + if (path != null) cv.drawPath(path, stroke); + } + + @override + bool shouldRepaint(EcgWaveformPainter o) => + o.packets != packets || o.pxPerSecond != pxPerSecond || o.color != color; +} diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 0b5d5ca60..1b1d7f2f6 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -23,6 +23,7 @@ import 'dart:convert'; import 'dart:math'; import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:file_picker/file_picker.dart'; @@ -33,6 +34,9 @@ import 'package:share_plus/share_plus.dart'; import '../../coach/coach_config.dart'; import '../../data/day_label.dart'; +import '../../ecg/ecg_controller.dart'; +import '../../ecg/ecg_models.dart'; +import '../../ecg/ecg_waveform_buffer.dart'; import '../../data/journal_fields.dart'; import '../../data/med_store.dart'; import '../../data/nutrition_store.dart'; @@ -171,6 +175,39 @@ Map goldenCases() => { // shot because a 9:16 card is where the column's arithmetic has the // most room to go wrong, not because it is a different design. 'share_card_story': _shareCard(photo: false, format: PosterFormat.story), + // WHOOP MG ECG: the touch illustration frozen at one phase, the live + // preview over a synthetic trace, the capture body mid-measurement, and + // one history row. The synthetic trace is a gallery fixture, labelled + // nowhere as a reading. + 'ecg_illustration': const EcgTouchIllustration( + wrist: EcgWrist.right, + t: .3, + contact: true, + semanticLabel: 'Illustration: the band on your wrist, and the thumb ' + 'and index finger of your other hand touching its two metal sides.'), + 'ecg_preview': EcgLivePreview( + buffer: _ecgDemoBuffer(), + scheduler: EcgPreviewScheduler(), + label: 'Live signal preview', + unit: 'µV'), + 'ecg_capture_body': SizedBox( + height: 620, + child: EcgCaptureBody( + state: const EcgCaptureState( + phase: EcgCapturePhase.active, progress: 42, liveHr: 71), + wrist: EcgWrist.right, + phase: .3, + live: _ecgDemoBuffer(), + scheduler: EcgPreviewScheduler(), + onRetry: () {}, + onTakeAnother: () {}, + onDone: () {}, + onView: () {}, + ), + ), + 'ecg_reading_row': Surface( + pad: EdgeInsets.zero, + child: EcgReadingRow(reading: _ecgDemoReading, onTap: () {})), 'signal': const SignalCard( LucideIcons.heartPulse, C.blue, 'Resting heart rate', '52', unit: 'bpm', sub: '4 BELOW YOUR BASELINE'), @@ -2415,3 +2452,41 @@ class _GalleryScreenState extends State { ); } } + +// ── WHOOP MG ECG gallery fixtures ───────────────────────────────────────── + +/// A synthetic, ECG-shaped trace for the preview case: a slow wave with a +/// sharp spike each second. A fixture for the eye, never presented as data. +EcgWaveformBuffer _ecgDemoBuffer() { + final b = EcgWaveformBuffer(capacity: 600); + b.push(Int16List.fromList(List.generate(600, (i) { + final wave = (120 * sin(i / 100 * 2 * pi)).round(); + final spike = (i % 100) == 30 ? 650 : (i % 100) == 32 ? -220 : 0; + return wave + spike; + }))); + return b; +} + +const EcgReading _ecgDemoReading = EcgReading( + id: 'ecg_gallery', + deviceId: '', + wrist: EcgWrist.left, + startTs: 1787823754, + endTs: 1787823784, + strapTerminalTs: 1787823784, + strapTerminalSubsec: 0, + resultCode: 1, + category: EcgCategory.sinusRhythm, + avgHr: 77, + quality: 3, + unreadableMask: 0, + interruptions: 0, + sampleCount: 3000, + minUv: -531, + maxUv: 731, + rmsUv: 126.8, + missingSegments: 0, + status: EcgReadingStatus.completed, + notes: null, + createdAt: 1787823784000, +); diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 8ed400787..52732a929 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -54,6 +54,20 @@ bool coachReady(BuildContext c) { } } +/// [coachReady] for an event handler, which must not listen. +/// +/// `watch` outside `build` trips a provider assert, and the catch above turns +/// that into a plain "not configured" — so a tap handler asking [coachReady] +/// sends a fully configured user to the setup form every time. +bool coachReadyNow(BuildContext c) { + try { + final cfg = c.read(); + return cfg.configured || cfg.keyUnreadable; + } catch (_) { + return false; + } +} + /// What the Profile row should say under "AI coach", or null when there is no /// [CoachConfig] above this context at all. /// @@ -75,7 +89,18 @@ String? coachSubtitle(BuildContext c) { } class CoachScreen extends StatefulWidget { - const CoachScreen({super.key}); + /// A message to send the moment the engine is up — visibly, as the user's + /// own turn, so the model runs its tools on it like any other question + /// (nothing is injected as trusted prose). [startNewSession] opens a fresh + /// conversation for it first. + final String? initialMessage; + final bool startNewSession; + + const CoachScreen({ + super.key, + this.initialMessage, + this.startNewSession = false, + }); @override State createState() => _CoachScreenState(); @@ -123,6 +148,7 @@ class _CoachScreenState extends State { engine.dispose(); return; } + if (widget.startNewSession) engine.newSession(); setState(() { _engine = engine; _items @@ -130,8 +156,15 @@ class _CoachScreenState extends State { ..addAll(engine.transcript); }); _scrollDown(); + final first = widget.initialMessage; + if (first != null && first.trim().isNotEmpty && !_sentInitial) { + _sentInitial = true; + await _send(first); + } } + bool _sentInitial = false; + @override void dispose() { _input.dispose(); diff --git a/lib/ui2/screens/ecg.dart b/lib/ui2/screens/ecg.dart new file mode 100644 index 000000000..8d4697594 --- /dev/null +++ b/lib/ui2/screens/ecg.dart @@ -0,0 +1,1028 @@ +// WHOOP MG ECG — the Heart Screener entry (history + Take ECG), the capture +// screen and the reading detail. +// +// The entry is gated on the paired band being a REMEMBERED WHOOP MG; inside +// it, saved readings read fine while the band is away and "Take ECG" needs +// the MG connected and READY. Everything shown as a result is the band's own +// category — labelled so — never a phone-side classification. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../coach/coach_config.dart'; +import '../../data/db.dart'; +import '../../ecg/ecg_controller.dart'; +import '../../ecg/ecg_models.dart'; +import '../../ecg/ecg_waveform_buffer.dart'; +import '../../l10n/app_localizations.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme_switcher.dart' show themedRoute; +import '../ui2.dart'; +import 'coach.dart'; +import 'home_screen.dart' show go, pad; + +/// Whether the paired band is a remembered WHOOP MG — false outside an +/// AppState (goldens), like every other provider read in this folder. +bool pairedIsMaverickOf(BuildContext c) { + try { + return c.watch().pairedIsMaverick; + } catch (_) { + return false; + } +} + +String ecgCategoryLabel(AppLocalizations? l, EcgCategory c) => switch (c) { + EcgCategory.sinusRhythm => l?.ecgCategorySinus ?? 'Sinus rhythm', + EcgCategory.lowHeartRate => l?.ecgCategoryLowHr ?? 'Low heart rate', + EcgCategory.possibleAfib => l?.ecgCategoryPossibleAfib ?? 'Possible AFib', + EcgCategory.afibHighHeartRate => + l?.ecgCategoryAfibHighHr ?? 'AFib with high heart rate', + EcgCategory.highHeartRate => l?.ecgCategoryHighHr ?? 'High heart rate', + EcgCategory.highHeartRateNoAfib => + l?.ecgCategoryHighHrNoAfib ?? 'High heart rate, no AFib detected', + EcgCategory.inconclusive => l?.ecgCategoryInconclusive ?? 'Inconclusive', + EcgCategory.unreadable => l?.ecgCategoryUnreadable ?? 'Unreadable', +}; + +List ecgReasonLabels(AppLocalizations? l, int mask) => [ + if (mask & 0x01 != 0) l?.ecgReasonLowAmplitude ?? 'Low amplitude', + if (mask & 0x02 != 0) l?.ecgReasonNoise ?? 'Significant noise', + if (mask & 0x04 != 0) l?.ecgReasonUnstable ?? 'Unstable signal', + if (mask & 0x08 != 0) l?.ecgReasonNotEnoughData ?? 'Not enough data', +]; + +String _wristLabel(AppLocalizations? l, EcgWrist w) => w == EcgWrist.left + ? (l?.ecgWristLeft ?? 'Left wrist') + : (l?.ecgWristRight ?? 'Right wrist'); + +String _fmtWhen(int epochS) { + final d = DateTime.fromMillisecondsSinceEpoch(epochS * 1000); + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}'; +} + +// ═══════════════════ entry card (Health overview) ═══════════════════ + +/// The Health-overview door. Only built when [pairedIsMaverickOf] is true. +class EcgEntryCard extends StatelessWidget { + const EcgEntryCard({super.key}); + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + return ActionCard( + l?.ecgHeartScreener ?? 'Heart Screener', + l?.ecgEntryMeta ?? 'WHOOP MG · band-reported', + l?.ecgOpen ?? 'Open', + LucideIcons.activity, + C.domHealth, + onTap: () => go(c, const EcgHomeScreen()), + ); + } +} + +// ═══════════════════ home: history + Take ECG ═══════════════════ + +class EcgHomeScreen extends StatefulWidget { + const EcgHomeScreen({super.key}); + + @override + State createState() => _EcgHomeScreenState(); +} + +class _EcgHomeScreenState extends State { + List _readings = const []; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final rows = await LocalDb.listEcgReadings(); + final list = [for (final r in rows) ?EcgReading.fromRow(r)]; + if (!mounted) return; + setState(() { + _readings = list; + _loaded = true; + }); + } catch (_) { + if (mounted) setState(() => _loaded = true); + } + } + + Future _take(BuildContext c, AppState app) async { + final serial = app.ecg.transport.serial; + final remembered = serial == null + ? null + : await app.ecg.guard.wrist(serial); + if (!c.mounted) return; + final wrist = await showModalBottomSheet( + context: c, + sheetAnimationStyle: sheetMotion(c), + builder: (_) => EcgWristSheet(current: remembered), + ); + if (wrist == null || !c.mounted) return; + await Navigator.of(c).push( + themedRoute( + (_) => EcgCaptureScreen(wrist: wrist), + name: 'EcgCaptureScreen', + ), + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + final id = app.ecg.state.readingId; + if (app.ecg.state.phase == EcgCapturePhase.completed && id != null) { + unawaited(_openDetail(context, id)); + } + } + + Future _openDetail(BuildContext c, String id) async { + final data = await EcgDetailData.load(id); + if (!c.mounted || data == null) return; + await Navigator.of(c).push( + themedRoute((_) => EcgDetailScreen(data: data), name: 'EcgDetailScreen'), + ); + if (mounted) await _load(); + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + final app = c.watch(); + final canTake = app.engine.isConnected && app.engine.isMaverick; + return Scaffold( + backgroundColor: p.bg, + appBar: AppBar( + backgroundColor: p.bg, + title: Text(l?.ecgHeartScreener ?? 'Heart Screener'), + ), + body: ListView( + padding: pad, + children: [ + ActionCard( + l?.ecgTakeEcg ?? 'Take ECG', + canTake + ? (l?.ecgEntryMeta ?? 'WHOOP MG · band-reported') + : (l?.ecgNeedsMg ?? 'Take ECG needs a connected WHOOP MG.'), + l?.ecgTakeEcg ?? 'Take ECG', + LucideIcons.heartPulse, + C.domHealth, + onTap: canTake ? () => _take(c, app) : null, + ), + const SizedBox(height: S.x4), + if (_loaded && _readings.isEmpty) + StatusCard( + l?.ecgHistoryEmpty ?? 'No readings yet.', + l?.ecgHistoryEmptyWhy ?? + 'Readings you take are saved here and stay readable while ' + 'the band is away.', + icon: LucideIcons.activity, + ), + if (_readings.isNotEmpty) + Section( + l?.ecgTitle ?? 'ECG', + Surface( + pad: const EdgeInsets.symmetric(vertical: S.x1), + child: Column( + children: [ + for (var i = 0; i < _readings.length; i++) ...[ + if (i > 0) Divider(color: p.line, height: 1), + EcgReadingRow( + reading: _readings[i], + onTap: () => _openDetail(c, _readings[i].id), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +/// One saved reading in the history list. +class EcgReadingRow extends StatelessWidget { + final EcgReading reading; + final VoidCallback? onTap; + const EcgReadingRow({super.key, required this.reading, this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + final cat = ecgCategoryLabel(l, reading.category); + final hr = reading.avgHr; + return Pressable( + onTap: onTap, + semanticLabel: + '$cat, ${hr == null ? '' : '$hr bpm, '}${_fmtWhen(reading.startTs)}', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x4, vertical: S.x3), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(cat, style: F.body.copyWith(color: p.ink)), + const SizedBox(height: S.x1), + Text( + '${_fmtWhen(reading.startTs)} · ${_wristLabel(l, reading.wrist)}', + style: F.cap.copyWith(color: p.ink3), + ), + ], + ), + ), + if (hr != null) Text('$hr', style: F.n24.copyWith(color: p.ink)), + if (hr != null) const SizedBox(width: S.x1), + if (hr != null) Text('bpm', style: F.cap.copyWith(color: p.ink3)), + const SizedBox(width: S.x2), + Icon(LucideIcons.chevronRight, size: 16, color: p.ink3), + ], + ), + ), + ); + } +} + +/// "Which wrist is the band on?" — pops the choice. +class EcgWristSheet extends StatelessWidget { + final EcgWrist? current; + const EcgWristSheet({super.key, this.current}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + Widget option(EcgWrist w, IconData icon) => Pressable( + semanticLabel: _wristLabel(l, w), + onTap: () => Navigator.of(c).pop(w), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x5, vertical: S.x4), + child: Row( + children: [ + Icon(icon, size: 20, color: p.ink2), + const SizedBox(width: S.x3), + Expanded( + child: Text( + _wristLabel(l, w), + style: F.body.copyWith(color: p.ink), + ), + ), + if (current == w) + Icon(LucideIcons.check, size: 18, color: C.domHealth), + ], + ), + ), + ); + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(S.x5, S.x5, S.x5, S.x2), + child: Text( + l?.ecgWristPrompt ?? 'Which wrist is the band on?', + style: F.head.copyWith(color: p.ink), + ), + ), + option(EcgWrist.left, LucideIcons.arrowLeft), + option(EcgWrist.right, LucideIcons.arrowRight), + const SizedBox(height: S.x3), + ], + ), + ); + } +} + +// ═══════════════════ capture ═══════════════════ + +class EcgCaptureScreen extends StatefulWidget { + final EcgWrist wrist; + + /// The controller to drive; defaults to the app's. Tests hand in their own. + final EcgController? controller; + const EcgCaptureScreen({super.key, required this.wrist, this.controller}); + + @override + State createState() => _EcgCaptureScreenState(); +} + +class _EcgCaptureScreenState extends State + with SingleTickerProviderStateMixin { + EcgController? _c; + Ticker? _ticker; + Timer? _slowTick; + double _phase = 0; + Duration _lastPreview = Duration.zero; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final c = widget.controller ?? context.read().ecg; + c.addListener(_onController); + setState(() => _c = c); + _startClock(); + unawaited(c.begin(widget.wrist)); + }); + } + + void _startClock() { + if (Motion.enabled(context)) { + _ticker = createTicker(_onTick)..start(); + } else { + // Reduced motion: no animation, but the live preview still needs a + // clock to repaint on — one coalesced repaint per second. + _slowTick = Timer.periodic(Motion.tick, (_) { + _c?.preview.tick(); + }); + } + } + + void _onTick(Duration elapsed) { + final pulseMs = Motion.ecgPulse.inMilliseconds; + final t = (elapsed.inMilliseconds % pulseMs) / pulseMs; + if (elapsed - _lastPreview >= Motion.ecgPreviewTick) { + _lastPreview = elapsed; + _c?.preview.tick(); + } + if (mounted) setState(() => _phase = t); + } + + void _onController() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _ticker?.dispose(); + _slowTick?.cancel(); + _c?.removeListener(_onController); + // Leaving the screen by any route stops the reading (fire-and-forget: + // the controller's own cleanup path is idempotent). + final c = _c; + if (c != null && c.isCapturing) unawaited(c.cancel()); + super.dispose(); + } + + Future _close(BuildContext c) async { + final ctl = _c; + if (ctl != null && ctl.isCapturing) await ctl.cancel(); + if (c.mounted) Navigator.of(c).pop(); + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + final ctl = _c; + final s = ctl?.state ?? const EcgCaptureState(); + final busy = ctl?.isCapturing ?? false; + return PopScope( + canPop: !busy, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || !busy) return; + await _close(c); + }, + child: Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Pressable( + semanticLabel: l?.ecgClose ?? 'Close ECG', + onTap: () => _close(c), + child: Icon(LucideIcons.x, size: 22, color: p.ink2), + ), + ), + Expanded( + child: ctl == null + ? const SizedBox.shrink() + : EcgCaptureBody( + state: s, + wrist: widget.wrist, + phase: _phase, + live: ctl.live, + scheduler: ctl.preview, + onRetry: ctl.retry, + onTakeAnother: () => ctl.begin(widget.wrist), + onDone: () => _close(c), + onView: () async { + final id = s.readingId; + if (id == null) return; + final data = await EcgDetailData.load(id); + if (!c.mounted || data == null) return; + await Navigator.of(c).pushReplacement( + themedRoute( + (_) => EcgDetailScreen(data: data), + name: 'EcgDetailScreen', + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The capture screen's content for one [state] — pure, data in, callbacks +/// out, so every phase can be pumped in a test without a band. +class EcgCaptureBody extends StatelessWidget { + final EcgCaptureState state; + final EcgWrist wrist; + final double phase; + final EcgWaveformBuffer live; + final EcgPreviewScheduler scheduler; + final VoidCallback onRetry; + final VoidCallback onTakeAnother; + final VoidCallback onDone; + final VoidCallback onView; + + const EcgCaptureBody({ + super.key, + required this.state, + required this.wrist, + required this.phase, + required this.live, + required this.scheduler, + required this.onRetry, + required this.onTakeAnother, + required this.onDone, + required this.onView, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + final s = state; + Widget title(String t) => Text(t, style: F.t2.copyWith(color: p.ink)); + Widget body(String t) => + Text(t, style: F.body.copyWith(color: p.ink2, height: 1.4)); + Widget button(String label, VoidCallback? onTap, {bool primary = true}) => + Pressable( + semanticLabel: label, + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: S.x3), + decoration: BoxDecoration( + color: primary ? p.fill(C.domHealth) : p.card2, + borderRadius: R.rMd, + ), + child: Text( + label, + style: F.body.copyWith( + color: primary ? p.inkOnFill : p.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + + final capturing = switch (s.phase) { + EcgCapturePhase.recovering || + EcgCapturePhase.preparing || + EcgCapturePhase.starting || + EcgCapturePhase.waiting || + EcgCapturePhase.active || + EcgCapturePhase.contactLost || + EcgCapturePhase.restarting => true, + _ => false, + }; + final armed = switch (s.phase) { + EcgCapturePhase.starting || + EcgCapturePhase.waiting || + EcgCapturePhase.active || + EcgCapturePhase.contactLost || + EcgCapturePhase.restarting => true, + _ => false, + }; + final measuring = + s.phase == EcgCapturePhase.active || + s.phase == EcgCapturePhase.contactLost || + s.phase == EcgCapturePhase.restarting; + + if (capturing) { + final status = switch (s.phase) { + EcgCapturePhase.recovering => + l?.ecgRecovering ?? 'Stopping a previous reading first…', + EcgCapturePhase.preparing => l?.ecgPreparing ?? 'Preparing the band…', + EcgCapturePhase.starting || + EcgCapturePhase.waiting => l?.ecgWaiting ?? 'Waiting for contact', + EcgCapturePhase.active => l?.ecgMeasuring ?? 'Measuring', + EcgCapturePhase.contactLost => + l?.ecgContactLost ?? 'Adjust your fingers and keep still', + EcgCapturePhase.restarting => l?.ecgRestarting ?? 'Restarting…', + _ => '', + }; + return ListView( + padding: const EdgeInsets.only(bottom: S.x8), + children: [ + const SizedBox(height: S.x2), + EcgTouchIllustration( + wrist: wrist, + t: phase, + contact: measuring, + semanticLabel: + l?.ecgIllustration ?? + 'Illustration: the band on your wrist, and the thumb and index ' + 'finger of your other hand touching its two metal sides.', + ), + const SizedBox(height: S.x4), + body( + l?.ecgInstruction ?? + 'Rest your arm. Touch both metal sides with your opposite thumb ' + 'and index finger. Keep still.', + ), + const SizedBox(height: S.x4), + Text( + status, + key: const ValueKey('ecg-status'), + style: F.head.copyWith( + color: s.phase == EcgCapturePhase.contactLost ? C.orange : p.ink, + ), + ), + if (measuring) ...[ + const SizedBox(height: S.x2), + Semantics( + label: l?.ecgProgress(s.progress) ?? '${s.progress}% complete', + child: ClipRRect( + borderRadius: R.rSm, + child: LinearProgressIndicator( + value: s.progress / 100, + minHeight: 8, + backgroundColor: p.track, + color: C.domHealth, + ), + ), + ), + const SizedBox(height: S.x2), + Row( + children: [ + Text( + l?.ecgProgress(s.progress) ?? '${s.progress}% complete', + style: F.cap.copyWith(color: p.ink3), + ), + const Spacer(), + if (s.liveHr != null) ...[ + Text('${s.liveHr}', style: F.n24.copyWith(color: p.ink)), + const SizedBox(width: S.x1), + Text('bpm', style: F.cap.copyWith(color: p.ink3)), + ], + ], + ), + ], + if (armed) ...[ + const SizedBox(height: S.x4), + EcgLivePreview( + buffer: live, + scheduler: scheduler, + label: l?.ecgLivePreview ?? 'Live signal preview', + unit: 'µV', + ), + ], + ], + ); + } + + switch (s.phase) { + case EcgCapturePhase.idle: + return const SizedBox.shrink(); + case EcgCapturePhase.incompatible: + return StatusCard( + l?.ecgIncompatible ?? 'This band is not a WHOOP MG.', + l?.ecgNeedsMg ?? 'Take ECG needs a connected WHOOP MG.', + icon: LucideIcons.circleOff, + ); + case EcgCapturePhase.disconnected: + return StatusCard( + l?.ecgDisconnected ?? 'Connect your WHOOP MG first.', + l?.ecgNeedsMg ?? 'Take ECG needs a connected WHOOP MG.', + icon: LucideIcons.bluetoothOff, + ); + case EcgCapturePhase.busy: + return StatusCard( + l?.ecgBusy ?? 'Finish the other live session first.', + s.reason ?? '', + icon: LucideIcons.hourglass, + ); + case EcgCapturePhase.saving: + case EcgCapturePhase.cleaningUp: + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: S.x4), + body( + s.phase == EcgCapturePhase.saving + ? (l?.ecgSaving ?? 'Saving…') + : (l?.ecgCleaningUp ?? 'Stopping the band…'), + ), + ], + ), + ); + case EcgCapturePhase.completed: + return ListView( + children: [ + const SizedBox(height: S.x6), + title(l?.ecgCompleted ?? 'Reading saved'), + const SizedBox(height: S.x2), + body( + l?.ecgNotDiagnosis ?? + 'The category comes from the band. This is not a diagnosis.', + ), + if (s.cleanupIncomplete) ...[ + const SizedBox(height: S.x3), + body( + l?.ecgCleanupIncomplete ?? + 'The band may still be generating. It will be stopped on ' + 'the next connection.', + ), + ], + const SizedBox(height: S.x6), + button(l?.ecgViewReading ?? 'View reading', onView), + const SizedBox(height: S.x3), + button(l?.ecgDone ?? 'Done', onDone, primary: false), + ], + ); + case EcgCapturePhase.unreadable: + final reasons = ecgReasonLabels(l, s.unreadableMask); + return ListView( + children: [ + const SizedBox(height: S.x6), + title(l?.ecgUnreadableTitle ?? 'The band could not read this'), + const SizedBox(height: S.x2), + body(l?.ecgBandReported ?? 'Band-reported result'), + for (final r in reasons) ...[ + const SizedBox(height: S.x1), + Text('· $r', style: F.body.copyWith(color: p.ink)), + ], + const SizedBox(height: S.x6), + button(l?.ecgTakeAnother ?? 'Take another', onTakeAnother), + const SizedBox(height: S.x3), + button(l?.ecgDone ?? 'Done', onDone, primary: false), + ], + ); + case EcgCapturePhase.inconclusiveRetry: + return ListView( + children: [ + const SizedBox(height: S.x6), + title(l?.ecgInconclusiveTitle ?? 'Inconclusive'), + const SizedBox(height: S.x2), + body( + l?.ecgInconclusiveRetryHint ?? + 'The band could not decide. You can try once more.', + ), + const SizedBox(height: S.x6), + button(l?.ecgTryOnceMore ?? 'Try once more', onRetry), + const SizedBox(height: S.x3), + button(l?.ecgDone ?? 'Done', onDone, primary: false), + ], + ); + case EcgCapturePhase.cancelled: + case EcgCapturePhase.failed: + final why = switch (s.reason) { + 'disconnected' => + l?.ecgFailedDisconnected ?? 'The band disconnected.', + 'timeout' => l?.ecgFailedTimeout ?? 'No result within two minutes.', + 'cancelled' || 'paused' || null => '', + final r => + l?.ecgFailedGeneric(r) ?? + 'The band did not accept the reading ($r).', + }; + return ListView( + children: [ + const SizedBox(height: S.x6), + title( + s.phase == EcgCapturePhase.cancelled + ? (l?.ecgCancelledTitle ?? 'Reading cancelled') + : (l?.ecgFailedTitle ?? 'Reading failed'), + ), + if (why.isNotEmpty) ...[const SizedBox(height: S.x2), body(why)], + if (s.cleanupIncomplete) ...[ + const SizedBox(height: S.x3), + body( + l?.ecgCleanupIncomplete ?? + 'The band may still be generating. It will be stopped on ' + 'the next connection.', + ), + ], + const SizedBox(height: S.x6), + button(l?.ecgTakeAnother ?? 'Take another', onTakeAnother), + const SizedBox(height: S.x3), + button(l?.ecgDone ?? 'Done', onDone, primary: false), + ], + ); + default: + return const SizedBox.shrink(); + } + } +} + +// ═══════════════════ detail ═══════════════════ + +class EcgDetailData { + final EcgReading reading; + final List packets; + const EcgDetailData({required this.reading, required this.packets}); + + static Future load(String id) async { + final row = await LocalDb.ecgReading(id); + final reading = row == null ? null : EcgReading.fromRow(row); + if (reading == null) return null; + final packets = (await LocalDb.ecgReadingPackets( + id, + )).map(EcgPacketCodec.fromRow).toList(); + return EcgDetailData(reading: reading, packets: packets); + } +} + +class EcgDetailScreen extends StatefulWidget { + final EcgDetailData data; + const EcgDetailScreen({super.key, required this.data}); + + @override + State createState() => _EcgDetailScreenState(); +} + +/// The message the coach receives for "Analyze now" — sent visibly as the +/// user's own turn; the model must call `get_ecg_reading` itself. +String ecgAnalyzePrompt(String id) => + 'Analyse my ECG reading $id. Use get_ecg_reading. Start with signal ' + 'quality and the band-reported result, then read the waveform itself — ' + 'rate, rhythm and its regularity, intervals and morphology — and give ' + 'your impression. Say where the trace or its unproven polarity does not ' + 'support a reading, and say so if you disagree with the band.'; + +class _EcgDetailScreenState extends State { + static const _scales = [40.0, 80.0, 160.0, 320.0]; + int _scale = 1; + + Future _analyze(BuildContext c) async { + final l = AppLocalizations.of(c); + final id = widget.data.reading.id; + if (!coachReadyNow(c)) { + await Navigator.of( + c, + ).push(themedRoute((_) => const CoachSetup(), name: 'CoachSetup')); + if (!c.mounted || !coachReadyNow(c)) return; + } + final cfg = c.read(); + if (!cfg.isLocalEndpoint) { + final host = Uri.tryParse(cfg.apiBase)?.host ?? cfg.apiBase; + final ok = await showDialog( + context: c, + builder: (dc) => AlertDialog( + title: Text( + l?.ecgAnalyzeCloudTitle ?? 'Send this reading to your model?', + ), + content: Text( + l?.ecgAnalyzeCloudBody(host, cfg.model) ?? + 'The reading summary and the full waveform (every sample the ' + 'band recorded, 100 per second) will be sent to $host as ' + '${cfg.model}. No raw frames, no band serial.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(false), + child: Text(l?.ecgCancel ?? 'Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dc).pop(true), + child: Text(l?.ecgContinue ?? 'Continue'), + ), + ], + ), + ); + if (ok != true || !c.mounted) return; + } + await Navigator.of(c).push( + themedRoute( + (_) => CoachScreen( + initialMessage: ecgAnalyzePrompt(id), + startNewSession: true, + ), + name: 'CoachScreen', + ), + ); + } + + Future _delete(BuildContext c) async { + await LocalDb.deleteEcgReading(widget.data.reading.id); + if (c.mounted) Navigator.of(c).pop(); + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = AppLocalizations.of(c); + final r = widget.data.reading; + final packets = widget.data.packets; + final px = _scales[_scale]; + final cat = ecgCategoryLabel(l, r.category); + Widget kv(String k, String v) => Padding( + padding: const EdgeInsets.symmetric(vertical: S.x1), + child: Row( + children: [ + Expanded( + child: Text(k, style: F.body.copyWith(color: p.ink2)), + ), + Text(v, style: F.body.copyWith(color: p.ink)), + ], + ), + ); + return Scaffold( + backgroundColor: p.bg, + appBar: AppBar(backgroundColor: p.bg, title: Text(l?.ecgTitle ?? 'ECG')), + body: ListView( + padding: pad, + children: [ + Surface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l?.ecgBandReported ?? 'Band-reported result', + style: F.cap.copyWith(color: p.ink3), + ), + const SizedBox(height: S.x1), + Text(cat, style: F.t2.copyWith(color: p.ink)), + const SizedBox(height: S.x1), + Text(_fmtWhen(r.startTs), style: F.cap.copyWith(color: p.ink3)), + if (r.status == EcgReadingStatus.inconclusive || + r.category == EcgCategory.unreadable) ...[ + const SizedBox(height: S.x2), + for (final reason in ecgReasonLabels(l, r.unreadableMask)) + Text('· $reason', style: F.body.copyWith(color: p.ink)), + ], + const SizedBox(height: S.x3), + Text( + l?.ecgNotDiagnosis ?? + 'The category comes from the band. This is not a diagnosis.', + style: F.cap.copyWith(color: p.ink3), + ), + ], + ), + ), + const SizedBox(height: S.x4), + Section( + l?.ecgWaveformLabel ?? + 'Accepted waveform, microvolts as the band sent them. Gaps are ' + 'missing seconds.', + packets.isEmpty + ? StatusCard( + l?.ecgWaveformEmpty ?? + 'No waveform was saved with this reading.', + '', + icon: LucideIcons.activity, + ) + : Surface( + pad: const EdgeInsets.all(S.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '±${EcgWaveformPainter.rangeFor(packets)} µV', + style: F.cap.copyWith(color: p.ink3), + ), + const Spacer(), + Pressable( + semanticLabel: l?.ecgZoomOut ?? 'Zoom out', + onTap: _scale > 0 + ? () => setState(() => _scale--) + : null, + child: Icon( + LucideIcons.zoomOut, + size: 20, + color: p.ink2, + ), + ), + const SizedBox(width: S.x3), + Pressable( + semanticLabel: l?.ecgZoomIn ?? 'Zoom in', + onTap: _scale < _scales.length - 1 + ? () => setState(() => _scale++) + : null, + child: Icon( + LucideIcons.zoomIn, + size: 20, + color: p.ink2, + ), + ), + ], + ), + const SizedBox(height: S.x2), + SizedBox( + height: 180, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: RepaintBoundary( + child: CustomPaint( + size: Size( + EcgWaveformPainter.widthFor(packets, px), + 180, + ), + painter: EcgWaveformPainter( + packets: packets, + pxPerSecond: px, + color: C.domHealth, + grid: p.line, + gap: p.wash(C.orange), + ), + ), + ), + ), + ), + const SizedBox(height: S.x2), + Text( + l?.ecgSampleNote(r.sampleCount, kEcgSampleRateHz) ?? + '${r.sampleCount} samples at $kEcgSampleRateHz Hz, ' + 'filtered, input-referred µV. No lead or ' + 'polarity is claimed.', + style: F.cap.copyWith(color: p.ink3), + ), + ], + ), + ), + ), + const SizedBox(height: S.x4), + Surface( + child: Column( + children: [ + kv( + l?.ecgAvgHr ?? 'Average heart rate', + r.avgHr == null ? '—' : '${r.avgHr} bpm', + ), + kv( + l?.ecgQuality ?? 'Signal quality', + r.quality == null ? '—' : '${r.quality}', + ), + kv(l?.ecgDuration ?? 'Duration', '${r.durationS} s'), + kv( + l?.ecgInterruptions ?? 'Interruptions', + '${r.interruptions}', + ), + kv( + l?.ecgMissingSegments ?? 'Missing segments', + '${r.missingSegments}', + ), + kv(l?.ecgWristLabel ?? 'Wrist', _wristLabel(l, r.wrist)), + ], + ), + ), + const SizedBox(height: S.x4), + ActionCard( + l?.ecgAnalyzeNow ?? 'Analyze now', + l?.ecgBandReported ?? 'Band-reported result', + l?.ecgAnalyzeNow ?? 'Analyze now', + LucideIcons.sparkles, + kCoachAccent, + onTap: () => _analyze(c), + ), + const SizedBox(height: S.x4), + Pressable( + semanticLabel: l?.ecgDelete ?? 'Delete reading', + onTap: () => _delete(c), + child: Padding( + padding: const EdgeInsets.all(S.x3), + child: Text( + l?.ecgDelete ?? 'Delete reading', + textAlign: TextAlign.center, + style: F.body.copyWith(color: C.red), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 7c3b3b5f7..e69d07590 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; import '../ui2.dart'; import 'circadian_detail.dart'; +import 'ecg.dart' show EcgEntryCard, pairedIsMaverickOf; import 'findings_log.dart'; import 'home_screen.dart'; import 'investigate.dart'; @@ -819,6 +820,12 @@ class _HealthScreenState extends State with RevisionReload { ); return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // WHOOP MG only: the ECG door appears once the paired band has + // positively identified itself as an MG, and stays while it is away. + if (pairedIsMaverickOf(c)) ...[ + const EcgEntryCard(), + const SizedBox(height: S.x3), + ], if (rows.isNotEmpty) Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), diff --git a/lib/ui2/screens/screens.dart b/lib/ui2/screens/screens.dart index e1a493f0b..9c66c0951 100644 --- a/lib/ui2/screens/screens.dart +++ b/lib/ui2/screens/screens.dart @@ -3,6 +3,7 @@ export 'ai_briefing.dart'; export 'beats.dart'; export 'circadian_detail.dart'; export 'coach.dart'; +export 'ecg.dart'; export 'coach_figures.dart'; export 'findings_log.dart'; export 'health_screen.dart'; diff --git a/lib/ui2/theme.dart b/lib/ui2/theme.dart index 88d6c82ac..2abbf1439 100644 --- a/lib/ui2/theme.dart +++ b/lib/ui2/theme.dart @@ -399,6 +399,14 @@ class Motion { /// the screen (see `BreathRing.t`); this is only how long a cycle lasts, and /// the screen must not start it at all when [enabled] is false. static const breath = Duration(seconds: 5); + + /// One pulse of the ECG capture screen's contact rings. Phase is owned by + /// the screen (like [breath]); nothing runs when [enabled] is false. + static const ecgPulse = Duration(seconds: 2); + + /// The ECG live-preview repaint cadence: incoming packets only mark the + /// ring dirty, and the screen's clock repaints at most this often. + static const ecgPreviewTick = Duration(milliseconds: 100); } /// Collapse [d] to zero when the user has asked for reduced motion. Every diff --git a/lib/ui2/ui2.dart b/lib/ui2/ui2.dart index 3b5329080..ed091a627 100644 --- a/lib/ui2/ui2.dart +++ b/lib/ui2/ui2.dart @@ -10,6 +10,7 @@ export 'charts.dart'; export 'community_links.dart'; export 'grammar.dart'; export 'live_hr.dart'; +export 'ecg_widgets.dart'; export 'nudges.dart'; export 'paint_activity.dart'; export 'revision.dart'; diff --git a/pubspec.lock b/pubspec.lock index 9c9ec0da3..bb696813f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -946,8 +946,8 @@ packages: dependency: "direct main" description: path: "." - ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 - resolved-ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 + ref: bc7d8d0df706e40a2546ffde4545263f09d0fecb + resolved-ref: bc7d8d0df706e40a2546ffde4545263f09d0fecb url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index b8b0f4444..a3905a21e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -248,7 +248,14 @@ dependencies: # protocol, true — protocol's PR#47 merged as b8430e4 before #48/#50 # landed. So `fe1464d` carries ZeTime, wearfit and ringconn together; # nothing from this branch's own repin is lost by moving to it. - ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 + # + # protocol main @ bc7d8d0 — the whole point of this repin: PR#54 lands + # the Labrador (WHOOP MG ECG) parser this branch's `lib/ecg/` calls + # (`LabradorR17`, `LabradorR16Raw`, `Gen5HelloInfo.isMaverick`, + # `cmdAbortHistorical`). Also carries #70 (garmin/ultrahuman decode + # fixes, unrelated but already an ancestor). Verified present: + # `git show bc7d8d0:lib/src/labrador.dart | grep 'class LabradorR17'`. + ref: bc7d8d0df706e40a2546ffde4545263f09d0fecb openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git diff --git a/test/ble_safe_trim_test.dart b/test/ble_safe_trim_test.dart index dd84a05a9..bd91dabbf 100644 --- a/test/ble_safe_trim_test.dart +++ b/test/ble_safe_trim_test.dart @@ -59,7 +59,7 @@ void main() { group('P0 — a durable commit that fails must not let the caller ACK', () { test('commit() REPORTS failure instead of swallowing the exception', () async { final d = _drainWith( - (raws, samples, token, {archives, deviceFamily}) async => + (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async => throw StateError('OOM in SqlCommand.getSqlArguments'), ); d.onHistoricalRecord(_raw(1), _sample(1), 24); @@ -75,7 +75,7 @@ void main() { test('a failed commit RE-BUFFERS the records instead of losing them', () async { final d = _drainWith( - (raws, samples, token, {archives, deviceFamily}) async => throw StateError('rollback'), + (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async => throw StateError('rollback'), ); d.onHistoricalRecord(_raw(1), _sample(1), 24); d.onHistoricalRecord(_raw(2), _sample(2), 24); @@ -94,7 +94,7 @@ void main() { // every record AND the archived one. final seenRaws = []; final seenArchives = []; - final d2 = _drainWith((raws, samples, token, {archives, deviceFamily}) async { + final d2 = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { seenRaws.addAll(raws.map((r) => r.hex)); seenArchives.addAll((archives ?? const []).map((a) => a.hex)); }); @@ -117,7 +117,7 @@ void main() { final d = DrainController( onRecord: (sample, raw) async {}, onRecordsBatch: null, - onCommit: (raws, samples, token, {archives, deviceFamily}) async { + onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { if (fail) { await gate.future; throw StateError('rollback'); @@ -147,7 +147,7 @@ void main() { final d = DrainController( onRecord: (sample, raw) async {}, onRecordsBatch: null, - onCommit: (raws, samples, token, {archives, deviceFamily}) async { + onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { if (fail) throw StateError('rollback'); }, onArchive: null, @@ -176,20 +176,20 @@ void main() { }); test('an empty buffer commit does not claim trim advanced', () async { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); expect(await d.commit(_tokenA), isTrue); expect(d.lastTrimAdvanced, isFalse); }); test('archive-only commit still counts as trim advanced', () async { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.onUndecodableRecord(_archive(1)); expect(await d.commit(_tokenA), isTrue); expect(d.lastTrimAdvanced, isTrue); }); test('a successful commit clears the buffer and reports durable', () async { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(await d.commit(_tokenA), isTrue); @@ -333,7 +333,7 @@ void main() { }); test('supportsSafeTrim is true only when onCommit is wired', () { - final withCommit = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final withCommit = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); expect(withCommit.supportsSafeTrim, isTrue); final unbuffered = DrainController( @@ -360,7 +360,7 @@ void main() { test('archive-only + onCommit still persists before success', () async { final seen = []; - final ok = _drainWith((raws, samples, token, {archives, deviceFamily}) async { + final ok = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { seen.addAll((archives ?? const []).map((a) => a.hex)); }); ok.onUndecodableRecord(_archive(9)); @@ -390,7 +390,7 @@ void main() { group('P0 — a discarded burst poisons its HISTORY_END token', () { test('discardOpenChunk marks the open burst un-ACKable', () async { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(d.burstDiscarded, isFalse); @@ -413,7 +413,7 @@ void main() { }); test('poisons even when the open buffer is already empty', () { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.discardOpenChunk(); expect(d.burstDiscarded, isTrue); }); @@ -424,7 +424,7 @@ void main() { // which cleared the latch. The abandoned burst's HISTORY_END was still in // flight, landed on a clean guard, and got ACKed verbatim: the band // trimmed exactly the records the watchdog threw away. - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.onHistoricalRecord(_raw(1), _sample(1), 24); d.discardOpenChunk(); @@ -442,7 +442,7 @@ void main() { }); test('only a HISTORY_START (beginBurst) clears the poison', () { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.discardOpenChunk(); expect(d.burstDiscarded, isTrue); @@ -461,7 +461,7 @@ void main() { }); test('poisonedBursts counts once per burst, not once per discard call', () { - final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); + final d = _drainWith((raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}); d.discardOpenChunk(); d.discardOpenChunk(); expect(d.poisonedBursts, 1); diff --git a/test/coach_ecg_security_test.dart b/test/coach_ecg_security_test.dart new file mode 100644 index 000000000..51e024d4b --- /dev/null +++ b/test/coach_ecg_security_test.dart @@ -0,0 +1,117 @@ +// The AI coach may read ECG SUMMARIES and nothing else. `v_ecg_readings` is +// on the allow-list; the three base tables are blocked at layer 1 (token +// net) and — for the two packet tables, which no view reads — at layer 2 (the +// structural btree gate) as well. `ecg_reading` IS a base table of an allowed +// view, so its btree is structurally reachable; the token net is what keeps +// its `device_id` and `notes` columns out. Both facts are pinned here. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_db.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_coach_ecg_security_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.insertEcgReading( + { + 'id': 'ecg_1', + 'device_id': 'SERIAL-SECRET', + 'source': 'mg_labrador', + 'wrist': 'left', + 'start_ts': 1787823754, + 'end_ts': 1787823784, + 'result_code': 6, + 'category': 'inconclusive', + 'avg_hr': 80, + 'quality': 2, + 'unreadable_mask': 0, + 'interruptions': 1, + 'sample_count': 2, + 'status': 'inconclusive', + 'notes': 'private note', + 'created_at': 1787823784000, + }, + [ + { + 'sequence': 1, + 'sample_count': 2, + 'samples': [1, 0, 255, 255], + 'inner_hex': '2b11deadbeef', + 'is_placeholder': 0, + }, + ], + ); + }); + + tearDownAll(() async { + await CoachDb.close(); + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('the summary view is readable through run_sql', () async { + final out = await CoachDb.runCoachSql( + 'SELECT id, category, avg_hr, duration_s, date FROM v_ecg_readings', + ); + expect(out, contains('"ecg_1"')); + expect(out, contains('inconclusive')); + expect(out, contains('"duration_s":30')); + expect(out, isNot(contains('SERIAL-SECRET'))); + expect(out, isNot(contains('private note'))); + expect(out, isNot(contains('deadbeef'))); + }); + + test('the view exposes no identity, notes or bytes columns', () async { + final out = await CoachDb.runCoachSql('SELECT * FROM v_ecg_readings'); + expect(out, isNot(contains('device_id'))); + expect(out, isNot(contains('notes'))); + expect(out, isNot(contains('inner_hex'))); + expect(out, isNot(contains('samples'))); + }); + + for (final t in ['ecg_reading', 'ecg_reading_packet', 'ecg_raw_packet']) { + test('layer 1 rejects the base table $t', () { + expect( + () => CoachDb.guardAndPrepare('SELECT * FROM $t'), + throwsA(isA()), + ); + expect( + () => CoachDb.guardAndPrepare('SELECT device_id FROM $t'), + throwsA(isA()), + ); + expect( + () => CoachDb.guardAndPrepare( + 'WITH x AS (SELECT 1) SELECT * FROM v_ecg_readings, $t', + ), + throwsA(isA()), + ); + }); + } + + for (final t in ['ecg_reading_packet', 'ecg_raw_packet']) { + test('layer 2 (parser bypassed) rejects $t — no view reads it', () async { + await expectLater( + CoachDb.debugAssertAllowedBtrees('SELECT * FROM $t LIMIT 5'), + throwsA(isA()), + ); + }); + } + + test( + 'runCoachSql over the packet table returns a rejection, not bytes', + () async { + final out = await CoachDb.runCoachSql( + 'SELECT inner_hex FROM ecg_reading_packet', + ); + expect(out, contains('error')); + expect(out, isNot(contains('deadbeef'))); + }, + ); +} diff --git a/test/coach_ecg_tool_test.dart b/test/coach_ecg_tool_test.dart new file mode 100644 index 000000000..99af927dd --- /dev/null +++ b/test/coach_ecg_tool_test.dart @@ -0,0 +1,253 @@ +// The coach's `get_ecg_reading` tool: a bound read by id, a bounded min/max +// envelope, and nothing that identifies the band or leaks bytes. Plus the +// prompt and tool-definition pins. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_actions.dart'; +import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:openstrap_edge/coach/coach_prompt.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_coach_ecg_tool_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final reading = EcgReading( + id: 'ecg_tool_1', + deviceId: 'SERIAL-SECRET', + wrist: EcgWrist.right, + startTs: 1787823754, + endTs: 1787823784, + strapTerminalTs: 1787823784, + strapTerminalSubsec: 0, + resultCode: 1, + category: EcgCategory.sinusRhythm, + avgHr: 77, + quality: 3, + unreadableMask: 0, + interruptions: 0, + sampleCount: 3000, + minUv: -531, + maxUv: 731, + rmsUv: 126.8, + missingSegments: 1, + status: EcgReadingStatus.completed, + notes: 'private note', + createdAt: 1787823784000, + ); + // 30 packets of 100 samples with one placeholder; a lone spike so the + // envelope's max/min survive. + final packets = []; + for (var s = 0; s < 31; s++) { + if (s == 10) { + packets.add(EcgAcceptedPacket.placeholder(s)); + continue; + } + final samples = Int16List.fromList( + List.generate(100, (i) => (i % 20) * 10 - 100), + ); + if (s == 20) samples[50] = 731; + if (s == 25) samples[7] = -531; + packets.add( + EcgAcceptedPacket( + sequence: s, + strapSeconds: 1787823754 + s, + strapSubsec: 0, + samples: samples, + inner: Uint8List.fromList(List.filled(228, 0xab)), + ), + ); + } + await LocalDb.insertEcgReading(reading.toRow(), [ + for (final x in packets) EcgPacketCodec.toRow(x), + ]); + + // A window far longer than a completed reading, with wide (4-digit) + // values, so the payload cannot fit whole and the stride has to widen. + final long = []; + for (var s = 0; s < 200; s++) { + long.add( + EcgAcceptedPacket( + sequence: s, + strapSeconds: 1787823754 + s, + strapSubsec: 0, + samples: Int16List.fromList( + List.generate(100, (i) => i.isEven ? -2582 : 2471), + ), + inner: Uint8List.fromList(List.filled(228, 0xab)), + ), + ); + } + await LocalDb.insertEcgReading( + EcgReading( + id: 'ecg_tool_long', + deviceId: 'SERIAL-SECRET', + wrist: EcgWrist.right, + startTs: 1787823754, + endTs: 1787823954, + strapTerminalTs: 1787823954, + strapTerminalSubsec: 0, + resultCode: 1, + category: EcgCategory.sinusRhythm, + avgHr: 77, + quality: 3, + unreadableMask: 0, + interruptions: 0, + sampleCount: 20000, + minUv: -2582, + maxUv: 2471, + rmsUv: 2526.0, + missingSegments: 0, + status: EcgReadingStatus.completed, + notes: 'private note', + createdAt: 1787823954000, + ).toRow(), + [for (final x in long) EcgPacketCodec.toRow(x)], + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test( + 'returns the band summary and the full waveform; no identity, no bytes', + () async { + final out = await CoachActions.ecgReading( + await LocalDb.instance, + 'ecg_tool_1', + ); + final j = jsonDecode(out) as Map; + expect(j['id'], 'ecg_tool_1'); + expect(j['band_category'], 'sinusRhythm'); + expect(j['result_code'], 1); + expect(j['avg_hr'], 77); + expect(j['duration_s'], 30); + expect(j['sample_count'], 3000); + expect(j['missing_segments'], 1); + expect(j['unit'], kEcgSampleUnit); + final wf = j['waveform'] as Map; + final pts = wf['samples'] as List; + expect(wf['stride'], 1, reason: 'a 30 s reading is sent whole'); + expect(wf['count'], pts.length); + expect(wf['effective_rate_hz'], kEcgSampleRateHz); + expect( + pts.whereType().length, + j['sample_count'], + reason: 'every sample the band sent, not a summary', + ); + expect( + pts.length, + (j['sample_count'] as int) + + (j['missing_segments'] as int) * kEcgSampleRateHz, + reason: 'a missing segment holds its second open', + ); + expect( + pts.where((e) => e == null), + isNotEmpty, + reason: 'the placeholder second', + ); + final real = pts.whereType(); + expect(real, contains(731), reason: 'peaks are the real samples'); + expect(real, contains(-531)); + expect(out, isNot(contains('SERIAL-SECRET'))); + expect(out, isNot(contains('private note'))); + expect(out, isNot(contains('abab'))); + expect(out, isNot(contains('device_id'))); + expect( + out.length, + lessThan(CoachEngine.kMaxEcgToolResultChars), + reason: 'fits one tool result without truncation', + ); + }, + ); + + test('an unknown id is an error, an empty id is a usage error', () async { + final out = await CoachActions.ecgReading(await LocalDb.instance, 'nope'); + expect(jsonDecode(out), containsPair('error', contains('nope'))); + final db = await LocalDb.instance; + await expectLater( + () => CoachActions.ecgReading(db, ''), + throwsA(isA()), + ); + }); + + test('an over-long window is decimated, never clipped', () async { + final out = await CoachActions.ecgReading( + await LocalDb.instance, + 'ecg_tool_long', + ); + expect( + out.length, + lessThanOrEqualTo(CoachActions.ecgMaxPayloadChars), + reason: 'the result parses whole', + ); + final j = jsonDecode(out) as Map; // would throw if clipped + final wf = j['waveform'] as Map; + expect(wf['stride'], greaterThan(1)); + expect(wf['effective_rate_hz'], kEcgSampleRateHz / (wf['stride'] as int)); + expect(wf['count'], (wf['samples'] as List).length); + expect( + j['sample_count'], + 20000, + reason: 'the summary still reports the true length', + ); + }); + + test('the payload budget stays under the engine ceiling', () { + expect( + CoachActions.ecgMaxPayloadChars, + lessThan(CoachEngine.kMaxEcgToolResultChars), + reason: 'decimate deliberately rather than be clipped mid-number', + ); + // A bound single-reading lookup may be larger than a query the model + // widens itself, but never larger than the running history it lives in. + expect( + CoachEngine.kMaxToolResultChars, + lessThan(CoachEngine.kMaxEcgToolResultChars), + ); + expect( + CoachEngine.kMaxEcgToolResultChars, + lessThan(CoachEngine.kMaxHistoryChars), + ); + }); + + test('the result explains the data it carries', () async { + final out = await CoachActions.ecgReading( + await LocalDb.instance, + 'ecg_tool_1', + ); + final h = + (jsonDecode(out) as Map)['how_to_read'] + as Map; + expect(h['sample_rate'], contains('500 Hz')); + expect(h['sample_rate'], contains('10 ms')); + expect(h['avg_hr'], contains('not measured from these samples')); + expect(h['polarity'], contains('NOT proven')); + }); + + test('the system prompt carries the ECG law and the tool', () { + expect(kCoachSystemPrompt, contains('get_ecg_reading')); + expect(kCoachSystemPrompt, contains('v_ecg_readings')); + expect(kCoachSystemPrompt, contains('HeartKey')); + expect( + kCoachSystemPrompt, + contains('not a cleared diagnostic device'), + reason: 'interpretation is allowed; the standing caveat is not', + ); + expect(kCoachSystemPrompt, contains('Not medical advice')); + expect(kCoachSystemPrompt.toLowerCase(), contains('polarity')); + expect(kCoachSystemPrompt.toLowerCase(), contains('emergency')); + }); +} diff --git a/test/db_alarm_schedule_migration_test.dart b/test/db_alarm_schedule_migration_test.dart index 8526f9250..41182ebd0 100644 --- a/test/db_alarm_schedule_migration_test.dart +++ b/test/db_alarm_schedule_migration_test.dart @@ -65,10 +65,10 @@ void main() { created.add(name); await _seedEmptyV49Db(name); final version = await _openThroughLocalDb(name); - // schemaVersion moved 50->51 for M3 (multi-device attribution) after this - // test was written for the alarm_schedule table's own v50 rung — the - // literal below tracks whatever the ladder currently ends on, not a - // number this test owns. + // The v50 rung is what this file isolates; later rungs (v51 multi-device + // attribution, v52 the WHOOP MG ECG store) ride the same open, so the + // ladder's top is a floor here, not a number this test owns. + expect(LocalDb.schemaVersion, greaterThanOrEqualTo(50)); expect(version, LocalDb.schemaVersion); final db = await LocalDb.instance; diff --git a/test/db_migration_ladder_test.dart b/test/db_migration_ladder_test.dart index cb5eae3dc..3a27446c4 100644 --- a/test/db_migration_ladder_test.dart +++ b/test/db_migration_ladder_test.dart @@ -1222,6 +1222,29 @@ void main() { }, ); + test( + 'upgrade from v50 creates the three ECG tables and the coach view, and a ' + 'second open is a no-op', + () async { + const name = 'migrate_v50_ecg_test.db'; + created.add(name); + await _seedOldDb(name, 50, [_preDeviceLiveCoverageDdl, ..._v5DerivedDdl]); + expect(await _openThroughLocalDb(name), LocalDb.schemaVersion); + final names = await LocalDb.tableNames(); + expect(names, + containsAll(['ecg_reading', 'ecg_reading_packet', 'ecg_raw_packet'])); + final db = await LocalDb.instance; + final views = await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='view' AND name='v_ecg_readings'"); + expect(views, hasLength(1)); + // Idempotent: the repair pass re-runs every creator on the next open. + await LocalDb.close(); + expect(await _openThroughLocalDb(name), LocalDb.schemaVersion); + final health = await LocalDb.schemaHealth(); + expect(health['ok'], isTrue, reason: '$health'); + }, + ); + test( 'the paired band survives the upgrade: the prefs pair migrates into the ' 'device table on first load', diff --git a/test/ecg_ble_engine_test.dart b/test/ecg_ble_engine_test.dart new file mode 100644 index 000000000..e00d60075 --- /dev/null +++ b/test/ecg_ble_engine_test.dart @@ -0,0 +1,654 @@ +// The engine half of a WHOOP MG ECG reading: MG identity, the exclusive +// lease, the exact PREPARE/START/RESTART/CLEANUP lists (bytes, order, +// observer-before-write correlation, attempt-all, no retry), history +// preemption and refusal while leased, live R17 delivery (parsed / malformed +// / other revisions), raw R16 into the safe-trim buffer, READY recovery +// BEFORE `listening` and before INIT, link-down events, and the standing pin +// that no Labrador opcode is on a block list. + +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List _helloBody({int revision = 1, int optical = 0}) { + final body = Uint8List(Gen5HelloInfo.semanticBodyLen); + final v = ByteData.sublistView(body); + body[0] = revision; + v.setUint32(1, 900, Endian.little); + v.setUint32(6, DateTime.now().millisecondsSinceEpoch ~/ 1000, Endian.little); + for (var i = 0; i < 10; i++) { + // Synthetic, not a real strap: the serial is 10 ASCII bytes at offset 14 + // and nothing here depends on its value. + body[14 + i] = '5AM0000000'.codeUnitAt(i); + } + v.setUint32(79, 13, Endian.little); + v.setUint32(87, optical, Endian.little); + body[91] = 50; + body[92] = 41; + body[93] = 1; + body[102] = 1; + return body; +} + +Decoded _helloReply(int seq, {int revision = 1, int optical = 0}) => + Decoded('cmd_response', { + 'opcode': Cmd.getHello, + 'req_seq': seq, + 'cmd_status': CommandAwaiter.statusSuccess, + 'gen5_hello': Gen5HelloInfo.parse( + _helloBody(revision: revision, optical: optical), + )!, + }); + +Decoded _ack( + int seq, + int opcode, { + int status = CommandAwaiter.statusSuccess, +}) => Decoded('cmd_response', { + 'opcode': opcode, + 'req_seq': seq, + 'cmd_status': status, +}); + +/// A type-43 revision-17 inner with [count] samples (physical 228-byte shape). +Uint8List _r17Inner({ + int count = 100, + int sequence = 23940969, + int flags = 0x0a, + int progress = 3, + int? declaredCount, + int revision = 17, +}) { + final inner = Uint8List(228); + final v = ByteData.sublistView(inner); + inner[0] = 0x2B; + inner[1] = revision; + v.setUint32(3, sequence, Endian.little); + v.setUint32(7, 1787823784, Endian.little); + inner[13] = 1; + inner[14] = flags; + inner[16] = 1; + inner[17] = progress; + inner[20] = 70; + v.setUint16(21, 0xffff, Endian.little); + v.setUint16(24, declaredCount ?? count, Endian.little); + for (var i = 0; i < count; i++) { + v.setInt16(26 + 2 * i, i - 50, Endian.little); + } + return inner; +} + +/// A type-47 revision-16 inner (1,572 bytes). +Uint8List _r16Inner({int sequence = 23940915}) { + final inner = Uint8List(1572); + final v = ByteData.sublistView(inner); + inner[0] = 0x2F; + inner[1] = 16; + inner[2] = 3; + v.setUint32(3, sequence, Endian.little); + v.setUint32(7, 1787823731, Endian.little); + v.setUint16(11, 24242, Endian.little); + for (var i = 13; i < 1572; i++) { + inner[i] = (i * 7) & 0xff; + } + return inner; +} + +/// A fake gen5 link that records every outgoing command and answers through +/// [replyTo] FROM INSIDE the write — so a satisfied await proves the +/// observer existed before the write completed. +class _Link { + final commands = <({int seq, int opcode, List body})>[]; + final events = []; + final logs = []; + final trace = []; + late final BleEngine engine; + Decoded? Function(int seq, int opcode)? replyTo; + bool writeOk = true; + List? committedEcgRaw; + + _Link({BandProfile band = BandProfile.gen5, EcgReadyHook? onReady}) { + engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + onEcgEvent: events.add, + onReadyEcgRecovery: onReady, + ); + engine.debugInstallFakeLink( + band: band, + onWrite: (frame) async { + final inner = parseFrame(frame, profile: band)!.inner; + commands.add((seq: inner[1], opcode: inner[2], body: inner.sublist(3))); + trace.add('cmd:${inner[2]}'); + if (!writeOk) return false; + final reply = replyTo?.call(inner[1], inner[2]); + if (reply != null) engine.debugAbsorbDecoded(reply); + return true; + }, + onCommit: + ( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async { + committedEcgRaw = ecgRawPackets; + }, + ); + } + + void answerAll({int status = CommandAwaiter.statusSuccess}) => + replyTo = (seq, op) => _ack(seq, op, status: status); + + void helloMg({int optical = 0, int revision = 1}) => + engine.debugAbsorbDecoded( + _helloReply(99, optical: optical, revision: revision), + ); + + List get opcodes => commands.map((c) => c.opcode).toList(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(BleEngine.resetBandClaimForTest); + tearDown(BleEngine.resetBandClaimForTest); + + group('MG identity', () { + test('false before hello; true for a revision-1 MAVERICK hello', () { + final l = _Link(); + expect(l.engine.isMaverick, isFalse); + l.helloMg(optical: 0); + expect(l.engine.isMaverick, isTrue); + }); + + test( + 'the ordinary WHOOP 5.0 (optical 82) and an unknown revision are not MG', + () { + final l = _Link()..helloMg(optical: 82); + expect(l.engine.isMaverick, isFalse); + final l2 = _Link()..helloMg(optical: 0, revision: 2); + expect(l2.engine.isMaverick, isFalse); + }, + ); + }); + + group('lease', () { + test('one holder at a time; release frees it; stale after a new link', () { + final l = _Link(); + final a = l.engine.ecgAcquire()!; + expect(l.engine.ecgLeaseValid(a), isTrue); + expect(l.engine.ecgAcquire(), isNull, reason: 'already leased'); + l.engine.ecgRelease(a); + expect(l.engine.ecgLeaseValid(a), isFalse); + final b = l.engine.ecgAcquire()!; + expect(l.engine.ecgLeaseValid(b), isTrue); + // A replacement link invalidates the old lease without releasing the + // engine's view of it through that stale handle. + l.engine.debugInstallFakeLink( + onWrite: (_) async => true, + band: BandProfile.gen5, + ); + expect(l.engine.ecgLeaseValid(b), isFalse); + l.engine.ecgRelease(b); // stale: must not clear a lease it no longer owns + }); + }); + + group('command lists — exact gen5 bytes, order, correlation', () { + test( + 'PREPARE right: 123 01 01, 139 01 01, 125 01 01 — all correlated', + () async { + final l = _Link()..answerAll(); + final lease = l.engine.ecgAcquire()!; + final out = await l.engine.ecgPrepare(lease, WristSelection.right); + expect(l.opcodes, [123, 139, 125]); + expect(l.commands[0].body.sublist(0, 5), [1, 1, 0, 0, 0]); + expect(l.commands[1].body.sublist(0, 2), [1, 1]); + expect(l.commands[2].body.sublist(0, 2), [1, 1]); + expect(out.map((o) => o.label), [ + 'selectWrist', + 'filteredOn', + 'rawSaveOn', + ]); + expect(out.every((o) => o.written && o.succeeded), isTrue); + // Sequences are distinct and the reply matched THIS request's seq. + expect(l.commands.map((c) => c.seq).toSet().length, 3); + }, + ); + + test('PREPARE left selects 01 02', () async { + final l = _Link()..answerAll(); + await l.engine.ecgPrepare(l.engine.ecgAcquire()!, WristSelection.left); + expect(l.commands[0].body.sublist(0, 2), [1, 2]); + }); + + test( + 'START: 20 (bodyless, padded) then 124 01 02; RESTART uses 01 03', + () async { + final l = _Link()..answerAll(); + final lease = l.engine.ecgAcquire()!; + await l.engine.ecgStart(lease); + expect(l.opcodes, [20, 124]); + expect(l.commands[0].body, [ + 0, + ], reason: 'one aligned pad byte, no body'); + expect(l.commands[1].body.sublist(0, 2), [1, 2]); + l.commands.clear(); + await l.engine.ecgRestart(lease); + expect(l.opcodes, [20, 124]); + expect(l.commands[1].body.sublist(0, 2), [1, 3]); + }, + ); + + test('CLEANUP: 124 01 01, 139 01 00, 125 01 00', () async { + final l = _Link()..answerAll(); + final out = await l.engine.ecgCleanup(l.engine.ecgAcquire()!); + expect(l.opcodes, [124, 139, 125]); + expect(l.commands[0].body.sublist(0, 2), [1, 1]); + expect(l.commands[1].body.sublist(0, 2), [1, 0]); + expect(l.commands[2].body.sublist(0, 2), [1, 0]); + expect(out.map((o) => o.label), [ + 'generationStop', + 'filteredOff', + 'rawSaveOff', + ]); + }); + + test( + 'a FAILURE reply marks the member failed and the rest are still sent', + () async { + final l = _Link(); + l.replyTo = (seq, op) => _ack( + seq, + op, + status: op == 139 + ? CommandAwaiter.statusFailure + : CommandAwaiter.statusSuccess, + ); + final out = await l.engine.ecgPrepare( + l.engine.ecgAcquire()!, + WristSelection.right, + ); + expect(l.opcodes, [123, 139, 125], reason: 'attempt-all'); + expect(out.map((o) => o.succeeded), [true, false, true]); + expect(out.every((o) => o.written), isTrue); + }, + ); + + test( + 'a reply with the wrong sequence is not a match: timeout, no resend', + () { + fakeAsync((async) { + final l = _Link(); + l.replyTo = (seq, op) => _ack(seq + 7, op); + List? out; + l.engine.ecgStart(l.engine.ecgAcquire()!).then((o) => out = o); + async.elapse(const Duration(seconds: 11)); + expect(out, isNotNull); + expect(l.opcodes, [ + 20, + 124, + ], reason: 'each member written exactly once'); + expect(out!.map((o) => o.written), [true, true]); + expect(out!.map((o) => o.succeeded), [false, false]); + }); + }, + ); + + test( + 'a failed write is recorded unwritten and the list still walks on', + () async { + final l = _Link()..writeOk = false; + final out = await l.engine.ecgCleanup(l.engine.ecgAcquire()!); + expect(l.opcodes, [124, 139, 125]); + expect(out.every((o) => !o.written && !o.succeeded), isTrue); + }, + ); + + test('a released or stale lease writes nothing', () async { + final l = _Link()..answerAll(); + final lease = l.engine.ecgAcquire()!; + l.engine.ecgRelease(lease); + final out = await l.engine.ecgPrepare(lease, WristSelection.right); + expect(l.commands, isEmpty); + expect(out, hasLength(3)); + expect(out.every((o) => !o.written), isTrue); + }); + }); + + group('history ownership', () { + test( + 'cancel ends an active history task with one abort, then refresh is refused while leased', + () async { + final l = _Link()..answerAll(); + expect(await l.engine.debugStartHistoricalRefresh(), isTrue); + expect(l.engine.offloadActive, isTrue); + expect(l.opcodes, contains(Cmd.sendHistoricalData)); + final lease = l.engine.ecgAcquire()!; + l.commands.clear(); + await l.engine.ecgCancelHistory(lease); + expect(l.opcodes, [Cmd.abortHistoricalTransmits]); + expect(l.engine.offloadActive, isFalse); + l.commands.clear(); + expect(await l.engine.debugStartHistoricalRefresh(), isFalse); + expect( + l.commands, + isEmpty, + reason: 'no 0x16 while the ECG owner holds the transport', + ); + l.engine.ecgRelease(lease); + expect(await l.engine.debugStartHistoricalRefresh(), isTrue); + }, + ); + + test('cancel with no history running sends nothing and returns', () async { + final l = _Link()..answerAll(); + await l.engine.ecgCancelHistory(l.engine.ecgAcquire()!); + expect(l.commands, isEmpty); + }); + + test('maintenance traffic pauses under a lease', () { + expect( + shouldPauseMaintenanceTraffic(offloadActive: false, ecgLeased: true), + isTrue, + ); + expect(shouldPauseMaintenanceTraffic(offloadActive: false), isFalse); + }); + }); + + group('live R17 delivery', () { + test('a decodable type-43 revision-17 frame becomes an EcgFrameEvent', () { + final l = _Link(); + l.engine.debugProcessImmediateFrame( + Frame(_r17Inner(count: 49), true, true), + ); + expect(l.events, hasLength(1)); + final e = l.events.single as EcgFrameEvent; + expect(e.r17.sequence, 23940969); + expect(e.r17.sampleCount, 49); + expect(e.linkGeneration, l.engine.linkGeneration); + }); + + test('a revision-17 frame that does not parse is reported malformed', () { + final l = _Link(); + l.engine.debugProcessImmediateFrame( + Frame(_r17Inner(declaredCount: 101), true, true), + ); + expect(l.events.single, isA()); + }); + + test( + 'other type-43 revisions (IMU R21) and gen4 links produce no ECG event', + () { + final l = _Link(); + l.engine.debugProcessImmediateFrame( + Frame(_r17Inner(revision: 21), true, true), + ); + expect(l.events, isEmpty); + final g4 = _Link(band: BandProfile.gen4); + g4.engine.debugProcessImmediateFrame(Frame(_r17Inner(), true, true)); + expect(g4.events, isEmpty); + }, + ); + }); + + group('raw R16 into the safe-trim buffer', () { + test( + 'an ingested R16 is buffered on the drain and counted, not archived', + () { + final l = _Link(); + l.engine.debugIngestHistoricalFrame(Frame(_r16Inner(), true, true)); + final d = l.engine.debugDrain!; + expect(d.bufferedEcgRaw, 1); + expect(d.bufferedArchives, 0); + expect(d.bufferedRecords, 0); + expect(d.records, 1); + expect(d.currentBurstHistoricalPacketCount, 1); + }, + ); + + test( + 'the buffered R16 is handed to the commit sink with the token', + () async { + final l = _Link(); + l.engine.debugIngestHistoricalFrame(Frame(_r16Inner(), true, true)); + final d = l.engine.debugDrain!; + expect(await d.commit([1, 2, 3, 4, 5, 6, 7, 8]), isTrue); + expect(l.committedEcgRaw, hasLength(1)); + expect(l.committedEcgRaw!.single.sequence, 23940915); + expect(l.committedEcgRaw!.single.hex, hasLength(1572 * 2)); + expect(d.bufferedEcgRaw, 0); + }, + ); + }); + + group('DrainController raw-ECG lifecycle', () { + DrainController drain(CommitSyncBatchSink onCommit) => DrainController( + onRecord: (_, _) async {}, + onRecordsBatch: null, + onCommit: onCommit, + onArchive: null, + log: (_) {}, + ); + EcgRawPacket pkt(int seq) => EcgRawPacket( + hex: '2f10${seq.toRadixString(16)}', + deviceId: '', + sequence: seq, + strapSeconds: 1, + strapSubsec: 0, + capturedAt: 1, + ); + + test( + 'a raw-only chunk is durable progress and commits before ACK', + () async { + var commits = 0; + final d = drain(( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async { + commits++; + expect(raws, isEmpty); + expect(ecgRawPackets, hasLength(2)); + }); + d.onEcgRawPacket(pkt(1), counter: 1); + d.onEcgRawPacket(pkt(2), counter: 2); + expect(d.bufferedEcgRaw, 2); + expect(await d.commit([9, 9, 9, 9, 9, 9, 9, 9]), isTrue); + expect(commits, 1); + expect( + d.lastTrimAdvanced, + isTrue, + reason: 'raw ECG alone advances the trim', + ); + }, + ); + + test( + 'a failed commit restores the raw ECG at the front and rolls the trim back', + () async { + final d = drain(( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async { + throw StateError('disk'); + }); + d.onEcgRawPacket(pkt(1), counter: 1); + expect(await d.commit([1, 1, 1, 1, 1, 1, 1, 1]), isFalse); + expect(d.bufferedEcgRaw, 1); + expect(d.lastTrimAdvanced, isFalse); + }, + ); + + test('discardOpenChunk drops buffered raw ECG', () { + final d = drain( + ( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async {}, + ); + d.onEcgRawPacket(pkt(1), counter: 1); + d.discardOpenChunk(); + expect(d.bufferedEcgRaw, 0); + }); + + test('the unbuffered (no onCommit) controller refuses raw ECG', () { + final d = DrainController( + onRecord: (_, _) async {}, + onRecordsBatch: null, + onCommit: null, + onArchive: null, + log: (_) {}, + ); + expect(() => d.onEcgRawPacket(pkt(1), counter: 1), throwsStateError); + }); + }); + + group('link down', () { + test( + 'teardown emits EcgLinkDownEvent with the old generation and voids the lease', + () async { + final l = _Link()..answerAll(); + final lease = l.engine.ecgAcquire()!; + final gen = l.engine.linkGeneration; + await l.engine.disconnect(); + expect( + l.events.whereType().single.linkGeneration, + gen, + ); + expect(l.engine.linkGeneration, gen + 1); + expect(l.engine.ecgLeaseValid(lease), isFalse); + expect(l.engine.ecgLeaseHeld, isFalse); + }, + ); + }); + + group('opcode safety', () { + test( + 'no Labrador opcode is on a block list, and the write path accepts them', + () async { + for (final op in [20, 123, 124, 125, 139]) { + expect(dangerousCmds, isNot(contains(op)), reason: 'opcode $op'); + expect(OpcodeSafety.isDestructive(op), isFalse, reason: 'opcode $op'); + } + final l = _Link(); + final frame = cmdLabradorDataGeneration( + 1, + LabradorOperation.stop, + profile: BandProfile.gen5, + ); + expect(await l.engine.debugWriteRaw(frame), isTrue); + }, + ); + }); + + group('READY recovery', () { + test( + 'the hook runs before listening and before INIT; cleanup precedes GET_DATA_RANGE', + () { + fakeAsync((async) { + late _Link l; + var readyDuringHook = true; + l = _Link( + onReady: (engine) async { + l.trace.add('hook'); + readyDuringHook = engine.isConnected; + expect(engine.ecgLeaseHeld, isTrue); + expect( + engine.ecgAcquire(), + isNull, + reason: 'recovery holds the lease', + ); + final out = await engine.ecgRecoveryCleanup(); + expect(out.every((o) => o.succeeded), isTrue); + l.trace.add('hook-done'); + }, + ); + // Bootstrap replies + the cleanup replies come from the same table. + l.replyTo = (seq, op) => switch (op) { + Cmd.getHello => _helloReply(seq), + _ => _ack(seq, op), + }; + bool? ok; + l.engine.debugConnectGen5Official(_Ops(l)).then((v) => ok = v); + async.elapse(const Duration(seconds: 8)); + expect(ok, isTrue); + expect( + readyDuringHook, + isFalse, + reason: 'READY is not visible during recovery', + ); + final hook = l.trace.indexOf('hook'); + final done = l.trace.indexOf('hook-done'); + final range = l.trace.indexOf('cmd:${Cmd.getDataRange}'); + expect(hook, isNot(-1)); + expect( + l.trace.sublist(hook, done), + containsAllInOrder(['cmd:124', 'cmd:139', 'cmd:125']), + ); + expect( + range, + greaterThan(done), + reason: 'INIT (GET_DATA_RANGE) only after recovery', + ); + expect( + l.engine.ecgLeaseHeld, + isFalse, + reason: 'recovery lease released', + ); + expect(l.engine.isConnected, isTrue); + }); + }, + ); + + test('ecgRecoveryCleanup outside the hook writes nothing', () async { + final l = _Link()..answerAll(); + expect(await l.engine.ecgRecoveryCleanup(), isEmpty); + expect(l.commands, isEmpty); + }); + }); +} + +/// The scripted platform half of the official gen5 connect, as in +/// gen5_bootstrap_official_test.dart, minus its failure knobs. +class _Ops implements GattBootstrapOps { + final _Link link; + _Ops(this.link); + + @override + bool get bondingApplies => true; + @override + Future preferLe2mPhy() async => link.trace.add('phy'); + @override + Future discoverAndValidate() async => kWhoopGen5; + @override + Future requestMtu(int mtu) async => mtu; + @override + Future isBonded() async => true; + @override + Future createBond() async {} + @override + Future subscribe(String role) async => link.trace.add('sub:$role'); + @override + Future subscribeOptionalMemfault() async => true; +} diff --git a/test/ecg_controller_test.dart b/test/ecg_controller_test.dart new file mode 100644 index 000000000..0b8bacd86 --- /dev/null +++ b/test/ecg_controller_test.dart @@ -0,0 +1,617 @@ +// EcgController over a scripted fake transport: ownership order, the +// guard-before-write rule, subscription before START, single-flight, +// cleanup on every exit exactly once, the one inconclusive retry, save +// before completed, and the stale-link / malformed / link-down exits. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ecg/ecg_controller.dart'; +import 'package:openstrap_edge/ecg/ecg_guard_store.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; +import 'package:openstrap_edge/ecg/ecg_transport.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +LabradorR17 frame({ + required int seq, + int progress = 3, + bool presence = true, + bool s2One = true, + int s2State = 1, + int result = 0, + int liveHr = 70, + int avgHr = 0, + int unreadable = 0, +}) { + final inner = Uint8List(26 + 200); + final v = ByteData.sublistView(inner); + inner[0] = 0x2B; + inner[1] = 17; + v.setUint32(3, seq, Endian.little); + v.setUint32(7, 1787823700 + seq, Endian.little); + inner[13] = 2; + inner[14] = (presence ? 0x08 : 0) | (s2One ? 0x02 : 0); + inner[15] = result; + inner[16] = s2State; + inner[17] = progress; + inner[18] = unreadable; + inner[19] = avgHr; + inner[20] = liveHr; + v.setUint16(21, 0xffff, Endian.little); + v.setUint16(24, 100, Endian.little); + for (var i = 0; i < 100; i++) { + v.setInt16(26 + 2 * i, i - 50, Endian.little); + } + return LabradorR17.parse(inner)!; +} + +LabradorR17 terminal({ + required int seq, + int result = 1, + int avgHr = 77, + int liveHr = 78, + int unreadable = 0, +}) => frame( + seq: seq, + progress: 100, + s2State: 2, + s2One: false, + result: result, + avgHr: avgHr, + liveHr: liveHr, + unreadable: unreadable, +); + +EcgCommandListResult _ok(List labels) => EcgCommandListResult([ + for (final l in labels) EcgMemberOutcome(l, written: true, succeeded: true), +]); + +class FakeTransport implements EcgTransport { + final calls = []; + final _events = StreamController.broadcast(); + bool ready = true; + bool maverick = true; + int gen = 7; + @override + String? serial = 'MG-SERIAL'; + EcgLeaseHandle? current; + int acquired = 0; + + /// Per-list scripted results (null = all succeed). + EcgCommandListResult? prepareResult; + EcgCommandListResult? startResult; + EcgCommandListResult? restartResult; + EcgCommandListResult? cleanupResult; + + /// Runs INSIDE start()/restart() before they resolve — to emit frames at + /// the write/response boundary. + void Function()? duringStart; + Completer? holdRestart; + Completer? holdCleanup; + int syncRequests = 0; + + @override + bool get isReady => ready; + @override + bool get isMaverick => maverick; + @override + int get linkGeneration => gen; + @override + Stream get events => _events.stream; + + void emit(EcgTransportEvent e) => _events.add(e); + void emitFrame(LabradorR17 r, {int? generation}) => + emit(EcgTransportFrame(r, generation ?? gen)); + + /// Simulate a link teardown: new generation, lease void, event. + void dropLink() { + final old = gen; + gen++; + current = null; + emit(EcgTransportLinkDown(old)); + } + + @override + EcgLeaseHandle? acquire() { + if (!ready || current != null) return null; + acquired++; + return current = EcgLeaseHandle(Object(), gen); + } + + @override + bool leaseValid(EcgLeaseHandle lease) => + identical(current, lease) && lease.linkGeneration == gen; + + @override + void release(EcgLeaseHandle lease) { + calls.add('release'); + if (identical(current, lease)) current = null; + } + + @override + Future cancelHistory(EcgLeaseHandle lease) async => + calls.add('cancelHistory'); + + @override + Future prepare( + EcgLeaseHandle lease, + EcgWrist wrist, + ) async { + calls.add('prepare:${wrist.name}'); + return prepareResult ?? _ok(['selectWrist', 'filteredOn', 'rawSaveOn']); + } + + @override + Future start(EcgLeaseHandle lease) async { + calls.add('start'); + duringStart?.call(); + return startResult ?? _ok(['abortHistorical', 'generationStart']); + } + + @override + Future restart(EcgLeaseHandle lease) async { + calls.add('restart'); + if (holdRestart != null) await holdRestart!.future; + return restartResult ?? _ok(['abortHistorical', 'generationRestart']); + } + + @override + Future cleanup(EcgLeaseHandle lease) async { + calls.add('cleanup'); + if (holdCleanup != null) await holdCleanup!.future; + if (!leaseValid(lease)) { + return EcgCommandListResult([ + for (final l in ['generationStop', 'filteredOff', 'rawSaveOff']) + EcgMemberOutcome(l, written: false, succeeded: false), + ]); + } + return cleanupResult ?? + _ok(['generationStop', 'filteredOff', 'rawSaveOff']); + } + + @override + Future requestSync() async { + calls.add('sync'); + syncRequests++; + } +} + +class Rig { + final t = FakeTransport(); + final guard = MemoryEcgGuardStore(); + final saved = <(EcgReading, List)>[]; + final screen = []; + final phases = []; + bool failSave = false; + String? busy; + late final EcgController c; + var now = 1787823754000; + + Rig({Duration timeout = const Duration(seconds: 120)}) { + c = EcgController( + transport: t, + guard: guard, + save: (r, p) async { + if (failSave) throw StateError('disk full'); + saved.add((r, p)); + }, + busyReason: () => busy, + holdScreen: (o) async => screen.add('hold:$o'), + releaseScreen: (o) async => screen.add('release:$o'), + captureTimeout: timeout, + nowMs: () => now, + ); + c.addListener(() => phases.add(c.state.phase)); + } + + Future settle() => Future.delayed(Duration.zero); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('preconditions', () { + test( + 'not ready → disconnected; not MG → incompatible; busy → busy', + () async { + final r = Rig(); + r.t.ready = false; + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.disconnected); + r.t.ready = true; + r.t.maverick = false; + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.incompatible); + r.t.maverick = true; + r.busy = 'workout'; + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.busy); + expect(r.c.state.reason, 'workout'); + expect(r.t.calls, isEmpty, reason: 'nothing touched the band'); + expect(r.guard.log, isEmpty); + }, + ); + + test('a leased transport (recovery or another owner) is busy', () async { + final r = Rig(); + r.t.current = EcgLeaseHandle(Object(), r.t.gen); + await r.c.begin(EcgWrist.left); + expect(r.c.state.phase, EcgCapturePhase.busy); + expect(r.c.state.reason, 'transport'); + }); + }); + + group('the successful reading', () { + test('order: cancelHistory → guard set → prepare → start; save before ' + 'cleanup; completed only after cleanup; then sync', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.waiting); + expect(r.t.calls, ['cancelHistory', 'prepare:right', 'start']); + expect(r.guard.log, ['set:MG-SERIAL']); + expect(r.guard.wrists['MG-SERIAL'], EcgWrist.right); + expect(r.screen, ['hold:ecg']); + expect(r.c.isCapturing, isTrue); + + r.t.emitFrame(frame(seq: 1, presence: false, progress: 0)); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.waiting); + expect( + r.c.live.length, + 100, + reason: 'the preview shows real samples pre-contact', + ); + + r.t.emitFrame(frame(seq: 2, progress: 3, liveHr: 71)); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.active); + expect(r.c.state.progress, 3); + expect(r.c.state.liveHr, 71); + r.t.emitFrame(frame(seq: 3, progress: 50)); + r.t.emitFrame(terminal(seq: 4, avgHr: 77)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.completed); + expect(r.saved, hasLength(1)); + final (reading, packets) = r.saved.single; + expect(packets.map((p) => p.sequence), [2, 3, 4]); + expect(reading.category, EcgCategory.sinusRhythm); + expect(reading.status, EcgReadingStatus.completed); + expect(reading.avgHr, 77); + expect(reading.sampleCount, 300); + expect(reading.wrist, EcgWrist.right); + expect(reading.startTs, 1787823754); + expect(r.c.state.readingId, reading.id); + // Save happened, then cleanup, then sync — and cleanup exactly once. + expect(r.t.calls, [ + 'cancelHistory', + 'prepare:right', + 'start', + 'cleanup', + 'release', + 'sync', + ]); + expect(r.guard.active, isEmpty, reason: 'cleared after a full cleanup'); + expect(r.screen, ['hold:ecg', 'release:ecg']); + expect(r.c.isCapturing, isFalse); + // The completed phase was never shown before saving/cleanup. + final completedAt = r.phases.indexOf(EcgCapturePhase.completed); + expect(r.phases.indexOf(EcgCapturePhase.saving), lessThan(completedAt)); + expect( + r.phases.indexOf(EcgCapturePhase.cleaningUp), + lessThan(completedAt), + ); + }); + + test('a contact frame delivered during the START write is the first ' + 'accepted packet', () async { + final r = Rig(); + r.t.duringStart = () => r.t.emitFrame(frame(seq: 10, progress: 3)); + await r.c.begin(EcgWrist.left); + await r.settle(); + expect(r.c.reducerState.accepted.map((p) => p.sequence), [10]); + expect(r.c.state.phase, EcgCapturePhase.active); + }); + + test( + 'START failing after such a frame discards it and cleans up', + () async { + final r = Rig(); + r.t.duringStart = () => r.t.emitFrame(frame(seq: 10, progress: 3)); + r.t.startResult = EcgCommandListResult(const [ + EcgMemberOutcome('abortHistorical', written: true, succeeded: true), + EcgMemberOutcome('generationStart', written: true, succeeded: false), + ]); + await r.c.begin(EcgWrist.left); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'start'); + expect(r.saved, isEmpty); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + expect(r.guard.active, isEmpty); + }, + ); + }); + + group('command-list failures', () { + test('a failed PREPARE prevents START and cleans up', () async { + final r = Rig(); + r.t.prepareResult = EcgCommandListResult(const [ + EcgMemberOutcome('selectWrist', written: true, succeeded: true), + EcgMemberOutcome('filteredOn', written: true, succeeded: false), + EcgMemberOutcome('rawSaveOn', written: true, succeeded: true), + ]); + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'prepare'); + expect(r.t.calls, [ + 'cancelHistory', + 'prepare:right', + 'cleanup', + 'release', + ]); + expect(r.screen, isEmpty, reason: 'the screen hold comes after PREPARE'); + }); + + test('an unacknowledged guard write refuses to enable the band', () async { + final r = Rig(); + r.guard.failWrites = true; + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'guard'); + expect(r.t.calls, ['cancelHistory', 'cleanup', 'release']); + expect(r.t.calls, isNot(contains('prepare:right'))); + }); + + test('a failed cleanup member retains the guard and flags it', () async { + final r = Rig(); + r.t.cleanupResult = EcgCommandListResult(const [ + EcgMemberOutcome('generationStop', written: true, succeeded: true), + EcgMemberOutcome('filteredOff', written: true, succeeded: false), + EcgMemberOutcome('rawSaveOff', written: true, succeeded: true), + ]); + await r.c.begin(EcgWrist.right); + await r.c.cancel(); + expect(r.c.state.phase, EcgCapturePhase.cancelled); + expect(r.c.state.cleanupIncomplete, isTrue); + expect(r.guard.active, contains('MG-SERIAL')); + }); + }); + + group('retained guard on begin', () { + test('runs the cleanup triplet first, then proceeds', () async { + final r = Rig(); + r.guard.active.add('MG-SERIAL'); + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.waiting); + expect(r.t.calls, ['cleanup', 'cancelHistory', 'prepare:right', 'start']); + expect(r.guard.log, ['clear:MG-SERIAL', 'set:MG-SERIAL']); + }); + + test('recovery cleanup failing means no PREPARE', () async { + final r = Rig(); + r.guard.active.add('MG-SERIAL'); + r.t.cleanupResult = EcgCommandListResult(const [ + EcgMemberOutcome('generationStop', written: true, succeeded: false), + EcgMemberOutcome('filteredOff', written: true, succeeded: true), + EcgMemberOutcome('rawSaveOff', written: true, succeeded: true), + ]); + await r.c.begin(EcgWrist.right); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'recovery'); + expect(r.t.calls, isNot(contains('prepare:right'))); + expect(r.guard.active, contains('MG-SERIAL')); + }); + }); + + group('exits', () { + test('cancel cleans up exactly once and releases everything', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + await r.c.cancel(); + await r.c.cancel(); + expect(r.c.state.phase, EcgCapturePhase.cancelled); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + expect(r.screen, ['hold:ecg', 'release:ecg']); + expect(r.t.current, isNull); + expect(r.saved, isEmpty); + }); + + test('app pause is a cancel with its own reason', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + await r.c.onAppPaused(); + expect(r.c.state.phase, EcgCapturePhase.cancelled); + expect(r.c.state.reason, 'paused'); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + }); + + test('the capture timeout fails and cleans up once', () async { + final r = Rig(timeout: const Duration(milliseconds: 30)); + await r.c.begin(EcgWrist.right); + await Future.delayed(const Duration(milliseconds: 80)); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'timeout'); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + }); + + test( + 'a malformed R17 during capture fails through the single path', + () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.emit(EcgTransportMalformed(r.t.gen, 'r17_parse')); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'malformed'); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + }, + ); + + test( + 'link loss fails, attempts cleanup (unwritten) and retains the guard', + () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.dropLink(); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'disconnected'); + expect(r.t.calls, contains('cleanup')); + expect( + r.guard.active, + contains('MG-SERIAL'), + reason: 'nothing was written; the next READY recovers', + ); + expect(r.c.state.cleanupIncomplete, isTrue); + }, + ); + + test('frames from an older link generation are ignored', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3), generation: r.t.gen - 1); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.waiting); + expect(r.c.reducerState.accepted, isEmpty); + }); + + test('progress 255 fails the reading', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(frame(seq: 2, progress: 255)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'progress_255'); + }); + }); + + group('restart', () { + test('several frames during a slow RESTART start ONE list; frames are ' + 'dropped meanwhile', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.holdRestart = Completer(); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame( + frame(seq: 2, progress: 6, s2One: false), + ); // restart predicate + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.restarting); + r.t.emitFrame(frame(seq: 3, progress: 6, s2One: false)); + r.t.emitFrame(frame(seq: 4, progress: 6, s2One: false)); + await r.settle(); + expect(r.t.calls.where((c) => c == 'restart'), hasLength(1)); + expect(r.c.reducerState.accepted, isEmpty); + r.t.holdRestart!.complete(); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.active); + r.t.emitFrame(frame(seq: 5, progress: 3)); + await r.settle(); + expect(r.c.reducerState.accepted.map((p) => p.sequence), [5]); + }); + + test('cancel during a RESTART wins', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.holdRestart = Completer(); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(frame(seq: 2, progress: 6, s2One: false)); + await r.settle(); + final cancel = r.c.cancel(); + r.t.holdRestart!.complete(); + await cancel; + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.cancelled); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + }); + + test('a failed RESTART fails the reading', () async { + final r = Rig(); + r.t.restartResult = EcgCommandListResult(const [ + EcgMemberOutcome('abortHistorical', written: true, succeeded: true), + EcgMemberOutcome('generationRestart', written: true, succeeded: false), + ]); + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(frame(seq: 2, progress: 6, s2One: false)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'restart'); + }); + }); + + group('terminal outcomes', () { + test('unreadable: cleanup, mask surfaced, nothing saved', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(terminal(seq: 2, result: 0, unreadable: 0x03)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.unreadable); + expect(r.c.state.unreadableMask, 0x03); + expect(r.saved, isEmpty); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + expect(r.t.syncRequests, 0); + }); + + test( + 'first inconclusive offers one retry; the retry persists inconclusive', + () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(terminal(seq: 2, result: 6)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.inconclusiveRetry); + expect(r.saved, isEmpty); + expect(r.c.isCapturing, isFalse); + await r.c.retry(); + expect(r.c.state.phase, EcgCapturePhase.waiting); + expect(r.t.calls.where((c) => c == 'prepare:right'), hasLength(2)); + r.t.emitFrame(frame(seq: 3, progress: 3)); + r.t.emitFrame(terminal(seq: 4, result: 6)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.completed); + expect(r.saved.single.$1.status, EcgReadingStatus.inconclusive); + // No third attempt is offered. + await r.c.retry(); + expect(r.t.calls.where((c) => c == 'prepare:right'), hasLength(2)); + }, + ); + + test('a save failure never shows completed', () async { + final r = Rig()..failSave = true; + await r.c.begin(EcgWrist.right); + r.t.emitFrame(frame(seq: 1, progress: 3)); + r.t.emitFrame(terminal(seq: 2)); + await r.settle(); + await r.settle(); + expect(r.c.state.phase, EcgCapturePhase.failed); + expect(r.c.state.reason, 'save'); + expect(r.phases, isNot(contains(EcgCapturePhase.completed))); + expect(r.t.calls.where((c) => c == 'cleanup'), hasLength(1)); + expect(r.t.syncRequests, 0); + }); + }); + + group('single flight', () { + test('a second begin while capturing is ignored', () async { + final r = Rig(); + await r.c.begin(EcgWrist.right); + await r.c.begin(EcgWrist.left); + expect(r.t.acquired, 1); + expect(r.t.calls.where((c) => c.startsWith('prepare')), hasLength(1)); + expect(r.c.state.wrist, EcgWrist.right); + }); + }); +} diff --git a/test/ecg_db_test.dart b/test/ecg_db_test.dart new file mode 100644 index 000000000..150bd1c17 --- /dev/null +++ b/test/ecg_db_test.dart @@ -0,0 +1,300 @@ +// The WHOOP MG ECG store: schema presence and idempotence, the atomic +// reading+packet insert (and its all-or-nothing failure), the signed-i16 +// BLOB / placeholder round trip, the manual delete cascade, raw R16 riding +// the safe-trim commit, and the ownership lists (salvage, backup restore, +// wipe). Runs the REAL LocalDb over sqflite_common_ffi. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +Uint8List _i16le(List v) { + final out = Uint8List(v.length * 2); + final bd = ByteData.sublistView(out); + for (var i = 0; i < v.length; i++) { + bd.setInt16(2 * i, v[i], Endian.little); + } + return out; +} + +List _fromI16le(Uint8List b) { + final bd = ByteData.sublistView(b); + return [ + for (var i = 0; i + 1 < b.length; i += 2) bd.getInt16(i, Endian.little), + ]; +} + +Map _reading(String id, {int startTs = 1787823754}) => { + 'id': id, + 'device_id': '', + 'source': 'mg_labrador', + 'wrist': 'right', + 'start_ts': startTs, + 'end_ts': startTs + 30, + 'strap_terminal_ts': startTs + 30, + 'strap_terminal_subsec': 100, + 'result_code': 1, + 'category': 'sinusRhythm', + 'avg_hr': 77, + 'quality': 3, + 'unreadable_mask': 0, + 'interruptions': 0, + 'sample_rate_hz': 100, + 'sample_unit': 'filtered_input_referred_uv', + 'sample_count': 5, + 'min_uv': -531, + 'max_uv': 731, + 'rms_uv': 126.773, + 'missing_segments': 1, + 'status': 'completed', + 'notes': null, + 'created_at': 1787823784000, +}; + +Map _packet( + int seq, + List samples, { + bool placeholder = false, +}) => { + 'sequence': seq, + 'strap_seconds': 1787823754 + seq, + 'strap_subsec': 12, + 'sample_count': samples.length, + 'samples': _i16le(samples), + 'inner_hex': placeholder ? '' : '2b11${seq.toRadixString(16)}', + 'is_placeholder': placeholder ? 1 : 0, +}; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_ecg_db_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test( + 'fresh schema has the three tables, the index and the coach view', + () async { + final names = await LocalDb.tableNames(); + expect( + names, + containsAll(['ecg_reading', 'ecg_reading_packet', 'ecg_raw_packet']), + ); + final db = await LocalDb.instance; + final views = await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='view' AND name='v_ecg_readings'", + ); + expect(views, hasLength(1)); + final health = await LocalDb.schemaHealth(); + expect(health['ok'], isTrue, reason: '$health'); + }, + ); + + test('reading + packets insert atomically and round-trip signed samples ' + 'and the placeholder', () async { + await LocalDb.insertEcgReading(_reading('r1'), [ + _packet(100, [-1, 1, 32767, -32768, 0]), + _packet(101, const [], placeholder: true), + _packet(102, [-5396, 3377]), + ]); + final row = (await LocalDb.ecgReading('r1'))!; + expect(row['category'], 'sinusRhythm'); + expect(row['avg_hr'], 77); + expect(row['sample_unit'], 'filtered_input_referred_uv'); + final packets = await LocalDb.ecgReadingPackets('r1'); + expect(packets.map((p) => p['ordinal']), [0, 1, 2]); + expect(_fromI16le(packets[0]['samples'] as Uint8List), [ + -1, + 1, + 32767, + -32768, + 0, + ]); + expect(packets[1]['is_placeholder'], 1); + expect((packets[1]['samples'] as Uint8List), isEmpty); + expect(packets[1]['sequence'], 101); + expect(_fromI16le(packets[2]['samples'] as Uint8List), [-5396, 3377]); + expect(packets[2]['inner_hex'], '2b1166'); + }); + + test('a failing packet insert rolls the reading back too', () async { + final bad = _packet(1, [1])..remove('sample_count'); // NOT NULL violation + await expectLater( + LocalDb.insertEcgReading(_reading('r_bad'), [ + _packet(0, [1]), + bad, + ]), + throwsA(anything), + ); + expect(await LocalDb.ecgReading('r_bad'), isNull); + expect(await LocalDb.ecgReadingPackets('r_bad'), isEmpty); + }); + + test('re-saving the same reading id is refused, not merged', () async { + await expectLater( + LocalDb.insertEcgReading(_reading('r1'), const []), + throwsA(anything), + ); + expect( + await LocalDb.ecgReadingPackets('r1'), + hasLength(3), + reason: 'the original packets are untouched', + ); + }); + + test('listEcgReadings is newest first and carries no packets', () async { + await LocalDb.insertEcgReading( + _reading('r2', startTs: 1787900000), + const [], + ); + final rows = await LocalDb.listEcgReadings(); + expect(rows.map((r) => r['id']).take(2), ['r2', 'r1']); + expect(rows.first.containsKey('samples'), isFalse); + }); + + test( + 'the coach view is summary-only: local date, duration, no identity', + () async { + final db = await LocalDb.instance; + final v = await db.query( + 'v_ecg_readings', + where: 'id = ?', + whereArgs: ['r1'], + ); + expect(v, hasLength(1)); + final r = v.first; + expect(r['duration_s'], 30); + expect(r['date'], hasLength(10)); + // 2026-08-27 19:42/19:43 in Europe/Berlin; whatever the host zone, the + // label is the LOCAL day of that instant. + final local = DateTime.fromMillisecondsSinceEpoch(1787823754 * 1000); + final expected = + '${local.year}-${local.month.toString().padLeft(2, '0')}-' + '${local.day.toString().padLeft(2, '0')}'; + expect(r['date'], expected); + expect(r.containsKey('device_id'), isFalse); + expect(r.containsKey('notes'), isFalse); + expect(r.containsKey('samples'), isFalse); + expect(r.containsKey('inner_hex'), isFalse); + }, + ); + + test( + 'deleteEcgReading cascades to its packets only and detaches raw rows', + () async { + await LocalDb.commitSyncBatch( + const [], + const [], + ecgRawPackets: [ + const EcgRawPacket( + hex: '2f10aa01', + deviceId: '', + sequence: 100, + strapSeconds: 1787823854, + strapSubsec: 12, + capturedAt: 1787823854000, + ), + ], + ); + final db = await LocalDb.instance; + await db.update( + 'ecg_raw_packet', + {'reading_id': 'r1'}, + where: 'hex = ?', + whereArgs: ['2f10aa01'], + ); + await LocalDb.deleteEcgReading('r1'); + expect(await LocalDb.ecgReading('r1'), isNull); + expect(await LocalDb.ecgReadingPackets('r1'), isEmpty); + expect(await LocalDb.ecgReadingPackets('r2'), isEmpty); + expect( + await LocalDb.ecgReading('r2'), + isNotNull, + reason: 'other readings survive', + ); + final raw = await db.query( + 'ecg_raw_packet', + where: 'hex = ?', + whereArgs: ['2f10aa01'], + ); + expect(raw, hasLength(1), reason: 'raw R16 is independent evidence'); + expect(raw.first['reading_id'], isNull); + }, + ); + + test( + 'raw R16 rides commitSyncBatch with the trim cursor, idempotently', + () async { + const pkt = EcgRawPacket( + hex: '2f10bb02', + deviceId: '', + sequence: 24016883, + strapSeconds: 1787928472, + strapSubsec: 19334, + capturedAt: 1787928473000, + ); + await LocalDb.commitSyncBatch( + const [], + const [], + trimToken: '0102030405060708', + ecgRawPackets: [pkt, pkt], + ); + expect(await LocalDb.ecgRawPacketCount(), 2); // the one above + this + expect(await LocalDb.getCursor('strap_trim'), '0102030405060708'); + // Same bytes again: no duplicate. + await LocalDb.commitSyncBatch( + const [], + const [], + ecgRawPackets: [pkt], + ); + expect(await LocalDb.ecgRawPacketCount(), 2); + }, + ); + + test('ownership: the ECG tables are salvaged, restored and wiped', () async { + expect( + LocalDb.salvageTablesForTest, + containsAllInOrder([ + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', + ]), + ); + expect( + LocalDb.restoreTablesForTest, + containsAllInOrder([ + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', + ]), + ); + final deleted = await LocalDb.wipeAll(); + expect(deleted, greaterThan(0)); + expect(await LocalDb.listEcgReadings(), isEmpty); + expect(await LocalDb.ecgRawPacketCount(), 0); + }); + + test('re-opening runs the repair pass idempotently', () async { + await LocalDb.close(); + final db = await LocalDb.instance; + final names = await LocalDb.tableNames(); + expect( + names, + containsAll(['ecg_reading', 'ecg_reading_packet', 'ecg_raw_packet']), + ); + final v = await db.rawQuery('PRAGMA user_version'); + expect(v.first.values.first, LocalDb.schemaVersion); + }); +} diff --git a/test/ecg_models_test.dart b/test/ecg_models_test.dart new file mode 100644 index 000000000..b4f58fd19 --- /dev/null +++ b/test/ecg_models_test.dart @@ -0,0 +1,154 @@ +// The band-result category table (every boundary), the row codecs and the +// window statistics. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; + +void main() { + group('categoryFor — the official result + HR table', () { + test('codes 0 and 2 are unreadable at any HR', () { + for (final hr in [0, 50, 75, 200, 255]) { + expect(categoryFor(0, hr), EcgCategory.unreadable); + expect(categoryFor(2, hr), EcgCategory.unreadable); + } + }); + + test('code 1: sinus rhythm only for 51..99', () { + expect(categoryFor(1, 50), EcgCategory.unreadable); + expect(categoryFor(1, 51), EcgCategory.sinusRhythm); + expect(categoryFor(1, 99), EcgCategory.sinusRhythm); + expect(categoryFor(1, 100), EcgCategory.unreadable); + }); + + test('code 3: low heart rate only at 50 or below', () { + expect(categoryFor(3, 0), EcgCategory.lowHeartRate); + expect(categoryFor(3, 50), EcgCategory.lowHeartRate); + expect(categoryFor(3, 51), EcgCategory.unreadable); + }); + + test( + 'code 4: possible AFib 51..99, AFib high HR 100..150, high HR 151..200', + () { + expect(categoryFor(4, 50), EcgCategory.unreadable); + expect(categoryFor(4, 51), EcgCategory.possibleAfib); + expect(categoryFor(4, 99), EcgCategory.possibleAfib); + expect(categoryFor(4, 100), EcgCategory.afibHighHeartRate); + expect(categoryFor(4, 150), EcgCategory.afibHighHeartRate); + expect(categoryFor(4, 151), EcgCategory.highHeartRate); + expect(categoryFor(4, 200), EcgCategory.highHeartRate); + expect(categoryFor(4, 201), EcgCategory.unreadable); + }, + ); + + test('code 5: high HR no AFib 100..150, high HR 151..200', () { + expect(categoryFor(5, 99), EcgCategory.unreadable); + expect(categoryFor(5, 100), EcgCategory.highHeartRateNoAfib); + expect(categoryFor(5, 150), EcgCategory.highHeartRateNoAfib); + expect(categoryFor(5, 151), EcgCategory.highHeartRate); + expect(categoryFor(5, 200), EcgCategory.highHeartRate); + expect(categoryFor(5, 201), EcgCategory.unreadable); + }); + + test('code 6 is inconclusive at any HR; unknown codes are unreadable', () { + expect(categoryFor(6, 0), EcgCategory.inconclusive); + expect(categoryFor(6, 180), EcgCategory.inconclusive); + expect(categoryFor(7, 75), EcgCategory.unreadable); + expect(categoryFor(99, 75), EcgCategory.unreadable); + expect(categoryFor(-1, 75), EcgCategory.unreadable); + }); + }); + + group('codecs', () { + test('samples round-trip as signed i16 LE bytes', () { + final s = Int16List.fromList([-1, 1, 32767, -32768, 0, -5396, 3377]); + final bytes = EcgPacketCodec.encodeSamples(s); + expect(bytes.length, 14); + expect(bytes.sublist(0, 4), [0xff, 0xff, 0x01, 0x00]); + expect(EcgPacketCodec.decodeSamples(bytes), s); + }); + + test('packet rows carry a placeholder faithfully', () { + final p = EcgAcceptedPacket.placeholder(42); + final row = EcgPacketCodec.toRow(p); + expect(row['is_placeholder'], 1); + expect(row['sample_count'], 0); + expect(row['inner_hex'], ''); + final back = EcgPacketCodec.fromRow(row); + expect(back.placeholder, isTrue); + expect(back.sequence, 42); + expect(back.samples, isEmpty); + }); + + test('reading rows round-trip', () { + final r = EcgReading( + id: ecgReadingId( + startEpochMs: 1787823754000, + terminalStrapS: 1787823784, + ), + deviceId: '', + wrist: EcgWrist.left, + startTs: 1787823754, + endTs: 1787823784, + strapTerminalTs: 1787823784, + strapTerminalSubsec: 5, + resultCode: 1, + category: EcgCategory.sinusRhythm, + avgHr: 77, + quality: 3, + unreadableMask: 0, + interruptions: 1, + sampleCount: 3000, + minUv: -531, + maxUv: 731, + rmsUv: 126.773, + missingSegments: 0, + status: EcgReadingStatus.completed, + notes: null, + createdAt: 1787823784000, + ); + expect(r.id, 'ecg_1787823754000_1787823784'); + final row = r.toRow(); + expect(row['sample_unit'], kEcgSampleUnit); + expect(row['sample_rate_hz'], 100); + expect(row['source'], kEcgSource); + final back = EcgReading.fromRow(row)!; + expect(back.wrist, EcgWrist.left); + expect(back.category, EcgCategory.sinusRhythm); + expect(back.durationS, 30); + expect(back.rmsUv, closeTo(126.773, 1e-9)); + expect(EcgReading.fromRow({'id': 'x'}), isNull); + }); + }); + + group('window stats', () { + test('min, max, rms and missing segments; placeholders add nothing', () { + final stats = EcgWindowStats.of([ + EcgAcceptedPacket( + sequence: 1, + strapSeconds: 1, + strapSubsec: 0, + samples: Int16List.fromList([3, -4]), + inner: Uint8List(0), + ), + EcgAcceptedPacket.placeholder(2), + EcgAcceptedPacket( + sequence: 3, + strapSeconds: 3, + strapSubsec: 0, + samples: Int16List.fromList([0, 12]), + inner: Uint8List(0), + ), + ]); + expect(stats.sampleCount, 4); + expect(stats.minUv, -4); + expect(stats.maxUv, 12); + expect(stats.rmsUv, closeTo(6.5, 1e-9)); // sqrt((9+16+0+144)/4) + expect(stats.missingSegments, 1); + final empty = EcgWindowStats.of(const []); + expect(empty.sampleCount, 0); + expect(empty.rmsUv, isNull); + }); + }); +} diff --git a/test/ecg_policy_test.dart b/test/ecg_policy_test.dart new file mode 100644 index 000000000..4e3f86d50 --- /dev/null +++ b/test/ecg_policy_test.dart @@ -0,0 +1,438 @@ +// The pure R17 reducer against every rule of the official foreground state +// machine (docs/mg/05 §4, docs/mg/06 §6), plus a synthetic replay of the +// frozen official capture's shape: 86 transport frames → 30 accepted → +// 3,000 samples, repeated terminal excluded. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; +import 'package:openstrap_edge/ecg/ecg_policy.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +LabradorR17 pkt({ + required int seq, + int progress = 3, + bool presence = true, + bool s2One = true, + int s2State = 1, + int result = 0, + int liveHr = 70, + int avgHr = 0, + int unreadable = 0, + int samples = 100, + bool transition = false, +}) { + final inner = Uint8List(26 + 2 * samples); + final v = ByteData.sublistView(inner); + inner[0] = 0x2B; + inner[1] = 17; + v.setUint32(3, seq, Endian.little); + v.setUint32(7, 1787823700 + seq, Endian.little); + inner[13] = 1; + inner[14] = + (presence ? 0x08 : 0) | (s2One ? 0x02 : 0) | (transition ? 0x04 : 0); + inner[15] = result; + inner[16] = s2State; + inner[17] = progress; + inner[18] = unreadable; + inner[19] = avgHr; + inner[20] = liveHr; + v.setUint16(21, 0xffff, Endian.little); + v.setUint16(24, samples, Endian.little); + for (var i = 0; i < samples; i++) { + v.setInt16(26 + 2 * i, seq, Endian.little); + } + return LabradorR17.parse(inner)!; +} + +LabradorR17 terminal({ + required int seq, + int result = 1, + int avgHr = 77, + int liveHr = 78, + int unreadable = 0, +}) => pkt( + seq: seq, + progress: 100, + s2State: 2, + s2One: false, + transition: true, + result: result, + avgHr: avgHr, + liveHr: liveHr, + unreadable: unreadable, +); + +/// Run [frames] from a fresh state; return the final state and every effect. +(EcgReducerState, List) run( + List frames, { + int retriesUsed = 0, +}) { + var s = EcgReducerState.initial(retriesUsed: retriesUsed); + final effects = []; + for (final f in frames) { + final step = reduceEcg(s, f); + s = step.state; + effects.addAll(step.effects); + } + return (s, effects); +} + +List seqs(EcgReducerState s) => s.accepted.map((p) => p.sequence).toList(); + +void main() { + group('WAITING', () { + test('pre-contact frames are ignored: no presence, zero progress, 255', () { + final (s, e) = run([ + pkt(seq: 1, presence: false, progress: 0), + pkt(seq: 2, presence: true, progress: 0), + pkt(seq: 3, presence: false, progress: 5), + pkt(seq: 4, presence: true, progress: 255), + ]); + expect(s.phase, EcgPhase.waiting); + expect(s.accepted, isEmpty); + expect(e, isEmpty); + }); + + test('the first presence + positive non-255 progress frame opens the ' + 'window and enters ACTIVE', () { + final (s, e) = run([pkt(seq: 9, progress: 3)]); + expect(s.phase, EcgPhase.active); + expect(seqs(s), [9]); + expect(s.previous?.sequence, 9); + expect(e, [isA(), isA()]); + }); + }); + + group('ACTIVE accumulation and terminal', () { + test('ordinary frames append while the current-S2-state-1 flag is set', () { + final (s, _) = run([ + pkt(seq: 1), + pkt(seq: 2, progress: 6), + pkt(seq: 3, progress: 9), + ]); + expect(seqs(s), [1, 2, 3]); + expect(s.interruptions, 0); + }); + + test( + 'progress 100 or S2 state 2 is terminal; the terminal frame is ' + 'appended; the persisted category uses AVERAGE HR, the live one LIVE HR', + () { + final (s, e) = run([ + pkt(seq: 1), + terminal(seq: 2, avgHr: 120, liveHr: 78, result: 1), + ]); + expect(s.phase, EcgPhase.done); + expect(seqs(s), [1, 2]); + final t = e.whereType().single.outcome; + expect( + t.kind, + EcgTerminalKind.completed, + reason: 'the LIVE branch (78 bpm) completes', + ); + expect(t.liveCategory, EcgCategory.sinusRhythm); + expect( + t.persistedCategory, + EcgCategory.unreadable, + reason: + 'result 1 at an average of 120 bpm is out of range — the ' + 'stored category says so, exactly like the official row', + ); + expect(t.averageHr, 120); + expect(t.liveHr, 78); + }, + ); + + test( + 'a live-unreadable terminal (live HR out of range) clears the window', + () { + final (s, e) = run([ + pkt(seq: 1), + terminal(seq: 2, avgHr: 77, liveHr: 120, result: 1), + ]); + expect(s.accepted, isEmpty); + expect( + e.whereType().single.outcome.kind, + EcgTerminalKind.unreadable, + ); + }, + ); + + test('a completed terminal via S2 state 2 with progress below 100', () { + final (s, e) = run([ + pkt(seq: 1), + pkt( + seq: 2, + progress: 96, + s2State: 2, + s2One: false, + result: 1, + liveHr: 72, + avgHr: 72, + ), + ]); + expect(s.phase, EcgPhase.done); + final t = e.whereType().single.outcome; + expect(t.kind, EcgTerminalKind.completed); + expect(t.persistedCategory, EcgCategory.sinusRhythm); + expect(t.liveCategory, EcgCategory.sinusRhythm); + expect(seqs(s), [1, 2]); + }); + + test('a repeated terminal frame is ignored', () { + final (s, e) = run([pkt(seq: 1), terminal(seq: 2), terminal(seq: 3)]); + expect(seqs(s), [1, 2]); + expect(e.whereType(), hasLength(1)); + }); + + test( + 'unreadable terminal: window cleared, mask surfaced, nothing appended', + () { + final (s, e) = run([ + pkt(seq: 1), + terminal(seq: 2, result: 0, unreadable: 0x05), + ]); + expect(s.phase, EcgPhase.done); + expect(s.accepted, isEmpty); + final t = e.whereType().single.outcome; + expect(t.kind, EcgTerminalKind.unreadable); + expect(t.unreadableMask, 0x05); + expect(e.last, isA()); + expect(e[e.length - 2], isA()); + }, + ); + + test( + 'first-attempt inconclusive offers ONE retry and persists nothing', + () { + final (s, e) = run([pkt(seq: 1), terminal(seq: 2, result: 6)]); + expect(s.accepted, isEmpty); + expect( + e.whereType().single.outcome.kind, + EcgTerminalKind.inconclusiveOfferRetry, + ); + }, + ); + + test( + 'inconclusive on the retry is final and persisted as inconclusive', + () { + final (s, e) = run([ + pkt(seq: 1), + terminal(seq: 2, result: 6), + ], retriesUsed: 1); + expect(seqs(s), [1, 2]); + final t = e.whereType().single.outcome; + expect(t.kind, EcgTerminalKind.inconclusiveFinal); + expect(t.persistedCategory, EcgCategory.inconclusive); + }, + ); + + test('progress 255 while active clears and fails', () { + final (s, e) = run([pkt(seq: 1), pkt(seq: 2, progress: 255)]); + expect(s.phase, EcgPhase.done); + expect(s.accepted, isEmpty); + expect(e.whereType().single.reason, 'progress_255'); + }); + }); + + group('contact loss', () { + test('missing presence clears the window, counts ONE interruption and ' + 'enters CONTACT_LOST', () { + final (s, e) = run([ + pkt(seq: 1), + pkt(seq: 2, progress: 6), + pkt(seq: 3, presence: false, progress: 6), + ]); + expect(s.phase, EcgPhase.contactLost); + expect(s.accepted, isEmpty); + expect(s.interruptions, 1); + expect(e.last, isA()); + }); + + test('zero progress and progress regression are losses too', () { + expect( + run([pkt(seq: 1, progress: 5), pkt(seq: 2, progress: 0)]).$1.phase, + EcgPhase.contactLost, + ); + expect( + run([pkt(seq: 1, progress: 5), pkt(seq: 2, progress: 4)]).$1.phase, + EcgPhase.contactLost, + ); + expect( + run([pkt(seq: 1, progress: 5), pkt(seq: 2, progress: 5)]).$1.phase, + EcgPhase.active, + reason: 'equal progress is nondecreasing', + ); + }); + + test('further bad packets while lost do not add interruptions; recovery ' + 'returns to ACTIVE with NO restart', () { + final (s, e) = run([ + pkt(seq: 1), + pkt(seq: 2, presence: false), + pkt(seq: 3, presence: false), + pkt(seq: 4, progress: 0), + pkt(seq: 5, progress: 3), + pkt(seq: 6, progress: 6), + ]); + expect(s.phase, EcgPhase.active); + expect(s.interruptions, 1); + expect(seqs(s), [ + 5, + 6, + ], reason: 'a fresh window, no placeholder from before the loss'); + expect(e.whereType(), isEmpty); + }); + + test('the exact three-interruption boundary: the third loss enters ' + 'CONTACT_LOST; the next bad packet fails', () { + final frames = [ + pkt(seq: 1), + pkt(seq: 2, presence: false), // loss 1 + pkt(seq: 3, progress: 3), + pkt(seq: 4, presence: false), // loss 2 + pkt(seq: 5, progress: 3), + pkt(seq: 6, presence: false), // loss 3 + ]; + final (s3, e3) = run(frames); + expect(s3.phase, EcgPhase.contactLost); + expect(s3.interruptions, 3); + expect( + e3.whereType(), + isEmpty, + reason: 'the transition itself does not fail', + ); + // A fourth loss AFTER a recovery is also fine to enter lost… + final (s4, e4) = run([ + ...frames, + pkt(seq: 7, progress: 3), + pkt(seq: 8, presence: false), + ]); + expect(s4.phase, EcgPhase.contactLost); + expect(s4.interruptions, 4); + expect(e4.whereType(), isEmpty); + // …but a bad packet while lost with the count at ≥3 fails. + final (s5, e5) = run([...frames, pkt(seq: 7, presence: false)]); + expect(s5.phase, EcgPhase.done); + expect(e5.whereType().single.reason, 'interruptions'); + // With the count at 2, a bad packet while lost just stays lost. + final (s6, e6) = run( + frames.sublist(0, 4) + [pkt(seq: 5, presence: false)], + ); + expect(s6.phase, EcgPhase.contactLost); + expect(s6.interruptions, 2); + expect(e6.whereType(), isEmpty); + }); + + test('progress 255 while lost fails immediately', () { + final (s, e) = run([ + pkt(seq: 1), + pkt(seq: 2, presence: false), + pkt(seq: 3, progress: 255), + ]); + expect(s.phase, EcgPhase.done); + expect(e.whereType().single.reason, 'progress_255'); + }); + + test( + 'a recovered window that then completes carries the interruption count', + () { + final (s, e) = run([ + pkt(seq: 1), + pkt(seq: 2, presence: false), + pkt(seq: 3, progress: 3), + terminal(seq: 4), + ]); + expect(s.interruptions, 1); + expect(seqs(s), [3, 4]); + expect( + e.whereType().single.outcome.kind, + EcgTerminalKind.completed, + ); + }, + ); + }); + + group('explicit RESTART predicate', () { + test('presence, positive nondecreasing nonterminal progress, S2-state-1 ' + 'flag clear → clear the unfinished window and send RESTART', () { + final (s, e) = run([ + pkt(seq: 1), + pkt(seq: 2, progress: 6), + pkt(seq: 3, progress: 6, s2One: false), + ]); + expect(s.phase, EcgPhase.active); + expect(s.accepted, isEmpty); + expect(s.interruptions, 0, reason: 'not a contact loss'); + expect(e.sublist(e.length - 2), [isA(), isA()]); + }); + + test('a short loss / recontact never sends RESTART', () { + final (_, e) = run([ + pkt(seq: 1), + pkt(seq: 2, progress: 0), + pkt(seq: 3, progress: 3), + pkt(seq: 4, progress: 6), + ]); + expect(e.whereType(), isEmpty); + }); + + test('the S2 flag is not consulted for the FIRST accepted frame', () { + final (s, _) = run([pkt(seq: 1, s2One: false)]); + expect(s.phase, EcgPhase.active); + expect(seqs(s), [1]); + }); + }); + + group('sequence gaps', () { + test('a jump inserts exactly one empty placeholder at previous + 1', () { + final (s, e) = run([pkt(seq: 10), pkt(seq: 20, progress: 6)]); + expect(seqs(s), [10, 11, 20]); + expect(s.accepted[1].placeholder, isTrue); + expect(s.accepted[1].samples, isEmpty); + expect(e.whereType().single.sequence, 11); + }); + + test('a jump on the terminal frame is placeholder-then-terminal', () { + final (s, _) = run([pkt(seq: 10), terminal(seq: 15)]); + expect(seqs(s), [10, 11, 15]); + }); + }); + + group('official capture shape', () { + test('86 transport frames → 30 accepted → 3,000 samples, repeated ' + 'terminal excluded, no resets', () { + // 55 pre-contact frames (no presence / zero progress), then 29 + // progressing frames, the terminal, and one repeated terminal. + final frames = [ + for (var i = 0; i < 49; i++) + pkt(seq: 23940914 + i, presence: false, progress: 0), + for (var i = 49; i < 55; i++) + pkt(seq: 23940914 + i, presence: true, progress: 0), + for (var i = 55; i < 84; i++) + pkt(seq: 23940914 + i, progress: 3 + ((i - 55) * 97 ~/ 29)), + terminal(seq: 23940998, avgHr: 77, liveHr: 78), + terminal(seq: 23940999, avgHr: 77, liveHr: 78), + ]; + expect(frames, hasLength(86)); + final (s, e) = run(frames); + expect(s.phase, EcgPhase.done); + expect(s.accepted, hasLength(30)); + expect(seqs(s).first, 23940969); + expect(seqs(s).last, 23940998); + expect(s.accepted.fold(0, (n, p) => n + p.samples.length), 3000); + expect(s.accepted.any((p) => p.placeholder), isFalse); + expect(s.interruptions, 0); + final t = e.whereType().single.outcome; + expect(t.kind, EcgTerminalKind.completed); + expect(t.persistedCategory, EcgCategory.sinusRhythm); + expect(t.averageHr, 77); + final stats = EcgWindowStats.of(s.accepted); + expect(stats.sampleCount, 3000); + expect(stats.missingSegments, 0); + }); + }); +} diff --git a/test/ecg_support_test.dart b/test/ecg_support_test.dart new file mode 100644 index 000000000..f774e3724 --- /dev/null +++ b/test/ecg_support_test.dart @@ -0,0 +1,149 @@ +// The live-preview ring buffer + repaint coalescing, the prefs-backed guard +// store, and retained-guard recovery. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ecg/ecg_guard_store.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; +import 'package:openstrap_edge/ecg/ecg_recovery.dart'; +import 'package:openstrap_edge/ecg/ecg_transport.dart'; +import 'package:openstrap_edge/ecg/ecg_waveform_buffer.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EcgWaveformBuffer', () { + test('holds the newest samples oldest-first and wraps', () { + final b = EcgWaveformBuffer(capacity: 5); + b.push(Int16List.fromList([1, 2, 3])); + expect(b.length, 3); + expect([for (var i = 0; i < b.length; i++) b[i]], [1, 2, 3]); + b.push(Int16List.fromList([4, 5, 6, 7])); + expect(b.length, 5); + expect([for (var i = 0; i < b.length; i++) b[i]], [3, 4, 5, 6, 7]); + expect(b.maxAbs(), 7); + b.push(Int16List.fromList([-9])); + expect([for (var i = 0; i < b.length; i++) b[i]], [4, 5, 6, 7, -9]); + expect(b.maxAbs(), 9); + expect(() => b[5], throwsRangeError); + }); + + test('version bumps per push; clear empties', () { + final b = EcgWaveformBuffer(capacity: 4); + final v0 = b.version; + b.push(Int16List.fromList([1])); + b.push(Int16List(0)); + expect(b.version, v0 + 1, reason: 'an empty push is not a change'); + b.clear(); + expect(b.isEmpty, isTrue); + expect(b.version, v0 + 2); + }); + }); + + group('EcgPreviewScheduler', () { + test('many marks inside one tick coalesce to one notification', () { + final s = EcgPreviewScheduler(); + var n = 0; + s.addListener(() => n++); + for (var i = 0; i < 50; i++) { + s.markDirty(); + } + expect(s.tick(), isTrue); + expect(n, 1); + expect(s.tick(), isFalse, reason: 'nothing changed since'); + expect(n, 1); + }); + }); + + group('PrefsEcgGuardStore', () { + test( + 'guard, wrist and remembered-MG flag are per serial and durable', + () async { + SharedPreferences.setMockInitialValues({}); + final g = PrefsEcgGuardStore(); + expect(await g.isActive('A'), isFalse); + expect(await g.setActive('A'), isTrue); + expect(await g.isActive('A'), isTrue); + expect(await g.isActive('B'), isFalse); + expect(await g.clear('A'), isTrue); + expect(await g.isActive('A'), isFalse); + await g.setWrist('A', EcgWrist.left); + expect(await g.wrist('A'), EcgWrist.left); + expect(await g.wrist('B'), isNull); + expect(await g.isRememberedMaverick('A'), isFalse); + await g.rememberMaverick('A'); + expect(await g.isRememberedMaverick('A'), isTrue); + final raw = await SharedPreferences.getInstance(); + expect(raw.getBool('ecg.maverick.A'), isTrue); + expect(raw.getString('ecg.wrist.A'), 'left'); + }, + ); + }); + + group('ecgRecoverRetainedGuard', () { + EcgCommandListResult ok() => const EcgCommandListResult([ + EcgMemberOutcome('generationStop', written: true, succeeded: true), + EcgMemberOutcome('filteredOff', written: true, succeeded: true), + EcgMemberOutcome('rawSaveOff', written: true, succeeded: true), + ]); + EcgCommandListResult partial() => const EcgCommandListResult([ + EcgMemberOutcome('generationStop', written: true, succeeded: true), + EcgMemberOutcome('filteredOff', written: true, succeeded: false), + EcgMemberOutcome('rawSaveOff', written: true, succeeded: true), + ]); + + test('no guard → nothing sent', () async { + final g = MemoryEcgGuardStore(); + var sent = 0; + final r = await ecgRecoverRetainedGuard( + guard: g, + serial: 'S', + cleanup: () async { + sent++; + return ok(); + }, + log: (_) {}, + ); + expect(r, EcgRecoveryOutcome.noGuard); + expect(sent, 0); + }); + + test('a retained guard runs cleanup; all-success clears it', () async { + final g = MemoryEcgGuardStore()..active.add('S'); + final r = await ecgRecoverRetainedGuard( + guard: g, + serial: 'S', + cleanup: () async => ok(), + log: (_) {}, + ); + expect(r, EcgRecoveryOutcome.cleared); + expect(g.active, isEmpty); + }); + + test('a failed member retains the guard', () async { + final g = MemoryEcgGuardStore()..active.add('S'); + final r = await ecgRecoverRetainedGuard( + guard: g, + serial: 'S', + cleanup: () async => partial(), + log: (_) {}, + ); + expect(r, EcgRecoveryOutcome.retained); + expect(g.active, contains('S')); + }); + + test('no serial → nothing happens', () async { + final g = MemoryEcgGuardStore()..active.add('S'); + final r = await ecgRecoverRetainedGuard( + guard: g, + serial: null, + cleanup: () async => ok(), + log: (_) {}, + ); + expect(r, EcgRecoveryOutcome.noSerial); + expect(g.active, contains('S')); + }); + }); +} diff --git a/test/ecg_ui_test.dart b/test/ecg_ui_test.dart new file mode 100644 index 000000000..ee09d8452 --- /dev/null +++ b/test/ecg_ui_test.dart @@ -0,0 +1,544 @@ +// The ECG capture body in every phase (data in, callbacks out), the touch +// illustration under reduced motion, the live preview label, the saved +// waveform painter's placeholder breaks, the detail screen's empty-waveform +// state and Analyze-now prompt, and the wrist sheet. + +import 'dart:typed_data'; +import 'dart:ui' show PictureRecorder; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/ecg/ecg_controller.dart'; +import 'package:openstrap_edge/ecg/ecg_models.dart'; +import 'package:openstrap_edge/ecg/ecg_waveform_buffer.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/ecg.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _pump( + WidgetTester t, + Widget home, { + bool reducedMotion = false, +}) async { + t.view.physicalSize = const Size(1170, 2532); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget( + MediaQuery( + data: MediaQueryData(disableAnimations: reducedMotion), + child: MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), + ), + ); + await t.pump(); +} + +String _allText(WidgetTester t) => + t.widgetList(find.byType(Text)).map((w) => w.data ?? '').join('\n'); + +Widget _body(EcgCaptureState s, {List? log}) => Scaffold( + body: EcgCaptureBody( + state: s, + wrist: EcgWrist.right, + phase: 0.25, + live: EcgWaveformBuffer(capacity: 100), + scheduler: EcgPreviewScheduler(), + onRetry: () => log?.add('retry'), + onTakeAnother: () => log?.add('another'), + onDone: () => log?.add('done'), + onView: () => log?.add('view'), + ), +); + +EcgAcceptedPacket _pkt(int seq, {int n = 100}) => EcgAcceptedPacket( + sequence: seq, + strapSeconds: 1787823700 + seq, + strapSubsec: 0, + samples: Int16List.fromList(List.generate(n, (i) => (i * 7) % 300 - 150)), + inner: Uint8List(0), +); + +EcgReading _reading({ + int packets = 3, + EcgCategory category = EcgCategory.sinusRhythm, +}) => EcgReading( + id: 'ecg_1', + deviceId: '', + wrist: EcgWrist.left, + startTs: 1787823754, + endTs: 1787823784, + strapTerminalTs: 1787823784, + strapTerminalSubsec: 0, + resultCode: 1, + category: category, + avgHr: 77, + quality: 3, + unreadableMask: 0, + interruptions: 1, + sampleCount: packets * 100, + minUv: -150, + maxUv: 149, + rmsUv: 86.6, + missingSegments: 0, + status: EcgReadingStatus.completed, + notes: null, + createdAt: 1787823784000, +); + +void main() { + group('capture body per phase', () { + testWidgets('waiting: instructions, illustration, status, preview label', ( + t, + ) async { + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.waiting)), + ); + final text = _allText(t); + expect(text, contains('Rest your arm. Touch both metal sides')); + expect(text, contains('Waiting for contact')); + expect(text, contains('Live signal preview')); + expect(text, contains('µV')); + expect(find.byType(EcgTouchIllustration), findsOneWidget); + expect(find.byType(EcgLivePreview), findsOneWidget); + // No lead / polarity claim anywhere on the capture screen. + expect(text.toLowerCase(), isNot(contains('lead i'))); + }); + + testWidgets('preparing shows no preview yet; recovering says why', ( + t, + ) async { + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.preparing)), + ); + expect(_allText(t), contains('Preparing the band')); + expect(find.byType(EcgLivePreview), findsNothing); + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.recovering)), + ); + expect(_allText(t), contains('Stopping a previous reading first')); + }); + + testWidgets('active: band progress and live HR, no fake timer', (t) async { + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.active, + progress: 42, + liveHr: 71, + ), + ), + ); + final text = _allText(t); + expect(text, contains('42% complete')); + expect(text, contains('71')); + expect(text, contains('Measuring')); + final bar = t.widget( + find.byType(LinearProgressIndicator), + ); + expect(bar.value, closeTo(.42, 1e-9)); + }); + + testWidgets('contact lost shows the exact instruction', (t) async { + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.contactLost, + progress: 30, + ), + ), + ); + expect(_allText(t), contains('Adjust your fingers and keep still')); + }); + + testWidgets('restarting, saving, cleaning up', (t) async { + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.restarting)), + ); + expect(_allText(t), contains('Restarting')); + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.saving)), + ); + expect(_allText(t), contains('Saving')); + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.cleaningUp)), + ); + expect(_allText(t), contains('Stopping the band')); + }); + + testWidgets( + 'completed offers view and done, and says it is not a diagnosis', + (t) async { + final log = []; + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.completed, + readingId: 'ecg_1', + ), + log: log, + ), + ); + expect(_allText(t), contains('Reading saved')); + expect(_allText(t), contains('not a diagnosis')); + await t.tap(find.text('View reading')); + await t.tap(find.text('Done')); + expect(log, ['view', 'done']); + }, + ); + + testWidgets( + 'unreadable lists the band reasons and offers another reading', + (t) async { + final log = []; + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.unreadable, + unreadableMask: 0x0a, + ), + log: log, + ), + ); + final text = _allText(t); + expect(text, contains('could not read')); + expect(text, contains('Significant noise')); + expect(text, contains('Not enough data')); + expect(text, isNot(contains('Low amplitude'))); + await t.tap(find.text('Take another')); + expect(log, ['another']); + }, + ); + + testWidgets('inconclusive offers exactly the one retry', (t) async { + final log = []; + await _pump( + t, + _body( + const EcgCaptureState(phase: EcgCapturePhase.inconclusiveRetry), + log: log, + ), + ); + expect(_allText(t), contains('try once more')); + await t.tap(find.text('Try once more')); + expect(log, ['retry']); + }); + + testWidgets('cancelled / failed copy, incl. incomplete cleanup', (t) async { + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.cancelled, + reason: 'cancelled', + ), + ), + ); + expect(_allText(t), contains('Reading cancelled')); + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.failed, + reason: 'disconnected', + cleanupIncomplete: true, + ), + ), + ); + final text = _allText(t); + expect(text, contains('Reading failed')); + expect(text, contains('The band disconnected.')); + expect(text, contains('stopped on the next connection')); + await _pump( + t, + _body( + const EcgCaptureState( + phase: EcgCapturePhase.failed, + reason: 'timeout', + ), + ), + ); + expect(_allText(t), contains('two minutes')); + }); + + testWidgets('incompatible, disconnected and busy are status cards', ( + t, + ) async { + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.incompatible)), + ); + expect(_allText(t), contains('not a WHOOP MG')); + await _pump( + t, + _body(const EcgCaptureState(phase: EcgCapturePhase.disconnected)), + ); + expect(_allText(t), contains('Connect your WHOOP MG')); + await _pump( + t, + _body( + const EcgCaptureState(phase: EcgCapturePhase.busy, reason: 'workout'), + ), + ); + expect(_allText(t), contains('Finish the other live session')); + }); + }); + + group('illustration and preview', () { + testWidgets( + 'the illustration carries semantics and paints under reduced motion', + (t) async { + await _pump( + t, + const Scaffold( + body: EcgTouchIllustration( + wrist: EcgWrist.left, + t: 0, + contact: false, + semanticLabel: 'Illustration: the band on your wrist', + ), + ), + reducedMotion: true, + ); + expect(find.bySemanticsLabel(RegExp('Illustration')), findsOneWidget); + expect(find.byType(CustomPaint), findsWidgets); + }, + ); + + test('the preview range is stepped and floored', () { + expect(ecgPreviewRange(0), 500); + expect(ecgPreviewRange(120), 500); + expect(ecgPreviewRange(600), 750); + expect(ecgPreviewRange(751), 1000); + expect(ecgPreviewRange(5396), 5500); + }); + + testWidgets('the preview repaints on the scheduler tick only', (t) async { + final buf = EcgWaveformBuffer(capacity: 50); + final sched = EcgPreviewScheduler(); + await _pump( + t, + Scaffold( + body: EcgLivePreview( + buffer: buf, + scheduler: sched, + label: 'Live signal preview', + unit: 'µV', + ), + ), + ); + EcgLivePainter painter() => t + .widgetList(find.byType(CustomPaint)) + .map((w) => w.painter) + .whereType() + .single; + final v0 = painter().version; + buf.push(Int16List.fromList([1, 2, 3])); + sched.markDirty(); + await t.pump(); + expect(painter().version, v0, reason: 'a push alone does not rebuild'); + sched.tick(); + await t.pump(); + expect(painter().version, v0 + 1); + }); + }); + + group('saved waveform', () { + test( + 'width covers placeholders; a placeholder is a break, not a bridge', + () { + final packets = [_pkt(1), EcgAcceptedPacket.placeholder(2), _pkt(3)]; + expect(EcgWaveformPainter.widthFor(packets, 80), 240); + expect(EcgWaveformPainter.rangeFor(packets), 500); + // Paint onto a recording canvas: the gap wash is drawn exactly once. + final rec = PictureRecorder(); + final cv = Canvas(rec); + EcgWaveformPainter( + packets: packets, + pxPerSecond: 80, + color: const Color(0xFF000000), + grid: const Color(0xFF888888), + gap: const Color(0xFFFF0000), + ).paint(cv, const Size(240, 100)); + rec.endRecording(); + }, + ); + + testWidgets( + 'the detail screen shows the band category, stats and Analyze now; ' + 'an empty waveform is a status card', + (t) async { + await _pump( + t, + EcgDetailScreen( + data: EcgDetailData( + reading: _reading(packets: 0), + packets: const [], + ), + ), + ); + final text = _allText(t); + expect(text, contains('Band-reported result')); + expect(text, contains('Sinus rhythm')); + expect(text, contains('No waveform was saved')); + expect(text, contains('Analyze now')); + expect(text, contains('77 bpm')); + expect(text, contains('Left wrist')); + expect(text, contains('not a diagnosis')); + expect(text.toLowerCase(), isNot(contains('lead i'))); + }, + ); + + testWidgets('with packets the waveform paints inside a horizontal scroll', ( + t, + ) async { + final packets = [_pkt(1), EcgAcceptedPacket.placeholder(2), _pkt(3)]; + await _pump( + t, + EcgDetailScreen( + data: EcgDetailData(reading: _reading(), packets: packets), + ), + ); + expect(find.byType(SingleChildScrollView), findsWidgets); + final painters = t + .widgetList(find.byType(CustomPaint)) + .map((w) => w.painter) + .whereType() + .toList(); + expect(painters, hasLength(1)); + expect(painters.single.packets, hasLength(3)); + expect(_allText(t), contains('300 samples at 100 Hz')); + await t.tap(find.bySemanticsLabel('Zoom in')); + await t.pump(); + final zoomed = t + .widgetList(find.byType(CustomPaint)) + .map((w) => w.painter) + .whereType() + .single; + expect(zoomed.pxPerSecond, greaterThan(painters.single.pxPerSecond)); + }); + }); + + group('analyze now with a configured coach', () { + // A tap handler must not `watch` a provider: provider asserts outside + // build and the predicate's own catch swallows it, so the gate reads + // "not configured" no matter what the user has set up. + testWidgets( + 'goes to the coach, not back to setup', + (t) async { + SharedPreferences.setMockInitialValues({}); + final cfg = CoachConfig(); + await cfg.save( + baseUrl: 'http://localhost:11434/v1', + apiKey: null, + model: 'm', + ); + expect(cfg.configured, isTrue, reason: 'precondition'); + + final pushed = []; + t.view.physicalSize = const Size(1170, 2532); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget( + ChangeNotifierProvider.value( + value: cfg, + child: MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + navigatorObservers: [_RouteLog(pushed)], + home: EcgDetailScreen( + data: EcgDetailData( + reading: _reading(packets: 0), + packets: const [], + ), + ), + ), + ), + ); + await t.pump(); + + await t.tap(find.byType(ActionCard)); + expect( + pushed, + isNot(contains('CoachSetup')), + reason: 'the coach is configured — setup must not be pushed', + ); + }, + ); + }); + + group('analyze-now prompt', () { + test('names the tool and asks for a reading of the waveform', () { + final p = ecgAnalyzePrompt('ecg_1'); + expect(p, contains('Analyse my ECG reading ecg_1')); + expect(p, contains('Use get_ecg_reading')); + expect(p, contains('rate, rhythm')); + expect( + p, + contains('polarity'), + reason: 'the unproven polarity is still stated', + ); + }); + }); + + group('wrist sheet', () { + testWidgets('pops the chosen wrist and marks the remembered one', ( + t, + ) async { + EcgWrist? picked; + await _pump( + t, + Builder( + builder: (c) => Scaffold( + body: Center( + child: TextButton( + onPressed: () async { + picked = await showModalBottomSheet( + context: c, + builder: (_) => + const EcgWristSheet(current: EcgWrist.right), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(_allText(t), contains('Which wrist is the band on?')); + await t.tap(find.text('Left wrist')); + await t.pumpAndSettle(); + expect(picked, EcgWrist.left); + }); + }); +} + +/// Records the names of pushed routes so a tap can be asserted without +/// building the destination screen. +class _RouteLog extends NavigatorObserver { + _RouteLog(this.pushed); + final List pushed; + @override + void didPush(Route route, Route? previousRoute) { + pushed.add(route.settings.name); + } +} diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index 2819e094f..24ef16a9a 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -185,7 +185,7 @@ void main() { DrainController drain() => DrainController( onRecord: (_, _) async {}, onRecordsBatch: null, - onCommit: (_, _, _, {archives, deviceFamily}) async {}, + onCommit: (_, _, _, {archives, ecgRawPackets, deviceFamily}) async {}, onArchive: (_) async {}, log: (_) {}, ); diff --git a/test/history_task_safety_test.dart b/test/history_task_safety_test.dart index ee93a9fd6..94dc00fa7 100644 --- a/test/history_task_safety_test.dart +++ b/test/history_task_safety_test.dart @@ -159,7 +159,7 @@ class _Rig { /// everything session-scoped is concerned). void connect() => engine.debugInstallFakeLink( band: band, - onCommit: (raws, samples, token, {archives, deviceFamily}) async { + onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { final hold = holdCommit; if (hold != null) { holdCommit = null; @@ -235,7 +235,7 @@ void main() { DrainController drain() => DrainController( onRecord: (sample, raw) async {}, onRecordsBatch: null, - onCommit: (raws, samples, token, {archives, deviceFamily}) async {}, + onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async {}, onArchive: null, log: (_) {}, ); @@ -367,7 +367,7 @@ void main() { final d = DrainController( onRecord: (sample, r) async {}, onRecordsBatch: null, - onCommit: (raws, samples, token, {archives, deviceFamily}) async { + onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { commits.add((token, raws.length)); }, onArchive: null, diff --git a/test/screen_wake_owners_test.dart b/test/screen_wake_owners_test.dart new file mode 100644 index 000000000..6685ce613 --- /dev/null +++ b/test/screen_wake_owners_test.dart @@ -0,0 +1,81 @@ +// ScreenWake with owners: the display is held while ANY owner remains, a +// failed platform enable is retried by the next transition, and concurrent +// hold/release keep their order through the serialized chain. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/gps/screen_wake.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('openstrap/edge_tracking'); + final calls = []; + bool answer = true; + + setUp(() { + ScreenWake.resetForTest(); + ScreenWake.platformOverride = 'android'; + calls.clear(); + answer = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.arguments['on'] as bool); + return answer; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + ScreenWake.resetForTest(); + }); + + test( + 'one owner releasing while another remains keeps the display held', + () async { + await ScreenWake.hold('workout'); + await ScreenWake.hold('ecg'); + expect(calls, [ + true, + ], reason: 'the second hold is a no-op on the platform'); + await ScreenWake.releaseOwner('workout'); + expect(ScreenWake.isHeld, isTrue); + expect(calls, [true]); + await ScreenWake.releaseOwner('ecg'); + expect(ScreenWake.isHeld, isFalse); + expect(calls, [true, false]); + }, + ); + + test( + 'a failed enable leaves the owner recorded and the next transition retries', + () async { + answer = false; + await ScreenWake.hold('ecg'); + expect(ScreenWake.isHeld, isFalse); + expect(ScreenWake.owners, {'ecg'}); + answer = true; + await ScreenWake.hold('ecg'); + expect(ScreenWake.isHeld, isTrue); + expect(calls, [true, true]); + }, + ); + + test('concurrent hold/release resolve in order and never end held', () async { + final a = ScreenWake.hold('ecg'); + final b = ScreenWake.releaseOwner('ecg'); + await Future.wait([a, b]); + expect(ScreenWake.isHeld, isFalse); + expect(ScreenWake.owners, isEmpty); + // The owner set is reconciled when each link of the chain runs, so a + // hold immediately undone may never reach the platform at all — and if + // it did, the release followed it. + expect(calls.isEmpty || calls.last == false, isTrue); + }); + + test('releasing an owner that never held is harmless', () async { + await ScreenWake.releaseOwner('nobody'); + expect(calls, isEmpty); + expect(ScreenWake.isHeld, isFalse); + }); +} diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 9299db905..05a6a0a55 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -195,6 +195,14 @@ void main() { const _notComponents = { // shell and routing 'AppShell', 'Domain', 'GalleryScreen', + // WHOOP MG ECG routes: the Heart Screener entry reads the database and + // pushes; the capture screen owns a live BLE reading (a gallery case would + // start one); the detail screen reads and deletes a reading and routes to + // the coach; the wrist sheet pops a Navigator. `EcgCaptureBody` is the + // pure half of the capture screen and is what ecg_ui_test.dart pumps, + // phase by phase. + 'EcgEntryCard', 'EcgHomeScreen', 'EcgCaptureScreen', 'EcgDetailScreen', + 'EcgWristSheet', // onboarding routes 'BootSplash', 'WelcomeScreen', 'WelcomeView', 'PairingScreen', 'PairingView', 'ProfileSetupScreen', 'ProfileSetupView',