From 20cb09ade6126c2094cc1a9ae9cbd9891f6f4b93 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 11:39:30 +0200 Subject: [PATCH 01/10] =?UTF-8?q?db:=20WHOOP=20MG=20ECG=20store=20(v51)=20?= =?UTF-8?q?=E2=80=94=20reading,=20accepted=20packets,=20raw=20R16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive tables behind a CREATE-IF-NOT-EXISTS rung: ecg_reading (the band-reported result of one user-initiated Labrador reading), ecg_reading_packet (the accepted R17 window, exact signed-i16-LE samples as a BLOB plus the inner hex, one placeholder row per sequence jump) and ecg_raw_packet (the raw R16 records the band saved and ordinary history recovered, byte-exact, keyed by bytes). No foreign keys — this DB never enables them — so the reading→packet cascade is a manual two-statement transaction. Units pinned: domain/strap timestamps in seconds, created_at/captured_at in ms like raw_archive. Raw R16 rides commitSyncBatch inside the same transaction as decoded rows, archives and the trim cursor, so nothing the band trims on ACK lacks a durable home. Ownership: the three tables join the salvage order and the backup-restore list (lifted to _restoreTables so a test can pin it); wipeAll enumerates sqlite_master and needs nothing. Coach: v_ecg_readings is a summary-only view over ecg_reading alone — the structural btree gate admits every base table a view reads, so the packet tables are never joined; ecg_reading/ecg_reading_packet/ecg_raw_packet join reservedTableNames so device_id/notes stay out of run_sql. Tests: fresh schema + view, v50→v51 ladder + idempotent reopen, atomic insert and rollback, signed BLOB / placeholder round trip, manual cascade, R16 in the safe-trim commit (idempotent on bytes), ownership lists + wipe, and the coach guard at both layers. --- lib/coach/coach_db.dart | 6 + lib/data/db.dart | 277 ++++++++++++++++++++++++-- lib/data/models.dart | 24 +++ test/coach_ecg_security_test.dart | 103 ++++++++++ test/db_migration_ladder_test.dart | 23 +++ test/ecg_db_test.dart | 300 +++++++++++++++++++++++++++++ 6 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 test/coach_ecg_security_test.dart create mode 100644 test/ecg_db_test.dart diff --git a/lib/coach/coach_db.dart b/lib/coach/coach_db.dart index 1d999bfb9..9bc08e41f 100644 --- a/lib/coach/coach_db.dart +++ b/lib/coach/coach_db.dart @@ -64,6 +64,7 @@ class CoachDb { 'v_sessions', 'v_baselines', 'v_insights', + 'v_ecg_readings', }; // Keywords that must never appear as standalone tokens (anything mutating or @@ -89,6 +90,11 @@ class CoachDb { // raw / decoded substrate 'raw_records', 'raw_archive', 'decoded_onehz', 'decoded_rr', 'samples', 'events', 'band_events', 'band_battery', + // 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/data/db.dart b/lib/data/db.dart index 9926d091b..e6e8593b9 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -181,6 +181,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', @@ -337,7 +343,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 = 50; + static const int schemaVersion = 51; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -422,6 +428,7 @@ class LocalDb { await _createPrimitiveArtifacts(db); await _createLiveCoverage(db); await _createDevice(db); + await _createEcgTables(db); await _createWorkoutSuggestions(db); await _createSleepOverride(db); await _createSleepNap(db); @@ -941,6 +948,15 @@ class LocalDb { // metric and nothing here changes a derived number. await _createAlarmSchedule(db); } + if (oldV < 51) { + // 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. + await _createEcgTables(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1019,6 +1035,7 @@ class LocalDb { await _ensureDayResultPartialColumn(db); await _createNotifFired(db); await _createAlarmSchedule(db); + 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); @@ -1462,6 +1479,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. @@ -2490,6 +2688,9 @@ class LocalDb { String? trimToken, Map? extraCursors, List? archives, + // 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 @@ -2623,6 +2824,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); @@ -2652,7 +2868,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'); @@ -3428,6 +3645,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'); @@ -3600,6 +3818,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 @@ -7105,15 +7341,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, @@ -7180,8 +7411,30 @@ class LocalDb { // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY // row is deliberately skipped on the way in; see the guard below. 'device', + // 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 0d67a7910..4e55d3c56 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -316,6 +316,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/test/coach_ecg_security_test.dart b/test/coach_ecg_security_test.dart new file mode 100644 index 000000000..4c013fe16 --- /dev/null +++ b/test/coach_ecg_security_test.dart @@ -0,0 +1,103 @@ +// 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/db_migration_ladder_test.dart b/test/db_migration_ladder_test.dart index 2e85d7fd1..6a03babe2 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_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); + }); +} From e78df606483e9db49387ec11dc20db9a5c7f2a33 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 11:56:12 +0200 Subject: [PATCH 02/10] =?UTF-8?q?ble:=20WHOOP=20MG=20ECG=20transport=20?= =?UTF-8?q?=E2=80=94=20lease,=20Labrador=20lists,=20R17/R16=20paths,=20REA?= =?UTF-8?q?DY=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine owns the transport half of an ECG reading: - EcgLease: exclusive ownership of the command transport for one link, bound to the session and link generation, acquired synchronously, validated on every ECG write and on release, voided at teardown (with an EcgLinkDownEvent naming the old generation). History refresh/backfill are refused while a valid lease is held; the heartbeat's LINK_VALID and the keep-alive's command writes pause under it (the liveness fuse keeps running). - ecgPrepare/ecgStart/ecgRestart/ecgCleanup: the exact docs/mg/05 lists through the protocol builders (cmdSelectWrist, cmdLabradorFiltered / RawSave / DataGeneration, cmdAbortHistorical with the gen5 profile). Attempt-all, one correlated await each — observer before write, seq+opcode match, the common 5 s timeout, no retry. _sendAwaited gains an owner so a stale continuation cannot write onto a replacement link. - ecgCancelHistory: ends an active history task through the one abort boundary (new terminal kind 'preempted') and waits for lifecycle quiescence. START's own opcode 20 stays unconditional. - Live type-43 revision-17 frames are parsed and delivered synchronously as EcgFrameEvent; a frame that claims R17 but does not parse is EcgMalformedR17Event. RAM only. - Historical type-47 revision-16 raw ECG is buffered on the DrainController as a burst count member and rides the safe-trim commit into ecg_raw_packet. Every durable-progress predicate knows about it: HISTORY_END, the HISTORY_START mid-burst discard, aborted-task leftovers, the commit early-return, snapshot/restore/discard; the headless drainer's commit closure forwards it too. - READY recovery: onReadyEcgRecovery runs after bootstrap and BEFORE listening is published or INIT claims history, under a recovery lease, so a retained may-be-active guard can send the cleanup triplet first. Tests: identity, lease, exact bytes/order per list, correlation and timeout, attempt-all after FAILURE and after a failed write, stale lease, history preemption and refusal, R17 parsed/malformed/other-revision/gen4, R16 buffering + commit + restore + discard, link-down event, opcode-safety pin, READY order via the real official connect (cleanup before GET_DATA_RANGE, READY invisible during the hook). --- lib/ble/ble_engine.dart | 440 +++++++++++++++++++++++-- lib/state/app_state.dart | 4 +- lib/sync/background_sync.dart | 4 +- test/ble_safe_trim_test.dart | 30 +- test/ecg_ble_engine_test.dart | 507 +++++++++++++++++++++++++++++ test/gen5_wiring_test.dart | 2 +- test/history_task_safety_test.dart | 6 +- 7 files changed, 952 insertions(+), 41 deletions(-) create mode 100644 test/ecg_ble_engine_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3f3978920..38d7b1373 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -77,6 +77,7 @@ typedef CommitSyncBatchSink = List samples, String? trimTokenHex, { List? archives, + List? ecgRawPackets, String? deviceFamily, }); @@ -86,6 +87,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. @@ -288,8 +367,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). @@ -422,6 +504,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 { @@ -835,6 +922,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; + /// If provided, sync chunks are persisted via this ATOMIC commit (raw + samples /// + continuation cursor in one transaction) before the HISTORY_END ACK. This is /// what makes the offload resumable across restarts (durable cursor). @@ -872,6 +966,8 @@ class BleEngine { this.onDataStored, this.onLiveFrame, this.onOffloadState, + this.onEcgEvent, + this.onReadyEcgRecovery, this.onCommitBatch, this.onArchiveRecord, this.cursorReader, @@ -911,6 +1007,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 @@ -1329,6 +1604,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. @@ -2572,7 +2852,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 @@ -2630,6 +2913,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. @@ -3463,7 +3753,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 @@ -3708,6 +4001,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.', @@ -3727,10 +4025,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.', ); @@ -4260,6 +4561,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()); @@ -4268,7 +4570,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.'); @@ -4393,17 +4695,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 ───────────────────────────────────────────────────────────── @@ -4461,6 +4769,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) { @@ -4663,6 +4984,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; @@ -4723,6 +5051,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/ @@ -5830,7 +6182,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.', @@ -6060,15 +6414,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 @@ -7439,7 +7795,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 @@ -7667,6 +8027,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(); @@ -7680,6 +8044,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 @@ -7990,13 +8379,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] @@ -8024,7 +8415,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 = @@ -8033,10 +8426,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( @@ -8045,7 +8442,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) { @@ -8054,11 +8452,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/state/app_state.dart b/lib/state/app_state.dart index 77715ab1d..eddc99c85 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1202,10 +1202,12 @@ class AppState extends ChangeNotifier { // RESUMABLE SYNC: atomic commit of decoded rows + continuation cursor // before the HISTORY_END ACK, and a reader to seed the offload frontier // from the durable high-water on (re)connect. - onCommitBatch: (raws, samples, trimTokenHex, {archives, deviceFamily}) => + onCommitBatch: (raws, samples, trimTokenHex, + {archives, ecgRawPackets, deviceFamily}) => LocalDb.commitSyncBatch(raws, samples, trimToken: trimTokenHex, archives: archives, + ecgRawPackets: ecgRawPackets, deviceFamily: deviceFamily, onCheckpoint: (msg) => _log('[COMMIT] $msg')), // Pre-setup fallback only: the drain path archives inside commitSyncBatch. diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 9c2e63d53..63ee7d167 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -74,10 +74,12 @@ Future runHeadlessSync({BandLease? lease}) async { onEvent: (id, ts, hex) => LocalDb.insertEvent(id, ts, hex), log: (l) => debugPrint('[bgsync] $l'), onRecordsBatch: LocalDb.insertRecordsBatch, - onCommitBatch: (raws, samples, trimTokenHex, {archives, deviceFamily}) => + onCommitBatch: (raws, samples, trimTokenHex, + {archives, ecgRawPackets, deviceFamily}) => LocalDb.commitSyncBatch(raws, samples, trimToken: trimTokenHex, archives: archives, + ecgRawPackets: ecgRawPackets, deviceFamily: deviceFamily, onCheckpoint: (msg) => debugPrint('[bgsync][COMMIT] $msg')), onArchiveRecord: LocalDb.archiveRawRecord, 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/ecg_ble_engine_test.dart b/test/ecg_ble_engine_test.dart new file mode 100644 index 000000000..524e00b12 --- /dev/null +++ b/test/ecg_ble_engine_test.dart @@ -0,0 +1,507 @@ +// 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++) { + 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/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, From f85cc509dedc86da4afe5ecb4d9130567d115ed7 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 12:10:51 +0200 Subject: [PATCH 03/10] =?UTF-8?q?ecg:=20WHOOP=20MG=20reading=20owner=20?= =?UTF-8?q?=E2=80=94=20pure=20reducer,=20controller,=20guard,=20recovery,?= =?UTF-8?q?=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/ecg/ is the phone-side half of a Labrador reading, behind an abstract EcgTransport (BleEngineEcgTransport forwards to the engine): - ecg_policy.dart: the official foreground R17 state machine as a PURE reducer (docs/mg/05 §4 / 06 §6) — WAITING ignores pre-contact packets; ACTIVE appends while the current-S2-state-1 flag is set, clears and counts one interruption per contact-loss transition, fails on progress 255, routes the exact explicit-RESTART predicate (presence, positive nondecreasing nonterminal progress, S2 flag clear) to a restart effect; CONTACT_LOST recovers without a command and fails on 255 or a bad packet with three interruptions banked; one placeholder per sequence jump; the terminal maps result+live HR for the branch and result+average HR for the persisted category; repeated terminals are ignored; first-attempt inconclusive offers exactly one retry. - ecg_controller.dart: lease → retained-guard recovery → history cancel → acknowledged guard write → PREPARE → subscribe → START (frames at the write boundary count) → reducer → durable save BEFORE completed → ONE cleanup on every exit (success, cancel, pause, timeout, malformed R17, link loss, save failure, partial command failure) → guard cleared only when all three cleanup members succeeded → post-reading history sync. Single-flight epoch, frames gated on link generation and dropped while a restart is in flight, screen hold released only when held. - ecg_guard_store.dart: per-serial guard / wrist / remembered-MG flag in SharedPreferences through the awaited API; ecg_recovery.dart: the controller-free retained-guard cleanup used at READY by both the foreground engine and the headless drainer. - ecg_models.dart: the band-result category table, typed rows and the signed-i16 BLOB codec; ecg_waveform_buffer.dart: the bounded live-preview ring plus a per-tick repaint coalescer. - ScreenWake gains owners (hold/releaseOwner); the workout call sites move to their own owner so neither feature can drop the other's hold. - AppState hosts the controller, folds capturing into _hasLiveConsumer, stops a reading on pauseForBackground before the live downgrade, remembers a MAVERICK hello per serial (pairedIsMaverick), and wires the READY recovery hook; runHeadlessSync wires the same recovery. Tests: every reducer rule plus the 86→30→3,000 official shape; the category table at every boundary; row codecs and stats; buffer/scheduler; prefs store; recovery outcomes; the controller over a scripted fake transport (order, guard-before-write, START-boundary frame, failures, retained guard, exits, restart single-flight, retry, save failure, single flight); screen-wake owners. --- lib/ecg/ble_ecg_transport.dart | 98 ++++++ lib/ecg/ecg_controller.dart | 539 ++++++++++++++++++++++++++++ lib/ecg/ecg_guard_store.dart | 120 +++++++ lib/ecg/ecg_models.dart | 358 +++++++++++++++++++ lib/ecg/ecg_policy.dart | 284 +++++++++++++++ lib/ecg/ecg_recovery.dart | 43 +++ lib/ecg/ecg_transport.dart | 96 +++++ lib/ecg/ecg_waveform_buffer.dart | 94 +++++ lib/gps/screen_wake.dart | 49 ++- lib/state/app_state.dart | 100 +++++- lib/sync/background_sync.dart | 19 + test/ecg_controller_test.dart | 560 ++++++++++++++++++++++++++++++ test/ecg_models_test.dart | 147 ++++++++ test/ecg_policy_test.dart | 348 +++++++++++++++++++ test/ecg_support_test.dart | 127 +++++++ test/screen_wake_owners_test.dart | 75 ++++ 16 files changed, 3043 insertions(+), 14 deletions(-) create mode 100644 lib/ecg/ble_ecg_transport.dart create mode 100644 lib/ecg/ecg_controller.dart create mode 100644 lib/ecg/ecg_guard_store.dart create mode 100644 lib/ecg/ecg_models.dart create mode 100644 lib/ecg/ecg_policy.dart create mode 100644 lib/ecg/ecg_recovery.dart create mode 100644 lib/ecg/ecg_transport.dart create mode 100644 lib/ecg/ecg_waveform_buffer.dart create mode 100644 test/ecg_controller_test.dart create mode 100644 test/ecg_models_test.dart create mode 100644 test/ecg_policy_test.dart create mode 100644 test/ecg_support_test.dart create mode 100644 test/screen_wake_owners_test.dart diff --git a/lib/ecg/ble_ecg_transport.dart b/lib/ecg/ble_ecg_transport.dart new file mode 100644 index 000000000..0ad3ecffa --- /dev/null +++ b/lib/ecg/ble_ecg_transport.dart @@ -0,0 +1,98 @@ +// 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..9d25e43ca --- /dev/null +++ b/lib/ecg/ecg_controller.dart @@ -0,0 +1,539 @@ +// 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..51e44f820 --- /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..1ec947071 --- /dev/null +++ b/lib/ecg/ecg_policy.dart @@ -0,0 +1,284 @@ +// 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..3cfcc279b --- /dev/null +++ b/lib/ecg/ecg_recovery.dart @@ -0,0 +1,43 @@ +// 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..7a5733752 --- /dev/null +++ b/lib/ecg/ecg_transport.dart @@ -0,0 +1,96 @@ +// 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..89b0190ca --- /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/state/app_state.dart b/lib/state/app_state.dart index eddc99c85..7df411334 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -57,6 +57,11 @@ import '../data/auto_backup.dart' as backup show runBackupIfDue; import 'alarm_schedule.dart'; import 'prefs.dart'; 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'; @@ -139,6 +144,65 @@ PairedDevice? healedPairing(PairedDevice? current, String? reportedSerial) { class AppState extends ChangeNotifier { late final BleEngine engine; 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 — @@ -1198,6 +1262,8 @@ class AppState extends ChangeNotifier { onState: _onEngineState, log: _log, onEvent: _onLiveEvent, + onEcgEvent: (e) => _ecgTransport?.onEngineEvent(e), + onReadyEcgRecovery: _recoverEcgGuardOnReady, onRecordsBatch: LocalDb.insertRecordsBatch, // RESUMABLE SYNC: atomic commit of decoded rows + continuation cursor // before the HISTORY_END ACK, and a reader to seed the offload frontier @@ -1274,8 +1340,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, @@ -1324,6 +1391,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 @@ -2146,6 +2215,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(); @@ -2557,6 +2629,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); @@ -2594,7 +2670,10 @@ class AppState extends ChangeNotifier { /// True while some foreground feature is actively consuming the live streams /// (workout coach, breathing session). bool get _hasLiveConsumer => - activeWorkout != null || breathingActive || breathingWindowOpen; + activeWorkout != null || + breathingActive || + breathingWindowOpen || + (_ecg?.isCapturing ?? false); /// Step live down when backgrounded with no live consumer. Platform-split: /// @@ -3445,6 +3524,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 || @@ -3874,6 +3961,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 — @@ -5399,7 +5487,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, @@ -5665,7 +5753,7 @@ class AppState extends ChangeNotifier { unawaited(_maybeStartRouteTracking(id, activeWorkout!.type)); unawaited(HrsLink.instance.arm()); _deriveScheduler.setWorkoutActive(true); - ScreenWake.enable(); + ScreenWake.hold('workout'); } else { // A stale live row has no end_ts (it was never stopped). We don't // know when the workout actually ended, so the honest stamp is @@ -5718,7 +5806,7 @@ class AppState extends ChangeNotifier { // AWAITED, like the route tail: an unawaited disarm races the finish screen // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); - ScreenWake.release(); + ScreenWake.releaseOwner('workout'); _deriveScheduler.setWorkoutActive(false); final w = activeWorkout!; // Nullable for the same reason `steps` below is: an unanchored profile @@ -5833,7 +5921,7 @@ class AppState extends ChangeNotifier { // AWAITED, like the route tail: an unawaited disarm races the finish screen // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); - ScreenWake.release(); + ScreenWake.releaseOwner('workout'); _deriveScheduler.setWorkoutActive(false); activeWorkout = null; _workoutRawBase = null; diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 63ee7d167..012166b4a 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -22,6 +22,9 @@ import '../ble/oura_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'; @@ -83,6 +86,22 @@ Future runHeadlessSync({BandLease? lease}) async { deviceFamily: deviceFamily, onCheckpoint: (msg) => debugPrint('[bgsync][COMMIT] $msg')), onArchiveRecord: LocalDb.archiveRawRecord, + // 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: LocalDb.getCursorInt, // Mark this as the background drainer: if the foreground app engine already // owns the band (same process — iOS restore-wake OR Android headless boot / diff --git a/test/ecg_controller_test.dart b/test/ecg_controller_test.dart new file mode 100644 index 000000000..d62bb402c --- /dev/null +++ b/test/ecg_controller_test.dart @@ -0,0 +1,560 @@ +// 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_models_test.dart b/test/ecg_models_test.dart new file mode 100644 index 000000000..a80af548a --- /dev/null +++ b/test/ecg_models_test.dart @@ -0,0 +1,147 @@ +// 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..3cb0277e1 --- /dev/null +++ b/test/ecg_policy_test.dart @@ -0,0 +1,348 @@ +// 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..58af00208 --- /dev/null +++ b/test/ecg_support_test.dart @@ -0,0 +1,127 @@ +// 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/screen_wake_owners_test.dart b/test/screen_wake_owners_test.dart new file mode 100644 index 000000000..5e62a28a7 --- /dev/null +++ b/test/screen_wake_owners_test.dart @@ -0,0 +1,75 @@ +// 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); + }); +} From adaef79b79c33af4c202235ac9570226d7a768a6 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 12:25:26 +0200 Subject: [PATCH 04/10] =?UTF-8?q?ui+coach:=20WHOOP=20MG=20Heart=20Screener?= =?UTF-8?q?=20=E2=80=94=20capture,=20history,=20detail,=20Analyze=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health → Heart Screener appears only when the paired band has positively identified itself as a WHOOP MG (remembered per serial, so saved readings stay reachable while the band is away); Take ECG additionally needs that MG connected and READY. The wrist choice is remembered per band. Capture screen: the pipeline is armed while the instructions and an original CustomPainter illustration (band on the chosen wrist, both electrode indents, the opposite thumb and index finger, pulsing contact rings, phase owned by the screen — still under reduced motion) are shown; the first valid contact packet moves it to measurement automatically. Band progress, live HR, "Adjust your fingers and keep still" on contact loss, and a "Live signal preview" of the real samples over a bounded ring, repainted at most every Motion.ecgPreviewTick and never per packet. Back and close cancel through the controller; the app's pause path stops a reading before the live downgrade. Every terminal state has its own copy; the result is always labelled band-reported and never a diagnosis. Detail: the complete accepted waveform with visible breaks at placeholder seconds, horizontal scroll and stepped zoom, the band's category and stats, delete, and Analyze now — which routes through Coach setup when unconfigured, shows an explicit disclosure (host + model) before a remote endpoint receives anything, then opens a NEW coach conversation and sends the fixed prompt visibly as the user's turn. Coach: v_ecg_readings on the allow-list (summary only, see the db commit); get_ecg_reading(reading_id) — a bound read returning the band summary and a ≤300-bucket min/max envelope, never raw frames, serial, device id, notes or all samples; the system prompt gains the ECG law (band's HeartKey category, unproven polarity, no diagnosis from the waveform, no confirm/rule-out, clinical/emergency guidance, one Not-medical-advice line) and the view/tool docs. CoachScreen takes initialMessage + startNewSession for the Analyze-now hand-off. Strings in all six locales. ecg_widgets.dart joins the ui2 barrel; the four components are in the gallery, the routes on the screens list. Tests: every capture phase, reduced-motion illustration, preview repaint only on the scheduler tick, waveform width/range and placeholder handling, detail screen with and without a waveform, zoom, the wrist sheet, the Analyze prompt; the coach tool's bound read, envelope determinism and leak checks, and the prompt pins. --- lib/coach/coach_actions.dart | 95 +++ lib/coach/coach_engine.dart | 23 +- lib/coach/coach_prompt.dart | 18 + lib/ecg/ble_ecg_transport.dart | 16 +- lib/ecg/ecg_controller.dart | 114 ++-- lib/ecg/ecg_models.dart | 104 +-- lib/ecg/ecg_policy.dart | 75 +- lib/ecg/ecg_recovery.dart | 12 +- lib/ecg/ecg_transport.dart | 13 +- lib/ecg/ecg_waveform_buffer.dart | 2 +- lib/l10n/app_de.arb | 74 +- lib/l10n/app_en.arb | 314 +++++++++ lib/l10n/app_es.arb | 74 +- lib/l10n/app_fr.arb | 74 +- lib/l10n/app_hi.arb | 74 +- lib/l10n/app_zh.arb | 74 +- lib/ui2/ecg_widgets.dart | 409 +++++++++++ lib/ui2/profile/gallery.dart | 75 ++ lib/ui2/screens/coach.dart | 21 +- lib/ui2/screens/ecg.dart | 1025 ++++++++++++++++++++++++++++ lib/ui2/screens/health_screen.dart | 7 + lib/ui2/screens/screens.dart | 1 + lib/ui2/theme.dart | 8 + lib/ui2/ui2.dart | 1 + test/coach_ecg_security_test.dart | 88 ++- test/coach_ecg_tool_test.dart | 164 +++++ test/ecg_ble_engine_test.dart | 583 ++++++++++------ test/ecg_controller_test.dart | 241 ++++--- test/ecg_models_test.dart | 51 +- test/ecg_policy_test.dart | 280 +++++--- test/ecg_support_test.dart | 84 ++- test/ecg_ui_test.dart | 476 +++++++++++++ test/screen_wake_owners_test.dart | 58 +- test/ui2_tokens_test.dart | 8 + 34 files changed, 4064 insertions(+), 672 deletions(-) create mode 100644 lib/ui2/ecg_widgets.dart create mode 100644 lib/ui2/screens/ecg.dart create mode 100644 test/coach_ecg_tool_test.dart create mode 100644 test/ecg_ui_test.dart diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart index 93667844a..ca0a9c84f 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'; @@ -97,6 +99,99 @@ class CoachActions { return d.millisecondsSinceEpoch ~/ 1000; } + // ── WHOOP MG ECG ─────────────────────────────────────────────────────────── + + /// At most this many waveform buckets leave the device. + static const int ecgEnvelopeBuckets = 300; + + /// One saved ECG reading for the coach: the band-reported summary plus a + /// bounded min/max envelope of the accepted waveform. A BOUND query on the + /// reading id — never model-written SQL — and never the raw frame hex, the + /// band serial, the device id, the notes or all 3,000 samples. Min/max per + /// bucket, not an average, so a peak survives the downsampling. + 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 envelope's time axis honest. + samples.addAll(List.filled(kEcgSampleRateHz, null)); + } else { + samples.addAll(p.samples); + } + } + final envelope = ecgEnvelope(samples, ecgEnvelopeBuckets); + final local = DateTime.fromMillisecondsSinceEpoch(reading.startTs * 1000); + return jsonEncode({ + '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_envelope': { + 'buckets': envelope.length, + 'bucket_ms': samples.isEmpty + ? null + : (samples.length * 1000 / kEcgSampleRateHz / envelope.length) + .round(), + 'points': envelope, + 'note': 'per-bucket [min, max] in filtered input-referred microvolts; ' + 'null where the accepted window has a missing segment', + }, + 'note': 'Band-reported. Not a diagnosis: no lead polarity is proven and ' + 'the phone classifies nothing from the waveform.', + }); + } + + /// Deterministic min/max envelope: [samples] split into at most [buckets] + /// contiguous ranges; each yields `[min, max]`, or null when the range + /// holds no real sample. + static List?> ecgEnvelope(List samples, int buckets) { + if (samples.isEmpty || buckets <= 0) return const []; + final n = samples.length; + final count = n < buckets ? n : buckets; + final out = ?>[]; + for (var b = 0; b < count; b++) { + final lo = (b * n / count).floor(); + final hi = ((b + 1) * n / count).floor().clamp(lo + 1, n); + int? mn; + int? mx; + for (var i = lo; i < hi; i++) { + final v = samples[i]; + if (v == null) continue; + mn = mn == null ? v : (v < mn ? v : mn); + mx = mx == null ? v : (v > mx ? v : mx); + } + out.add(mn == null ? null : [mn, mx!]); + } + return out; + } + // ── nutrition ────────────────────────────────────────────────────────────── /// One day of food: every entry, plus the totals the app itself computes. diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 10c794341..9fe99b5ab 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -721,6 +721,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 envelope; + // 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': @@ -899,7 +904,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.', @@ -940,6 +951,16 @@ 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 a bounded (~300-bucket) min/max waveform envelope ' + 'in microvolts. Never returns raw frames, a band serial or all ' + 'samples. The category is the band\'s HeartKey result — you must ' + 'not diagnose from the envelope.', + {'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..9e4281ffd 100644 --- a/lib/coach/coach_prompt.dart +++ b/lib/coach/coach_prompt.dart @@ -40,6 +40,18 @@ 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. You must NOT + independently diagnose AFib, sinus rhythm, ischemia, QT/ST changes or + anything else from the waveform envelope, and you must not say the + recording confirms or rules out a condition or confirms the band's + result. A concerning result 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 +87,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 envelope 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/ecg/ble_ecg_transport.dart b/lib/ecg/ble_ecg_transport.dart index 0ad3ecffa..c49857e1a 100644 --- a/lib/ecg/ble_ecg_transport.dart +++ b/lib/ecg/ble_ecg_transport.dart @@ -22,12 +22,15 @@ class BleEngineEcgTransport implements EcgTransport { /// Wire this to `BleEngine.onEcgEvent`. void onEngineEvent(EcgEngineEvent e) { _events.add(switch (e) { - EcgFrameEvent(:final r17, :final linkGeneration) => - EcgTransportFrame(r17, linkGeneration), + EcgFrameEvent(:final r17, :final linkGeneration) => EcgTransportFrame( + r17, + linkGeneration, + ), EcgMalformedR17Event(:final reason, :final linkGeneration) => EcgTransportMalformed(linkGeneration, reason), - EcgLinkDownEvent(:final linkGeneration) => - EcgTransportLinkDown(linkGeneration), + EcgLinkDownEvent(:final linkGeneration) => EcgTransportLinkDown( + linkGeneration, + ), }); } @@ -71,7 +74,10 @@ class BleEngineEcgTransport implements EcgTransport { ]); @override - Future prepare(EcgLeaseHandle lease, EcgWrist wrist) async => + Future prepare( + EcgLeaseHandle lease, + EcgWrist wrist, + ) async => _wrap(await engine.ecgPrepare(lease.token as EcgLease, wrist.selection)); @override diff --git a/lib/ecg/ecg_controller.dart b/lib/ecg/ecg_controller.dart index 9d25e43ca..5a554a214 100644 --- a/lib/ecg/ecg_controller.dart +++ b/lib/ecg/ecg_controller.dart @@ -91,41 +91,37 @@ class EcgCaptureState { 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, - ); + }) => 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, - }; + 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, -); +typedef EcgSave = + Future Function(EcgReading reading, List packets); class EcgController extends ChangeNotifier { final EcgTransport transport; @@ -153,8 +149,8 @@ class EcgController extends ChangeNotifier { void Function(String)? log, this.captureTimeout = const Duration(seconds: 120), int Function()? nowMs, - }) : log = log ?? ((_) {}), - nowMs = nowMs ?? (() => DateTime.now().millisecondsSinceEpoch); + }) : log = log ?? ((_) {}), + nowMs = nowMs ?? (() => DateTime.now().millisecondsSinceEpoch); EcgCaptureState _state = const EcgCaptureState(); EcgCaptureState get state => _state; @@ -208,20 +204,35 @@ class EcgController extends ChangeNotifier { } final busy = busyReason(); if (busy != null) { - _set(EcgCaptureState( - phase: EcgCapturePhase.busy, wrist: wrist, reason: busy)); + _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')); + _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')); + _set( + EcgCaptureState( + phase: EcgCapturePhase.busy, + wrist: wrist, + reason: 'transport', + ), + ); return; } _lease = lease; @@ -345,11 +356,13 @@ class EcgController extends ChangeNotifier { switch (e) { case EcgTransportLinkDown(): unawaited( - _finish(epoch, EcgCapturePhase.failed, reason: 'disconnected')); + _finish(epoch, EcgCapturePhase.failed, reason: 'disconnected'), + ); case EcgTransportMalformed(): if (_armed) { unawaited( - _finish(epoch, EcgCapturePhase.failed, reason: 'malformed')); + _finish(epoch, EcgCapturePhase.failed, reason: 'malformed'), + ); } case EcgTransportFrame(): _onFrame(epoch, e); @@ -426,8 +439,11 @@ class EcgController extends ChangeNotifier { _timer?.cancel(); switch (outcome.kind) { case EcgTerminalKind.unreadable: - await _finish(epoch, EcgCapturePhase.unreadable, - unreadableMask: outcome.unreadableMask); + await _finish( + epoch, + EcgCapturePhase.unreadable, + unreadableMask: outcome.unreadableMask, + ); case EcgTerminalKind.inconclusiveOfferRetry: await _finish(epoch, EcgCapturePhase.inconclusiveRetry); case EcgTerminalKind.completed: @@ -528,12 +544,14 @@ class EcgController extends ChangeNotifier { } transport.release(lease); if (identical(_lease, lease)) _lease = null; - _set(_state.copyWith( - phase: phase, - reason: reason, - readingId: readingId, - unreadableMask: unreadableMask, - cleanupIncomplete: incomplete, - )); + _set( + _state.copyWith( + phase: phase, + reason: reason, + readingId: readingId, + unreadableMask: unreadableMask, + cleanupIncomplete: incomplete, + ), + ); } } diff --git a/lib/ecg/ecg_models.dart b/lib/ecg/ecg_models.dart index 51e44f820..b31619d77 100644 --- a/lib/ecg/ecg_models.dart +++ b/lib/ecg/ecg_models.dart @@ -20,10 +20,10 @@ enum EcgWrist { this == EcgWrist.left ? WristSelection.left : WristSelection.right; static EcgWrist? parse(String? s) => switch (s) { - 'left' => EcgWrist.left, - 'right' => EcgWrist.right, - _ => null, - }; + 'left' => EcgWrist.left, + 'right' => EcgWrist.right, + _ => null, + }; } /// The official app's user-facing categories, from HeartKey result code plus @@ -95,21 +95,21 @@ class EcgAcceptedPacket { }); factory EcgAcceptedPacket.of(LabradorR17 r) => EcgAcceptedPacket( - sequence: r.sequence, - strapSeconds: r.strapSeconds, - strapSubsec: r.subseconds, - samples: r.samples, - inner: r.inner, - ); + 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, - ); + sequence: sequence, + strapSeconds: 0, + strapSubsec: 0, + samples: Int16List(0), + inner: Uint8List(0), + placeholder: true, + ); } /// Persisted status of a reading. Unreadable and first-attempt-inconclusive @@ -232,31 +232,31 @@ class EcgReading { 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, - }; + '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?); @@ -323,14 +323,14 @@ class EcgPacketCodec { } 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, - }; + '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; @@ -338,8 +338,8 @@ class EcgPacketCodec { final bytes = raw is Uint8List ? raw : raw is List - ? Uint8List.fromList(raw) - : Uint8List(0); + ? Uint8List.fromList(raw) + : Uint8List(0); return EcgAcceptedPacket( sequence: (r['sequence'] as num?)?.toInt() ?? 0, strapSeconds: (r['strap_seconds'] as num?)?.toInt() ?? 0, diff --git a/lib/ecg/ecg_policy.dart b/lib/ecg/ecg_policy.dart index 1ec947071..c953e0035 100644 --- a/lib/ecg/ecg_policy.dart +++ b/lib/ecg/ecg_policy.dart @@ -46,13 +46,13 @@ class EcgReducerState { /// 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, - ); + : this( + phase: EcgPhase.waiting, + accepted: const [], + previous: null, + interruptions: 0, + retriesUsed: retriesUsed, + ); EcgReducerState _with({ EcgPhase? phase, @@ -60,14 +60,13 @@ class EcgReducerState { 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, - ); + }) => 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. @@ -193,7 +192,8 @@ EcgReducerStep reduceEcg(EcgReducerState s, LabradorR17 f) { case EcgPhase.active: final prev = s.previous; - final lost = !f.presence || + final lost = + !f.presence || f.progress == 0 || (prev != null && f.progress < prev.progress); if (lost) { @@ -210,13 +210,20 @@ EcgReducerStep reduceEcg(EcgReducerState s, LabradorR17 f) { if (f.isTerminal) return _terminal(s, f); if (f.isInvalid) { return EcgReducerStep( - s._with(phase: EcgPhase.done, accepted: const [], clearPrevious: true), + 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); + 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 @@ -236,7 +243,11 @@ EcgReducerStep reduceEcg(EcgReducerState s, LabradorR17 f) { } if (f.isInvalid || s.interruptions >= 3) { return EcgReducerStep( - s._with(phase: EcgPhase.done, accepted: const [], clearPrevious: true), + s._with( + phase: EcgPhase.done, + accepted: const [], + clearPrevious: true, + ), [ const EcgClear(), EcgFail(f.isInvalid ? 'progress_255' : 'interruptions'), @@ -250,18 +261,23 @@ EcgReducerStep reduceEcg(EcgReducerState s, LabradorR17 f) { 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) { + 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, - )), + EcgTerminal( + EcgTerminalOutcome( + kind: kind, + liveCategory: live, + persistedCategory: persisted, + terminal: f, + ), + ), ], ); } @@ -270,8 +286,9 @@ EcgReducerStep _terminal(EcgReducerState s, LabradorR17 f) { return finish(EcgTerminalKind.unreadable, const [], const [EcgClear()]); } if (live == EcgCategory.inconclusive && s.retriesUsed == 0) { - return finish( - EcgTerminalKind.inconclusiveOfferRetry, const [], const [EcgClear()]); + return finish(EcgTerminalKind.inconclusiveOfferRetry, const [], const [ + EcgClear(), + ]); } final (accepted, effects) = _append(s, f); return finish( diff --git a/lib/ecg/ecg_recovery.dart b/lib/ecg/ecg_recovery.dart index 3cfcc279b..39384b270 100644 --- a/lib/ecg/ecg_recovery.dart +++ b/lib/ecg/ecg_recovery.dart @@ -30,14 +30,18 @@ Future ecgRecoverRetainedGuard({ }) 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.'); + 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.'); + 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 index 7a5733752..3ce035f9a 100644 --- a/lib/ecg/ecg_transport.dart +++ b/lib/ecg/ecg_transport.dart @@ -40,8 +40,11 @@ class EcgMemberOutcome { final String label; final bool written; final bool succeeded; - const EcgMemberOutcome(this.label, - {required this.written, required this.succeeded}); + const EcgMemberOutcome( + this.label, { + required this.written, + required this.succeeded, + }); @override String toString() => '$label(written=$written ok=$succeeded)'; @@ -53,8 +56,10 @@ class EcgCommandListResult { bool get allSucceeded => outcomes.isNotEmpty && outcomes.every((o) => o.succeeded); - List get failed => - [for (final o in outcomes) if (!o.succeeded) o]; + List get failed => [ + for (final o in outcomes) + if (!o.succeeded) o, + ]; @override String toString() => outcomes.join(', '); diff --git a/lib/ecg/ecg_waveform_buffer.dart b/lib/ecg/ecg_waveform_buffer.dart index 89b0190ca..47d0441ec 100644 --- a/lib/ecg/ecg_waveform_buffer.dart +++ b/lib/ecg/ecg_waveform_buffer.dart @@ -19,7 +19,7 @@ class EcgWaveformBuffer { int _version = 0; EcgWaveformBuffer({int capacity = defaultCapacity}) - : _ring = Int16List(capacity); + : _ring = Int16List(capacity); int get capacity => _ring.length; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9370b33b0..03f08dcb2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2305,5 +2305,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 eine begrenzte Kurvenhülle 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 6f2c546c5..782f67dae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11732,5 +11732,319 @@ "nudgeDontShowAgain": "Don't show this again", "@nudgeDontShowAgain": { "description": "Label and semantic label for the button that permanently dismisses a Home community nudge." + }, + "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 a bounded waveform envelope 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 7c06b2eb3..8be1decfb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2334,5 +2334,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 una envolvente acotada de la onda 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 c50b688f3..9bdc92a63 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2305,5 +2305,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 une enveloppe bornée du tracé 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 28a18ab4c..e1a518878 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2305,5 +2305,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": "सारांश और सीमित तरंग आवरण {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 6a256c8fd..7085168cf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2305,5 +2305,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": "读数摘要和有限的波形包络将作为 {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/ui2/ecg_widgets.dart b/lib/ui2/ecg_widgets.dart new file mode 100644 index 000000000..66c4c4cf5 --- /dev/null +++ b/lib/ui2/ecg_widgets.dart @@ -0,0 +1,409 @@ +// 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. [contact] draws the +/// fingers on the electrodes; otherwise they approach. +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) { + final w = s.width, h = s.height; + // Mirror for the left wrist: the forearm enters from the other side. + final mirror = wrist == EcgWrist.left; + cv.save(); + if (mirror) { + cv.translate(w, 0); + cv.scale(-1, 1); + } + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = ink2; + final fill = Paint()..color = skin; + + // Resting forearm: a rounded slab from the left edge to the wrist. + final armTop = h * .42, armBot = h * .70; + final armEnd = w * .58; + final arm = RRect.fromLTRBR( + -20, + armTop, + armEnd, + armBot, + Radius.circular((armBot - armTop) / 2), + ); + cv.drawRRect(arm, fill); + cv.drawRRect(arm, stroke); + + // The band around the wrist, with its two electrode indents on the + // outer face (top and bottom edges of the strap). + final bx = w * .46, bw = w * .07; + final bandRect = RRect.fromLTRBR( + bx, + armTop - 6, + bx + bw, + armBot + 6, + const Radius.circular(6), + ); + cv.drawRRect(bandRect, Paint()..color = band); + final electrodePaint = Paint()..color = accent; + final e1 = Offset(bx + bw / 2, armTop - 6); + final e2 = Offset(bx + bw / 2, armBot + 6); + cv.drawCircle(e1, 4, electrodePaint); + cv.drawCircle(e2, 4, electrodePaint); + + // Opposite hand: thumb from above, index finger from below, meeting the + // two indents. Before contact they hover at a small gap that eases in + // with the phase. + final gap = contact ? 0.0 : 10.0 + 6.0 * math.sin(t * 2 * math.pi); + final finger = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 14 + ..strokeCap = StrokeCap.round + ..color = skin; + final fingerLine = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 14 + ..strokeCap = StrokeCap.round + ..color = ink2 + ..strokeWidth = 16; + // Thumb. + final thumbTip = Offset(e1.dx, e1.dy - 8 - gap); + final thumbBase = Offset(w * .82, h * .08); + cv.drawLine(thumbBase, thumbTip, fingerLine); + cv.drawLine(thumbBase, thumbTip, finger); + // Index finger. + final indexTip = Offset(e2.dx, e2.dy + 8 + gap); + final indexBase = Offset(w * .86, h * .95); + cv.drawLine(indexBase, indexTip, fingerLine); + cv.drawLine(indexBase, indexTip, finger); + + // Contact rings: gentle pulses out of each electrode. + final ringPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = accent.withValues(alpha: (1 - t) * .8); + final r = 6 + 16 * t; + cv.drawCircle(e1, r, ringPaint); + cv.drawCircle(e2, r, ringPaint); + cv.restore(); + } + + @override + bool shouldRepaint(_TouchPainter o) => + o.t != t || o.contact != contact || o.wrist != wrist || o.ink != ink; +} + +/// 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 355cb8788..1145300db 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'), @@ -2391,3 +2428,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 d9d24cc03..0e8bc803e 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -75,7 +75,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 +134,7 @@ class _CoachScreenState extends State { engine.dispose(); return; } + if (widget.startNewSession) engine.newSession(); setState(() { _engine = engine; _items @@ -130,8 +142,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..d1e977c07 --- /dev/null +++ b/lib/ui2/screens/ecg.dart @@ -0,0 +1,1025 @@ +// 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) => + 'Explain my ECG reading $id. Use get_ecg_reading. Start with signal ' + 'quality and the band-reported result, then explain what it can and ' + 'cannot mean. Do not diagnose from the waveform.'; + +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 (!coachReady(c)) { + await Navigator.of( + c, + ).push(themedRoute((_) => const CoachSetup(), name: 'CoachSetup')); + if (!c.mounted || !coachReady(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 a bounded waveform envelope 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 c5cfad029..38868b371 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/test/coach_ecg_security_test.dart b/test/coach_ecg_security_test.dart index 4c013fe16..51e024d4b 100644 --- a/test/coach_ecg_security_test.dart +++ b/test/coach_ecg_security_test.dart @@ -18,32 +18,35 @@ void main() { 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, - }, [ + await LocalDb.insertEcgReading( { - 'sequence': 1, + '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, - 'samples': [1, 0, 255, 255], - 'inner_hex': '2b11deadbeef', - 'is_placeholder': 0, + '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 { @@ -55,7 +58,8 @@ void main() { 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'); + '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')); @@ -74,14 +78,20 @@ void main() { 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())); + () => 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()), + ); }); } @@ -94,10 +104,14 @@ void main() { }); } - 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'))); - }); + 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..87a0376aa --- /dev/null +++ b/test/coach_ecg_tool_test.dart @@ -0,0 +1,164 @@ +// 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), + ]); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test( + 'returns the band summary and a bounded envelope; 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 env = j['waveform_envelope'] as Map; + final pts = env['points'] as List; + expect(pts.length, lessThanOrEqualTo(CoachActions.ecgEnvelopeBuckets)); + expect(env['buckets'], pts.length); + expect( + pts.where((e) => e == null), + isNotEmpty, + reason: 'the placeholder second', + ); + final maxes = pts.whereType().map((e) => e[1] as int); + final mins = pts.whereType().map((e) => e[0] as int); + expect(maxes, contains(731), reason: 'peaks survive the downsampling'); + expect(mins, 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.kMaxToolResultChars), + 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('the envelope is deterministic and never averages a bucket', () { + // 8 samples into 3 buckets: [0,2) [2,5) [5,8) — floors, deterministic. + final env = CoachActions.ecgEnvelope([1, 9, -4, null, null, 2, 2, 2], 3); + expect(env, [ + [1, 9], + [-4, -4], + [2, 2], + ]); + // A bucket that is entirely missing is null, not zero. + expect(CoachActions.ecgEnvelope([5, null, null, 7], 2), [ + [5, 5], + [7, 7], + ]); + expect(CoachActions.ecgEnvelope([null, null, 3, 4], 2), [ + null, + [3, 4], + ]); + expect(CoachActions.ecgEnvelope(const [], 300), isEmpty); + expect(CoachActions.ecgEnvelope([5], 300), [ + [5, 5], + ]); + }); + + 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('must NOT')); + expect(kCoachSystemPrompt, contains('Not medical advice')); + expect(kCoachSystemPrompt.toLowerCase(), contains('polarity')); + expect(kCoachSystemPrompt.toLowerCase(), contains('emergency')); + }); +} diff --git a/test/ecg_ble_engine_test.dart b/test/ecg_ble_engine_test.dart index 524e00b12..07e729f8c 100644 --- a/test/ecg_ble_engine_test.dart +++ b/test/ecg_ble_engine_test.dart @@ -39,15 +39,20 @@ Decoded _helloReply(int seq, {int revision = 1, int optical = 0}) => 'opcode': Cmd.getHello, 'req_seq': seq, 'cmd_status': CommandAwaiter.statusSuccess, - 'gen5_hello': Gen5HelloInfo.parse(_helloBody(revision: revision, optical: optical))!, + '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, - }); +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({ @@ -125,9 +130,17 @@ class _Link { if (reply != null) engine.debugAbsorbDecoded(reply); return true; }, - onCommit: (raws, samples, token, {archives, ecgRawPackets, deviceFamily}) async { - committedEcgRaw = ecgRawPackets; - }, + onCommit: + ( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async { + committedEcgRaw = ecgRawPackets; + }, ); } @@ -135,7 +148,9 @@ class _Link { replyTo = (seq, op) => _ack(seq, op, status: status); void helloMg({int optical = 0, int revision = 1}) => - engine.debugAbsorbDecoded(_helloReply(99, optical: optical, revision: revision)); + engine.debugAbsorbDecoded( + _helloReply(99, optical: optical, revision: revision), + ); List get opcodes => commands.map((c) => c.opcode).toList(); } @@ -153,13 +168,15 @@ void main() { 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); - }); + 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', () { @@ -174,26 +191,36 @@ void main() { 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); + 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 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(); @@ -201,18 +228,23 @@ void main() { 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( + '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(); @@ -221,42 +253,63 @@ void main() { 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']); + 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) { + test( + 'a FAILURE reply marks the member failed and the rest are still sent', + () 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]); - }); - }); + 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 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 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(); @@ -270,23 +323,29 @@ void main() { }); 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 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(); @@ -295,7 +354,10 @@ void main() { }); test('maintenance traffic pauses under a lease', () { - expect(shouldPauseMaintenanceTraffic(offloadActive: false, ecgLeased: true), isTrue); + expect( + shouldPauseMaintenanceTraffic(offloadActive: false, ecgLeased: true), + isTrue, + ); expect(shouldPauseMaintenanceTraffic(offloadActive: false), isFalse); }); }); @@ -303,7 +365,9 @@ void main() { 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)); + 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); @@ -314,91 +378,133 @@ void main() { test('a revision-17 frame that does not parse is reported malformed', () { final l = _Link(); l.engine.debugProcessImmediateFrame( - Frame(_r17Inner(declaredCount: 101), true, true)); + 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); - }); + 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( + '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); - }); + 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: (_) {}, - ); + 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, - ); + 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 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( + '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 {}); + final d = drain( + ( + raws, + samples, + token, { + archives, + ecgRawPackets, + deviceFamily, + }) async {}, + ); d.onEcgRawPacket(pkt(1), counter: 1); d.discardOpenChunk(); expect(d.bufferedEcgRaw, 0); @@ -406,73 +512,112 @@ void main() { test('the unbuffered (no onCommit) controller refuses raw ECG', () { final d = DrainController( - onRecord: (_, _) async {}, onRecordsBatch: null, onCommit: null, onArchive: null, log: (_) {}); + 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); - }); + 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); - }); + 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'); + 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); }); - // 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(); diff --git a/test/ecg_controller_test.dart b/test/ecg_controller_test.dart index d62bb402c..0b8bacd86 100644 --- a/test/ecg_controller_test.dart +++ b/test/ecg_controller_test.dart @@ -46,11 +46,26 @@ LabradorR17 frame({ 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)]); +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 = []; @@ -115,10 +130,14 @@ class FakeTransport implements EcgTransport { } @override - Future cancelHistory(EcgLeaseHandle lease) async => calls.add('cancelHistory'); + Future cancelHistory(EcgLeaseHandle lease) async => + calls.add('cancelHistory'); @override - Future prepare(EcgLeaseHandle lease, EcgWrist wrist) async { + Future prepare( + EcgLeaseHandle lease, + EcgWrist wrist, + ) async { calls.add('prepare:${wrist.name}'); return prepareResult ?? _ok(['selectWrist', 'filteredOn', 'rawSaveOn']); } @@ -147,7 +166,8 @@ class FakeTransport implements EcgTransport { EcgMemberOutcome(l, written: false, succeeded: false), ]); } - return cleanupResult ?? _ok(['generationStop', 'filteredOff', 'rawSaveOff']); + return cleanupResult ?? + _ok(['generationStop', 'filteredOff', 'rawSaveOff']); } @override @@ -192,23 +212,26 @@ 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( + '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(); @@ -234,7 +257,11 @@ void main() { 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'); + 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(); @@ -257,14 +284,24 @@ void main() { 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.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)); + expect( + r.phases.indexOf(EcgCapturePhase.cleaningUp), + lessThan(completedAt), + ); }); test('a contact frame delivered during the START write is the first ' @@ -277,20 +314,23 @@ void main() { 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); - }); + 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', () { @@ -304,7 +344,12 @@ void main() { 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.t.calls, [ + 'cancelHistory', + 'prepare:right', + 'cleanup', + 'release', + ]); expect(r.screen, isEmpty, reason: 'the screen hold comes after PREPARE'); }); @@ -390,31 +435,39 @@ void main() { 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( + '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( + '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(); @@ -444,7 +497,9 @@ void main() { 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 + 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)); @@ -507,30 +562,32 @@ void main() { 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( + '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; diff --git a/test/ecg_models_test.dart b/test/ecg_models_test.dart index a80af548a..b4f58fd19 100644 --- a/test/ecg_models_test.dart +++ b/test/ecg_models_test.dart @@ -28,17 +28,19 @@ void main() { 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 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); @@ -81,7 +83,10 @@ void main() { test('reading rows round-trip', () { final r = EcgReading( - id: ecgReadingId(startEpochMs: 1787823754000, terminalStrapS: 1787823784), + id: ecgReadingId( + startEpochMs: 1787823754000, + terminalStrapS: 1787823784, + ), deviceId: '', wrist: EcgWrist.left, startTs: 1787823754, @@ -121,18 +126,20 @@ void main() { 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)), + 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)), + sequence: 3, + strapSeconds: 3, + strapSubsec: 0, + samples: Int16List.fromList([0, 12]), + inner: Uint8List(0), + ), ]); expect(stats.sampleCount, 4); expect(stats.minUv, -4); diff --git a/test/ecg_policy_test.dart b/test/ecg_policy_test.dart index 3cb0277e1..4e3f86d50 100644 --- a/test/ecg_policy_test.dart +++ b/test/ecg_policy_test.dart @@ -30,7 +30,8 @@ LabradorR17 pkt({ 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[14] = + (presence ? 0x08 : 0) | (s2One ? 0x02 : 0) | (transition ? 0x04 : 0); inner[15] = result; inner[16] = s2State; inner[17] = progress; @@ -51,22 +52,23 @@ LabradorR17 terminal({ 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, - ); +}) => 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}) { +(EcgReducerState, List) run( + List frames, { + int retriesUsed = 0, +}) { var s = EcgReducerState.initial(retriesUsed: retriesUsed); final effects = []; for (final f in frames) { @@ -77,8 +79,7 @@ LabradorR17 terminal({ return (s, effects); } -List seqs(EcgReducerState s) => - s.accepted.map((p) => p.sequence).toList(); +List seqs(EcgReducerState s) => s.accepted.map((p) => p.sequence).toList(); void main() { group('WAITING', () { @@ -106,40 +107,71 @@ void main() { 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)]); + 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( + '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 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), + 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; @@ -155,34 +187,48 @@ void main() { 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( + '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( + '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( + '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)]); @@ -195,7 +241,11 @@ void main() { 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)]); + 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); @@ -203,12 +253,19 @@ void main() { }); 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'); + 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 ' @@ -223,7 +280,10 @@ void main() { ]); 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(seqs(s), [ + 5, + 6, + ], reason: 'a fresh window, no placeholder from before the loss'); expect(e.whereType(), isEmpty); }); @@ -240,9 +300,17 @@ void main() { 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'); + 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)]); + 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); @@ -251,36 +319,51 @@ void main() { 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)]); + 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), + pkt(seq: 3, progress: 255), ]); - expect(s.interruptions, 1); - expect(seqs(s), [3, 4]); - expect(e.whereType().single.outcome.kind, EcgTerminalKind.completed); + 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)]); + 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'); @@ -288,7 +371,12 @@ void main() { }); 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)]); + 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); }); @@ -320,8 +408,10 @@ void main() { // 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 = 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), diff --git a/test/ecg_support_test.dart b/test/ecg_support_test.dart index 58af00208..f774e3724 100644 --- a/test/ecg_support_test.dart +++ b/test/ecg_support_test.dart @@ -58,44 +58,54 @@ void main() { }); 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'); - }); + 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), - ]); + 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), - ]); + 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: (_) {}); + guard: g, + serial: 'S', + cleanup: () async { + sent++; + return ok(); + }, + log: (_) {}, + ); expect(r, EcgRecoveryOutcome.noGuard); expect(sent, 0); }); @@ -103,7 +113,11 @@ void main() { 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: (_) {}); + guard: g, + serial: 'S', + cleanup: () async => ok(), + log: (_) {}, + ); expect(r, EcgRecoveryOutcome.cleared); expect(g.active, isEmpty); }); @@ -111,7 +125,11 @@ void main() { 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: (_) {}); + guard: g, + serial: 'S', + cleanup: () async => partial(), + log: (_) {}, + ); expect(r, EcgRecoveryOutcome.retained); expect(g.active, contains('S')); }); @@ -119,7 +137,11 @@ void main() { test('no serial → nothing happens', () async { final g = MemoryEcgGuardStore()..active.add('S'); final r = await ecgRecoverRetainedGuard( - guard: g, serial: null, cleanup: () async => ok(), log: (_) {}); + 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..cad329754 --- /dev/null +++ b/test/ecg_ui_test.dart @@ -0,0 +1,476 @@ +// 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/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'; + +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 prompt', () { + test('names the tool and forbids diagnosing from the waveform', () { + final p = ecgAnalyzePrompt('ecg_1'); + expect(p, contains('Explain my ECG reading ecg_1')); + expect(p, contains('Use get_ecg_reading')); + expect(p, contains('Do not diagnose from the waveform')); + }); + }); + + 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); + }); + }); +} diff --git a/test/screen_wake_owners_test.dart b/test/screen_wake_owners_test.dart index 5e62a28a7..6685ce613 100644 --- a/test/screen_wake_owners_test.dart +++ b/test/screen_wake_owners_test.dart @@ -19,9 +19,9 @@ void main() { answer = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { - calls.add(call.arguments['on'] as bool); - return answer; - }); + calls.add(call.arguments['on'] as bool); + return answer; + }); }); tearDown(() { @@ -30,30 +30,36 @@ void main() { 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( + '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( + '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'); diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index e280b39ed..4c71169df 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', From a71d0e3688d165fb14242ba40e34b63a9ee7aa9d Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 13:17:23 +0200 Subject: [PATCH 05/10] test: alarm ladder test pins the schema version as a floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v49→v50 migration test asserted schemaVersion == 50 literally, so every later additive rung (v51 is the WHOOP MG ECG store) would fail it for no reason of its own. The v50 rung is still isolated; the ladder's top is a floor here. --- test/db_alarm_schedule_migration_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/db_alarm_schedule_migration_test.dart b/test/db_alarm_schedule_migration_test.dart index 3d92cbed3..13d889cc7 100644 --- a/test/db_alarm_schedule_migration_test.dart +++ b/test/db_alarm_schedule_migration_test.dart @@ -65,7 +65,10 @@ void main() { created.add(name); await _seedEmptyV49Db(name); final version = await _openThroughLocalDb(name); - expect(LocalDb.schemaVersion, 50); + // The v50 rung is what this file isolates; later rungs (v51 added the + // WHOOP MG ECG store) ride the same open, so the ladder's top is a floor + // here, not a literal. + expect(LocalDb.schemaVersion, greaterThanOrEqualTo(50)); expect(version, LocalDb.schemaVersion); final db = await LocalDb.instance; From fd11a91c8edcada67e6531101f683b6fc399c88c Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 8 Sep 2026 14:43:58 +0200 Subject: [PATCH 06/10] fix(coach): reach the coach from Analyze now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_analyze` gated on `coachReady`, which reads the config with `watch`. `watch` is only legal during build; from a tap handler provider asserts, and coachReady's own catch turned that assert into a plain "not configured". A fully configured coach therefore sent the user to the setup form every time, and the check after the form returned false again and bailed out — the reading was never analysed. It is an assert, so it bit debug builds only, which is why it survived to hardware. Add `coachReadyNow`, the same predicate over `read`, and use it in the handler. The other watch-based helpers are all called from build. The regression test drives the tap through a NavigatorObserver so it asserts on the pushed route without building the coach screen itself. --- lib/ui2/screens/coach.dart | 14 +++++++++ lib/ui2/screens/ecg.dart | 4 +-- test/ecg_ui_test.dart | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 0e8bc803e..eebc41a3a 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. /// diff --git a/lib/ui2/screens/ecg.dart b/lib/ui2/screens/ecg.dart index d1e977c07..d5ee2f25e 100644 --- a/lib/ui2/screens/ecg.dart +++ b/lib/ui2/screens/ecg.dart @@ -786,11 +786,11 @@ class _EcgDetailScreenState extends State { Future _analyze(BuildContext c) async { final l = AppLocalizations.of(c); final id = widget.data.reading.id; - if (!coachReady(c)) { + if (!coachReadyNow(c)) { await Navigator.of( c, ).push(themedRoute((_) => const CoachSetup(), name: 'CoachSetup')); - if (!c.mounted || !coachReady(c)) return; + if (!c.mounted || !coachReadyNow(c)) return; } final cfg = c.read(); if (!cfg.isLocalEndpoint) { diff --git a/test/ecg_ui_test.dart b/test/ecg_ui_test.dart index cad329754..d3e190f2a 100644 --- a/test/ecg_ui_test.dart +++ b/test/ecg_ui_test.dart @@ -8,12 +8,15 @@ 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, @@ -432,6 +435,55 @@ void main() { }); }); + 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 forbids diagnosing from the waveform', () { final p = ecgAnalyzePrompt('ecg_1'); @@ -474,3 +526,14 @@ void main() { }); }); } + +/// 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); + } +} From 663ada92e6b7421b1d640afd648efcbef65e90bd Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 8 Sep 2026 14:45:36 +0200 Subject: [PATCH 07/10] coach: send the accepted ECG waveform, not an envelope get_ecg_reading sent a 300-bucket min/max envelope. At 100 Hz that is 100 ms per bucket and a QRS complex is 80-100 ms wide, so every beat collapsed into one amplitude bar: the model could describe the envelope but never the trace. Send the accepted window itself instead, at the band's own rate. A completed reading is 30 s = 3,000 samples and goes whole; nulls still hold a missing segment's second open. Only a longer window is decimated, by a whole-number stride reported alongside the effective rate, so the result is never clipped mid-number into JSON the model cannot parse. Add `how_to_read` beside it. Without the provenance the model reads the data wrongly in specific ways: it inverts the band's avg_hr into an "RR interval" and presents it as measured, and it judges QRS width as if the trace were a 500 Hz diagnostic ECG. The block states the 500 Hz raw to 100 Hz filtered path, the 10 ms sample resolution and what it does and does not support, that avg_hr is the band's and not measured here, and what quality, interruptions and missing_segments mean. get_ecg_reading also gets its own result ceiling. The shared bound exists because the model widens its own run_sql queries until one result dominates the window; a bound lookup of one reading by id is not that shape, and clipping it would only decimate a waveform to make room for the prose describing it. --- lib/coach/coach_actions.dart | 152 ++++++++++++++++++++-------------- lib/coach/coach_engine.dart | 36 ++++++-- lib/coach/coach_prompt.dart | 4 +- test/coach_ecg_tool_test.dart | 147 +++++++++++++++++++++++++------- 4 files changed, 235 insertions(+), 104 deletions(-) diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart index ca0a9c84f..688c1916b 100644 --- a/lib/coach/coach_actions.dart +++ b/lib/coach/coach_actions.dart @@ -102,13 +102,23 @@ class CoachActions { // ── WHOOP MG ECG ─────────────────────────────────────────────────────────── /// At most this many waveform buckets leave the device. - static const int ecgEnvelopeBuckets = 300; + /// 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; - /// One saved ECG reading for the coach: the band-reported summary plus a - /// bounded min/max envelope of the accepted waveform. A BOUND query on the + /// 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, the notes or all 3,000 samples. Min/max per - /// bucket, not an average, so a peak survives the downsampling. + /// 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) { @@ -125,71 +135,89 @@ class CoachActions { final samples = []; for (final p in packets) { if (p.placeholder) { - // One second of "no data" keeps the envelope's time axis honest. + // One second of "no data" keeps the waveform's time axis honest. samples.addAll(List.filled(kEcgSampleRateHz, null)); } else { samples.addAll(p.samples); } } - final envelope = ecgEnvelope(samples, ecgEnvelopeBuckets); final local = DateTime.fromMillisecondsSinceEpoch(reading.startTs * 1000); - return jsonEncode({ - '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_envelope': { - 'buckets': envelope.length, - 'bucket_ms': samples.isEmpty - ? null - : (samples.length * 1000 / kEcgSampleRateHz / envelope.length) - .round(), - 'points': envelope, - 'note': 'per-bucket [min, max] in filtered input-referred microvolts; ' - 'null where the accepted window has a missing segment', - }, - 'note': 'Band-reported. Not a diagnosis: no lead polarity is proven and ' - 'the phone classifies nothing from the waveform.', - }); - } - /// Deterministic min/max envelope: [samples] split into at most [buckets] - /// contiguous ranges; each yields `[min, max]`, or null when the range - /// holds no real sample. - static List?> ecgEnvelope(List samples, int buckets) { - if (samples.isEmpty || buckets <= 0) return const []; - final n = samples.length; - final count = n < buckets ? n : buckets; - final out = ?>[]; - for (var b = 0; b < count; b++) { - final lo = (b * n / count).floor(); - final hi = ((b + 1) * n / count).floor().clamp(lo + 1, n); - int? mn; - int? mx; - for (var i = lo; i < hi; i++) { - final v = samples[i]; - if (v == null) continue; - mn = mn == null ? v : (v < mn ? v : mn); - mx = mx == null ? v : (v > mx ? v : mx); - } - out.add(mn == null ? null : [mn, mx!]); + /// 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 out; + return encoded; } // ── nutrition ────────────────────────────────────────────────────────────── diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 9fe99b5ab..e234ad9e2 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -218,15 +218,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; @@ -500,7 +516,7 @@ class CoachEngine { 'role': 'tool', 'tool_call_id': id, 'name': name, - 'content': _clipToolResult(result), + 'content': _clipToolResult(result, name), }); } } @@ -721,7 +737,7 @@ 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 envelope; + // 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( @@ -955,10 +971,12 @@ class CoachEngine { '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 a bounded (~300-bucket) min/max waveform envelope ' - 'in microvolts. Never returns raw frames, a band serial or all ' - 'samples. The category is the band\'s HeartKey result — you must ' - 'not diagnose from the envelope.', + '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 — you must not diagnose from ' + 'the waveform.', {'reading_id': {'type': 'string', 'description': 'the reading id from v_ecg_readings'}}, ['reading_id']), _fn('log_food', diff --git a/lib/coach/coach_prompt.dart b/lib/coach/coach_prompt.dart index 9e4281ffd..aed6253b4 100644 --- a/lib/coach/coach_prompt.dart +++ b/lib/coach/coach_prompt.dart @@ -47,7 +47,7 @@ one friendly sentence declining and steering back. Never write code. You may explain what the band-reported category means, the signal quality, the heart rate and the unreadable reasons. You must NOT independently diagnose AFib, sinus rhythm, ischemia, QT/ST changes or - anything else from the waveform envelope, and you must not say the + anything else from the waveform, and you must not say the recording confirms or rules out a condition or confirms the band's result. A concerning result or symptoms → appropriate clinical evaluation. Chest pain, severe shortness of breath, fainting or other emergency @@ -91,7 +91,7 @@ results cap at 200. If a query is rejected, read the reason and fix it. 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 envelope is in `get_ecg_reading(reading_id)`. + 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/test/coach_ecg_tool_test.dart b/test/coach_ecg_tool_test.dart index 87a0376aa..88d5b0672 100644 --- a/test/coach_ecg_tool_test.dart +++ b/test/coach_ecg_tool_test.dart @@ -70,6 +70,49 @@ void main() { 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 { @@ -79,7 +122,7 @@ void main() { }); test( - 'returns the band summary and a bounded envelope; no identity, no bytes', + 'returns the band summary and the full waveform; no identity, no bytes', () async { final out = await CoachActions.ecgReading( await LocalDb.instance, @@ -94,26 +137,37 @@ void main() { expect(j['sample_count'], 3000); expect(j['missing_segments'], 1); expect(j['unit'], kEcgSampleUnit); - final env = j['waveform_envelope'] as Map; - final pts = env['points'] as List; - expect(pts.length, lessThanOrEqualTo(CoachActions.ecgEnvelopeBuckets)); - expect(env['buckets'], pts.length); + 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 maxes = pts.whereType().map((e) => e[1] as int); - final mins = pts.whereType().map((e) => e[0] as int); - expect(maxes, contains(731), reason: 'peaks survive the downsampling'); - expect(mins, contains(-531)); + 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.kMaxToolResultChars), + lessThan(CoachEngine.kMaxEcgToolResultChars), reason: 'fits one tool result without truncation', ); }, @@ -129,27 +183,58 @@ void main() { ); }); - test('the envelope is deterministic and never averages a bucket', () { - // 8 samples into 3 buckets: [0,2) [2,5) [5,8) — floors, deterministic. - final env = CoachActions.ecgEnvelope([1, 9, -4, null, null, 2, 2, 2], 3); - expect(env, [ - [1, 9], - [-4, -4], - [2, 2], - ]); - // A bucket that is entirely missing is null, not zero. - expect(CoachActions.ecgEnvelope([5, null, null, 7], 2), [ - [5, 5], - [7, 7], - ]); - expect(CoachActions.ecgEnvelope([null, null, 3, 4], 2), [ - null, - [3, 4], - ]); - expect(CoachActions.ecgEnvelope(const [], 300), isEmpty); - expect(CoachActions.ecgEnvelope([5], 300), [ - [5, 5], - ]); + 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', () { From 3a7d99c0893442533ac4a8d3637fd89636ebff99 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 8 Sep 2026 14:46:33 +0200 Subject: [PATCH 08/10] coach: let the model read the ECG waveform Law 7 forbade the coach interpreting the waveform at all: it could explain the band's category, the signal quality, the heart rate and the unreadable reasons, and nothing else. With the trace now in the tool result that left the model holding 3,000 samples it was instructed to say nothing about, and the analysis read as generic no matter how good the data was. Owner's decision, made explicitly: allow the interpretation. The model may now read rate, rhythm and its regularity, beat-to-beat variation, intervals and morphology, give its own impression, and say plainly when its reading and the band's category disagree instead of deferring. What stays is everything that is a fact about the data rather than a restriction on reading it: the anatomical lead polarity is still not proven, so axis and R/S direction remain off limits; the category is still the band's own HeartKey result; the emergency-symptom guidance is untouched; and the standing caveat is now explicit that this is not a cleared diagnostic device and no substitute for a clinician. The Analyze-now message and the tool description asked for the old behaviour in their own words and would otherwise have contradicted the law they sit under. --- lib/coach/coach_engine.dart | 4 ++-- lib/coach/coach_prompt.dart | 13 ++++++++----- lib/ui2/screens/ecg.dart | 8 +++++--- test/coach_ecg_tool_test.dart | 6 +++++- test/ecg_ui_test.dart | 11 ++++++++--- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index e234ad9e2..3be3bc5d4 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -975,8 +975,8 @@ class CoachEngine { '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 — you must not diagnose from ' - 'the waveform.', + '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', diff --git a/lib/coach/coach_prompt.dart b/lib/coach/coach_prompt.dart index aed6253b4..980249570 100644 --- a/lib/coach/coach_prompt.dart +++ b/lib/coach/coach_prompt.dart @@ -45,11 +45,14 @@ one friendly sentence declining and steering back. Never write code. (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. You must NOT - independently diagnose AFib, sinus rhythm, ischemia, QT/ST changes or - anything else from the waveform, and you must not say the - recording confirms or rules out a condition or confirms the band's - result. A concerning result or symptoms → appropriate clinical evaluation. + 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. diff --git a/lib/ui2/screens/ecg.dart b/lib/ui2/screens/ecg.dart index d5ee2f25e..b183516d2 100644 --- a/lib/ui2/screens/ecg.dart +++ b/lib/ui2/screens/ecg.dart @@ -775,9 +775,11 @@ class EcgDetailScreen extends StatefulWidget { /// 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) => - 'Explain my ECG reading $id. Use get_ecg_reading. Start with signal ' - 'quality and the band-reported result, then explain what it can and ' - 'cannot mean. Do not diagnose from the waveform.'; + '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]; diff --git a/test/coach_ecg_tool_test.dart b/test/coach_ecg_tool_test.dart index 88d5b0672..99af927dd 100644 --- a/test/coach_ecg_tool_test.dart +++ b/test/coach_ecg_tool_test.dart @@ -241,7 +241,11 @@ void main() { expect(kCoachSystemPrompt, contains('get_ecg_reading')); expect(kCoachSystemPrompt, contains('v_ecg_readings')); expect(kCoachSystemPrompt, contains('HeartKey')); - expect(kCoachSystemPrompt, contains('must NOT')); + 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/ecg_ui_test.dart b/test/ecg_ui_test.dart index d3e190f2a..ee09d8452 100644 --- a/test/ecg_ui_test.dart +++ b/test/ecg_ui_test.dart @@ -485,11 +485,16 @@ void main() { }); group('analyze-now prompt', () { - test('names the tool and forbids diagnosing from the waveform', () { + test('names the tool and asks for a reading of the waveform', () { final p = ecgAnalyzePrompt('ecg_1'); - expect(p, contains('Explain my ECG reading ecg_1')); + expect(p, contains('Analyse my ECG reading ecg_1')); expect(p, contains('Use get_ecg_reading')); - expect(p, contains('Do not diagnose from the waveform')); + expect(p, contains('rate, rhythm')); + expect( + p, + contains('polarity'), + reason: 'the unproven polarity is still stated', + ); }); }); From 34c43cee01bd5ebb555daf6d0655d565a81b4970 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 8 Sep 2026 18:31:13 +0200 Subject: [PATCH 09/10] coach: say what actually leaves the device before an ECG upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-endpoint dialog promised "a bounded waveform envelope". Since the tool result became the accepted window itself that sentence is wrong in the direction that matters: the user was consenting to a summary and sending a complete 30-second recording. Say what is sent — the summary and every sample the band recorded, 100 per second — in all six locales and in the code fallback. Nothing about the payload changes; only the sentence that asks permission for it. A local endpoint still sends nothing off the device, and the dialog is still shown only for a remote one. --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/screens/ecg.dart | 5 +++-- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f81060277..e1c2e6125 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2365,7 +2365,7 @@ "ecgReasonNotEnoughData": "Zu wenige Daten", "ecgAnalyzeNow": "Jetzt analysieren", "ecgAnalyzeCloudTitle": "Diese Aufzeichnung an dein Modell senden?", - "ecgAnalyzeCloudBody": "Die Zusammenfassung und eine begrenzte Kurvenhülle werden an {host} als {model} gesendet. Keine Rohdaten, keine Seriennummer.", + "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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 746724a16..fbb3d12d8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12253,7 +12253,7 @@ "@ecgAnalyzeCloudTitle": { "description": "WHOOP MG ECG: Send this reading to your model?" }, - "ecgAnalyzeCloudBody": "The reading summary and a bounded waveform envelope will be sent to {host} as {model}. No raw frames, no band serial.", + "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": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ba8a06510..8612c4529 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2394,7 +2394,7 @@ "ecgReasonNotEnoughData": "Datos insuficientes", "ecgAnalyzeNow": "Analizar ahora", "ecgAnalyzeCloudTitle": "¿Enviar esta lectura a tu modelo?", - "ecgAnalyzeCloudBody": "El resumen y una envolvente acotada de la onda se enviarán a {host} como {model}. Sin tramas en bruto ni número de serie.", + "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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 98b053a4d..c760566d0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2365,7 +2365,7 @@ "ecgReasonNotEnoughData": "Données insuffisantes", "ecgAnalyzeNow": "Analyser maintenant", "ecgAnalyzeCloudTitle": "Envoyer cette lecture à votre modèle ?", - "ecgAnalyzeCloudBody": "Le résumé et une enveloppe bornée du tracé seront envoyés à {host} en tant que {model}. Aucune trame brute, aucun numéro de série.", + "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", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 766c24523..a41b9a60b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2365,7 +2365,7 @@ "ecgReasonNotEnoughData": "पर्याप्त डेटा नहीं", "ecgAnalyzeNow": "अभी विश्लेषण करें", "ecgAnalyzeCloudTitle": "यह रीडिंग अपने मॉडल को भेजें?", - "ecgAnalyzeCloudBody": "सारांश और सीमित तरंग आवरण {host} को {model} के रूप में भेजा जाएगा। कोई कच्चा डेटा या सीरियल नंबर नहीं।", + "ecgAnalyzeCloudBody": "सारांश और पूरी तरंग (बैंड द्वारा रिकॉर्ड किया गया हर सैंपल, 100 प्रति सेकंड) {host} को {model} के रूप में भेजी जाएगी। कोई कच्चा डेटा या सीरियल नंबर नहीं।", "ecgContinue": "जारी रखें", "ecgCancel": "रद्द करें", "ecgDelete": "रीडिंग हटाएँ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 83388308c..f31d7ab86 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2365,7 +2365,7 @@ "ecgReasonNotEnoughData": "数据不足", "ecgAnalyzeNow": "立即分析", "ecgAnalyzeCloudTitle": "将此读数发送到您的模型?", - "ecgAnalyzeCloudBody": "读数摘要和有限的波形包络将作为 {model} 发送到 {host}。不含原始帧和手环序列号。", + "ecgAnalyzeCloudBody": "读数摘要和完整波形(手环记录的每个采样点,每秒 100 个)将作为 {model} 发送到 {host}。不含原始帧和手环序列号。", "ecgContinue": "继续", "ecgCancel": "取消", "ecgDelete": "删除读数", diff --git a/lib/ui2/screens/ecg.dart b/lib/ui2/screens/ecg.dart index b183516d2..8d4697594 100644 --- a/lib/ui2/screens/ecg.dart +++ b/lib/ui2/screens/ecg.dart @@ -805,8 +805,9 @@ class _EcgDetailScreenState extends State { ), content: Text( l?.ecgAnalyzeCloudBody(host, cfg.model) ?? - 'The reading summary and a bounded waveform envelope will be sent ' - 'to $host as ${cfg.model}. No raw frames, no band serial.', + '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( From 8fe924ac1ef5a39ebf88ce76d3a210d6fbbb3413 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 8 Sep 2026 18:39:16 +0200 Subject: [PATCH 10/10] ui2: redraw the ECG touch illustration as a hand on the band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old drawing did not teach the instruction under it. The forearm was a plain slab, the band a narrow rectangle across it, and the two fingers were detached angled bars floating to one side with nothing joining them to a hand — a user could not tell what to do from the picture. Redraw it as a tapered forearm wearing the band, with the opposite hand reaching across and pinching both electrode indents: thumb on the upper one, index on the lower, enough of the hand visible to read as a hand. The two contact points carry the emphasis and their halos settle once contact is reported. Same painter, same seams: unchanged public API, the left-wrist mirror, theme colours only, no assets, and legible at any frozen `t` for reduced motion. shouldRepaint now compares every input it draws from. --- lib/ui2/ecg_widgets.dart | 230 +++++++++++++++++++++++++++------------ 1 file changed, 159 insertions(+), 71 deletions(-) diff --git a/lib/ui2/ecg_widgets.dart b/lib/ui2/ecg_widgets.dart index 66c4c4cf5..730d3375d 100644 --- a/lib/ui2/ecg_widgets.dart +++ b/lib/ui2/ecg_widgets.dart @@ -19,8 +19,8 @@ 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. [contact] draws the -/// fingers on the electrodes; otherwise they approach. +/// [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; @@ -79,92 +79,180 @@ class _TouchPainter extends CustomPainter { @override void paint(Canvas cv, Size s) { - final w = s.width, h = s.height; - // Mirror for the left wrist: the forearm enters from the other side. - final mirror = wrist == EcgWrist.left; + // A fixed drawing space preserves the hand's proportions on narrow phones. + final scale = math.min(s.width / 360, s.height / 200); cv.save(); - if (mirror) { - cv.translate(w, 0); + 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); } - final stroke = Paint() + cv.translate((s.width - 360 * scale) / 2, (s.height - 200 * scale) / 2); + cv.scale(scale); + final outline = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 2.5 + ..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, + ); - // Resting forearm: a rounded slab from the left edge to the wrist. - final armTop = h * .42, armBot = h * .70; - final armEnd = w * .58; - final arm = RRect.fromLTRBR( - -20, - armTop, - armEnd, - armBot, - Radius.circular((armBot - armTop) / 2), + // 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, ); - cv.drawRRect(arm, fill); - cv.drawRRect(arm, stroke); - - // The band around the wrist, with its two electrode indents on the - // outer face (top and bottom edges of the strap). - final bx = w * .46, bw = w * .07; - final bandRect = RRect.fromLTRBR( - bx, - armTop - 6, - bx + bw, - armBot + 6, - const Radius.circular(6), + 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(bandRect, Paint()..color = band); - final electrodePaint = Paint()..color = accent; - final e1 = Offset(bx + bw / 2, armTop - 6); - final e2 = Offset(bx + bw / 2, armBot + 6); - cv.drawCircle(e1, 4, electrodePaint); - cv.drawCircle(e2, 4, electrodePaint); - - // Opposite hand: thumb from above, index finger from below, meeting the - // two indents. Before contact they hover at a small gap that eases in - // with the phase. - final gap = contact ? 0.0 : 10.0 + 6.0 * math.sin(t * 2 * math.pi); - final finger = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 14 - ..strokeCap = StrokeCap.round - ..color = skin; - final fingerLine = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 14 - ..strokeCap = StrokeCap.round - ..color = ink2 - ..strokeWidth = 16; - // Thumb. - final thumbTip = Offset(e1.dx, e1.dy - 8 - gap); - final thumbBase = Offset(w * .82, h * .08); - cv.drawLine(thumbBase, thumbTip, fingerLine); - cv.drawLine(thumbBase, thumbTip, finger); - // Index finger. - final indexTip = Offset(e2.dx, e2.dy + 8 + gap); - final indexBase = Offset(w * .86, h * .95); - cv.drawLine(indexBase, indexTip, fingerLine); - cv.drawLine(indexBase, indexTip, finger); - - // Contact rings: gentle pulses out of each electrode. - final ringPaint = Paint() + 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 = 2 - ..color = accent.withValues(alpha: (1 - t) * .8); - final r = 6 + 16 * t; - cv.drawCircle(e1, r, ringPaint); - cv.drawCircle(e2, r, ringPaint); + ..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.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