From 14598ea8d8bbc601ae7d67b7f11172ed4b8faddc Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Wed, 19 Aug 2026 15:01:09 +0200 Subject: [PATCH 01/11] gen5 link: official bootstrap order, real burst count gate, correlation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap now follows the order the strap expects: GET_HELLO goes out first and its own body answers identity, battery, charge, wear AND the clock question — the hello timestamp feeds the same verdict logic the GET_CLOCK reply used, so the read round-trip only happens as the fallback it actually is. Hello stays best-effort, not a connect gate. The burst count gate is enforced instead of advisory. A short burst is stored durably WITHOUT the trim token and answered with the two-byte failure result, so the strap re-offers the data instead of trimming flash it never delivered — the old path ACKed success on a shortfall, which was a silent, permanent loss of unbanked records. Battery-pack frames (53/54/55) now count as burst members; a captured type-54 checkpoint was failing 27/24 on every retry because they counted nowhere. The compare is the one-sided rule with slack 2 after three consecutive failures, capped at 15 attempts before a single abort. Command responses are correlated: originating sequence AND echoed opcode must both match, the observer is installed before the write, PENDING is non-terminal only for hello and the data range, and nothing is ever auto-resent. A response matching neither leaves a log trail instead of satisfying a stranger's await. Five hello failures across reconnects drop the platform bond and start over; serial/CPU identity is checked and logged (all-zero serial = the EEPROM-failure signal), never used to drop the link. The conditional-wake window uses the official 180 s / 7200 s cadence, the stored alarm can be run early with the rev-2 body, and the alarm read-back is a verification signal that never clobbers the user's displayed alarm. --- lib/ble/ble_engine.dart | 955 ++++++++++++++++++++++------- lib/ble/ble_state.dart | 361 +++++++++++ test/alarm_test.dart | 86 +++ test/ble_clock_gate_test.dart | 26 +- test/ble_engine_test.dart | 79 ++- test/command_correlation_test.dart | 565 +++++++++++++++++ test/gen5_wiring_test.dart | 76 ++- 7 files changed, 1916 insertions(+), 232 deletions(-) create mode 100644 test/command_correlation_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 2bb7caad..372062a7 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -193,13 +193,41 @@ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => /// gate-rejected record can never validate — which discards its OTHER, /// perfectly good buffered records and re-requests the same stuck block /// forever (zero sync progress). +/// The official rule is ONE-SIDED with a failure-dependent slack, not equality +/// (reversing-whoop doc 05, "Collector and count gate"): +/// +/// ```text +/// slack = consecutiveFailedValidations >= 3 ? 2 : 0 +/// pass = expected - slack <= actual +/// ``` +/// +/// Two consequences worth stating, because equality got both wrong: +/// * SURPLUS PASSES. There is no upper bound. The strap re-offers an +/// unacknowledged burst and can re-deliver frames, so tallying MORE than +/// expected is normal and must not fail — under equality it did. +/// * The first three attempts demand every frame; from the fourth, up to two +/// missing are tolerated so a burst with a persistently unreadable frame +/// can still make progress instead of looping to the 15-attempt abort. +/// The official Sensor-HPS boundary: attempts 1..14 send a failure result and +/// wait for the strap to re-offer; the 15th is terminal and aborts instead of +/// sending a fifteenth failure (reversing-whoop doc 05, "Exact Sensor-HPS retry +/// boundary"). Bounding it is what stops a permanently-short burst becoming an +/// infinite re-request loop. +const int kBurstValidationAttemptLimit = 15; + +@visibleForTesting +int burstCountSlack(int consecutiveFailedValidations) => + consecutiveFailedValidations >= 3 ? 2 : 0; + @visibleForTesting bool burstPacketCountMatches({ required int expectedPacketCount, required int actualBurstPacketCount, required int droppedThisBurst, + int consecutiveFailedValidations = 0, }) => - expectedPacketCount == actualBurstPacketCount + droppedThisBurst; + expectedPacketCount - burstCountSlack(consecutiveFailedValidations) <= + actualBurstPacketCount + droppedThisBurst; /// Honest burst-completeness signal for TELEMETRY ONLY — this NEVER gates the /// commit/ACK decision (see the log-only call site). @@ -808,6 +836,26 @@ class BleEngine { ); } + /// Commands currently waiting for a correlated response (doc 02). Zero at + /// rest; a wrong-opcode reply must leave the count unchanged. + @visibleForTesting + int get pendingCommandCount => _awaiter.pendingCount; + + /// Hello failures counted across reconnect attempts (doc 01). + @visibleForTesting + int get helloFailureCount => _helloFailures; + + /// The identity verdict from the last successful hello (doc 01). + @visibleForTesting + HelloIdentity? get helloIdentity => _helloIdentity; + + /// Drive the real gen5 hello exchange (write → correlated await → identity + /// gate / failure counter). Everything it decides sits behind a radio + /// otherwise, and it is the one path where a mis-correlated reply would be + /// acted on as a real identity. + @visibleForTesting + Future debugReadGen5Hello() => _readGen5Hello(); + /// Feed one inbound historical frame through the real ingest path (decode → /// plausibility gate → store or archive). /// @@ -932,6 +980,12 @@ class BleEngine { int? _strapHistoryOldestTs; int? _strapHistoryNewestTs; + /// Last GET_ALARM_TIME readback: what the STRAP says it holds, as opposed to + /// what the app believes it set. Diagnostics only — a disagreement means the + /// user's alarm may not actually be armed. Never used for display. + int? _strapAlarmEpoch; + bool? _strapAlarmActive; + // ── reconnect/offload policy ──────────────────────────────────────────────── // Marginal-radio + post-bond-loop persist ACROSS reconnects (they count // consecutive bad cycles), so they live for the engine's lifetime and self-reset @@ -1022,10 +1076,30 @@ class BleEngine { !ClockPolicy.suspectGraceExpired( _phoneClockSuspectSince, _monotonicSecs()); int _clockPausedOffloads = 0; // diagnostics: offloads deferred for this reason - /// Completes when the `clock_epoch` for the GET_CLOCK issued by [_readClock] - /// has been absorbed, so the clock gates read THIS session's verdict instead - /// of whatever the last connection left behind. - Completer? _clockReadPending; + /// Request/response correlation for every command this engine awaits + /// (doc 02). Replaces the two ad-hoc one-shot completers this file used to + /// carry for HELLO and GET_CLOCK, which keyed off "a reply of roughly the + /// right shape arrived" and could therefore be satisfied by an unrelated + /// command's answer. Emptied on teardown so a dropped link never leaves a + /// caller waiting out a full timeout on a connection that is gone. + final CommandAwaiter _awaiter = CommandAwaiter(); + + /// The most recent gen5 HELLO. Its timestamp is the official input to the + /// clock decision (doc 01: the normal gen5 path compares hello's time to the + /// phone and never sends GET_CLOCK unless hello supplied none). + Gen5HelloInfo? _gen5Hello; + + /// doc 01 §"Hello failure handling": failures are counted ACROSS reconnect + /// attempts (like `_marginalRadio`/`_postBondLoop`, and deliberately NOT + /// reset in the per-connection block in `_doConnect`); at + /// [kHelloFailuresBeforeBondReset] the counter resets and the platform bond + /// is removed before starting over. A successful hello clears it. + int _helloFailures = 0; + static const int kHelloFailuresBeforeBondReset = 5; + + /// The identity verdict from the last successful hello (doc 01 "What gates + /// READY") — observable, never a disconnect. Null until a hello lands. + HelloIdentity? _helloIdentity; DateTime? _bondTime; // when the handshake completed (bond confirmed) DateTime? _armTime; // when live (R10/R11) streams were last armed // Run-state for a chain of auto-continued offload rounds: how many @@ -1259,6 +1333,16 @@ class BleEngine { 'high_freq_requested': _highFreqModeRequested, 'high_freq_reason': _highFreqReason, 'high_freq_until_ms': _highFreqUntil?.millisecondsSinceEpoch, + // What the STRAP reports it holds (GET_ALARM_TIME), not what we set. + 'strap_alarm_epoch': _strapAlarmEpoch, + 'strap_alarm_active': _strapAlarmActive, + // doc 01/02: hello health and the identity gate, both observable rather + // than enforced. `hello_failures` counts ACROSS reconnects and resets + // itself at the bond-reset threshold. + 'hello_failures': _helloFailures, + 'hello_identity_ok': _helloIdentity?.ok, + 'hello_serial_eeprom_failure': _helloIdentity?.eepromFailureSignal, + 'pending_commands': _awaiter.pendingKeys, }; int? get strapHistoryNewestTs => _strapHistoryNewestTs; @@ -1547,6 +1631,25 @@ class BleEngine { // (drift 0) instead of the stale strap-RTC frame. The reads below // repopulate it for this connection. _clockRef = null; + _gen5Hello = null; + // HELLO FIRST on gen5 — the official bootstrap order (doc 01). Hello + // carries the strap's own timestamp, so it answers the "what time does + // the band think it is" question that the GET_CLOCK below exists to ask, + // and it carries identity/battery/charge/on-body state that everything + // after this wants. The app used to send it late, inside INIT, so none of + // that was available here and gen5 had no serial or battery at connect. + // + // Best effort: a failed or unanswered hello falls through to the ordinary + // clock read, which is what the official client does when hello supplies + // no timestamp. Nothing below is gated on it. + if (session.band.isGen5) { + await _readGen5Hello(); + if (_session != session || !session.connected) { + _log('link dropped during gen5 HELLO — abandoning setup.'); + if (identical(_session, session)) await _failConnect(); + return false; + } + } // READ BEFORE WRITE. This used to be an unconditional SET_CLOCK, which is // precisely the write [ClockPolicy.phoneClockSuspect] says we must never // make: on a phone running >1 day slow it stamps that slow time onto a @@ -1560,7 +1663,17 @@ class BleEngine { // link to drop underneath us, and setClock() absorbs failed writes, so // without these checks setup would carry on past a teardown, rebuild the // drain state and hand back `true` for a dead connection. - await _readClock(); + // Hello already answered this on gen5, so skip the round trip — the + // official client only falls back to GET_CLOCK when hello carried no + // timestamp. Feed hello's clock through the same handler the GET_CLOCK + // reply uses, so the suspect-phone and unset-RTC verdicts are computed + // from one place regardless of which command supplied the epoch. + final helloClock = _gen5Hello?.tsSeconds; + if (helloClock != null && helloClock > 0) { + _absorbClockEpoch(helloClock); + } else { + await _readClock(); + } if (_session != session || !session.connected) { _log('link dropped during the clock read — abandoning setup.'); // Tear down ONLY if we are still the live session. `_failConnect` @@ -1784,12 +1897,28 @@ class BleEngine { kBatteryPollIntervalSeconds) { return; } - // `_send` swallows write failures and reports them as false. Stamping - // regardless would buy five minutes of silence off a write that never left - // the phone. - if (await _send(Cmd.getBatteryLevel, const [])) { - _lastBatteryPollAt = DateTime.now(); - } + // Correlated (doc 02) but deliberately NOT awaited by this caller: the + // battery level is a display value, and both call sites — the keep-alive + // tick and `getBattery()` on the session-open path — only ever needed the + // write to have gone out. Blocking either for up to five seconds on a + // strap that ignores the poll would trade a cosmetic value for a slower + // connect. What the correlation buys is the log line below: an unanswered + // poll on the link whose ONLY inbound traffic is this reply is exactly the + // liveness signal the keep-alive cares about. + // + // Write failures are swallowed and reported as false. Stamping regardless + // would buy five minutes of silence off a write that never left the phone. + final out = await _sendAwaited(Cmd.getBatteryLevel, const []); + if (!out.written) return; + // The stamp belongs to the WRITE, so a strap that never answers does not + // turn the poll into a five-second-per-tick retry loop. + _lastBatteryPollAt = DateTime.now(); + unawaited(out.response.then((r) { + if (r == null) { + _log('[BATTERY] GET_BATTERY_LEVEL went unanswered — the link produced ' + 'no inbound traffic for this poll.'); + } + })); } /// Trigger a historical offload, floored by [BackfillPolicy] (manual / @@ -2138,7 +2267,9 @@ class BleEngine { } } - Future _send(int opcode, List payload) async { + /// The dangerous-opcode hard block, shared by [_send] and [_sendAwaited] so + /// an awaited command can never take a route around it. + bool _refuseDangerousOpcode(int opcode) { // `dangerousCmds` is this codebase's own gen4-curated hard-block list // (FORCE_TRIM/REBOOT/POWER_CYCLE/TOGGLE_PERSISTENT_R21/firmware-load). // `OpcodeSafety.destructive` is whoop-rs's independently-curated list of @@ -2151,8 +2282,13 @@ class BleEngine { // blanket block on `forbidden` would be wrong here. if (dangerousCmds.contains(opcode) || OpcodeSafety.isDestructive(opcode)) { _log('REFUSED dangerous opcode 0x${opcode.toRadixString(16)}'); - return false; + return true; } + return false; + } + + Future _send(int opcode, List payload) async { + if (_refuseDangerousOpcode(opcode)) return false; final frame = buildCommand( _seq.nextLive(), opcode, payload, _session?.band ?? BandProfile.gen4); final ok = await _write(frame); @@ -2163,6 +2299,49 @@ class BleEngine { return ok; } + /// Send a command and wait for ITS reply (doc 02). + /// + /// The observer is installed BEFORE the write ("Ordering"), so a response + /// that beats the write's own completion still finds a waiter. Correlation is + /// strict: only a reply echoing this exact sequence AND opcode satisfies the + /// await; anything else leaves it to expire. The timeout is applied exactly + /// once and NOTHING is resent — retry belongs to the calling state machine + /// ("Timeouts and retries"), because several commands mutate persistent state + /// and a duplicate write after a slow-but-successful response is a real + /// hazard. + /// + /// Awaits the WRITE and hands back whether it went out plus the still-pending + /// response, so a caller can distinguish "we never asked" from "we asked and + /// heard nothing" — different failures with different remedies — and so a + /// caller that only needs the request to have left the phone (the battery + /// poll) does not have to block on the reply. `response` completes null on a + /// failed write and on timeout. + /// + /// [frameBuilder] is for commands whose frame comes from a protocol helper + /// rather than a bare opcode+payload (the gen5 hello); it receives the + /// allocated sequence so the correlation still holds. + Future<({bool written, Future response})> _sendAwaited( + int opcode, + List payload, { + Duration timeout = CommandAwaiter.defaultTimeout, + Uint8List Function(int seq)? frameBuilder, + }) async { + if (_refuseDangerousOpcode(opcode)) { + return (written: false, response: Future.value()); + } + final seq = _seq.nextLive(); + 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)) { + pending.cancel(); + _log('WRITE FAILED for opcode 0x${opcode.toRadixString(16)} — ' + 'command not delivered.'); + return (written: false, response: pending.response); + } + return (written: true, response: pending.response); + } + // Offload commands whose PAYLOAD (not just the frame envelope) is // generation-specific: gen4 sends a single 0x00, gen5 sends an EMPTY payload. // Centralised so every offload trigger — the initial handshake, periodic @@ -2184,13 +2363,23 @@ class BleEngine { Future _sendHistoricalData() => _send(Cmd.sendHistoricalData, _offloadPayload); + /// Ask the strap to prompt more frequent history syncs around a wake time. + /// + /// Defaults are the OFFICIAL Smart Alarm values recovered from WHOOP's own + /// client: interval **180 s**, duration **7200 s** (2 h), i.e. the wire body + /// `02 b4 00 20 1c` (reversing-whoop doc 14 "High-frequency command", doc 05 + /// "High-frequency mode is a scheduler mode"). The window officially opens at + /// `latest wake time - 2 hours`, which is why the duration matches it. + /// + /// The previous default was 61 s / 90 min — chosen only because gen5 refuses + /// an interval of 60 or less, not because anything established it. A shorter + /// interval means more wake/connect cycles for the same result; the official + /// cadence is the one with evidence behind it. Future applyHighFreqWakeWindow({ required bool enabled, required DateTime? targetWake, - Duration duration = const Duration(minutes: 90), - // 61, not 60: gen5 refuses an interval of 60 or less outright, so the - // round number is the one value that guarantees the mode never engages. - int intervalSeconds = 61, + Duration duration = const Duration(seconds: 7200), + int intervalSeconds = 180, String reason = 'wake_window', }) async { if (_session?.connected != true) return; @@ -2358,6 +2547,21 @@ class BleEngine { } } else if (pt == PacketType.consoleLogs && _offloadActive) { _drain?.onBurstConsole(); + } else if (_offloadActive && + (pt == PacketType.relativePuffinEvents || + pt == PacketType.puffinEventsFromStrap || + pt == PacketType.relativeBatteryPackConsoleLogs)) { + // Battery-pack ("puffin") event/log wrappers, types 53/54/55. The strap + // COUNTS these in the burst total it reports at HISTORY_END, and they + // were counted nowhere here — so any burst carrying one looked short by + // exactly that many frames. That is not hypothetical: a retained capture + // has a checkpoint of 24 ordinary packets plus three type-54 wrappers + // reported as `expected = 27`, which fails 27/24 forever until the + // wrappers are counted (reversing-whoop doc 05, "History count + // membership" — each complete 47/48/50/53/54/55 frame counts once, and + // type 49 metadata never does). + _drain?.onBurstEvent(); + _log('[SYNC] puffin wrapper type=$pt counted as a burst member'); } final band = _session?.band ?? BandProfile.gen4; final decoded = _maybeAugmentClockEpoch( @@ -2642,16 +2846,29 @@ class BleEngine { ), ); } - // GET_ALARM_TIME readback is PARKED: the response byte layout isn't confirmed - // (the decode assumed a leading revision byte before the epoch that the band - // doesn't send → it returned a plausible-but-wrong epoch, e.g. showing 21:49 - // for an alarm set to 11:14). The band has no independent alarm source — its - // alarm is always exactly what the app last wrote (SET_ALARM is HW-verified) — - // so the locally-set/persisted value in AppState is authoritative for display. - // Do NOT clobber it with the unconfirmed readback. If the response format is - // ever captured, decode it in parseCommandResponse and re-enable here. + // GET_ALARM_TIME readback, re-enabled as a VERIFICATION signal. + // + // It was parked because the response layout was unconfirmed and the decode + // returned a plausible-but-wrong epoch (21:49 for an alarm set to 11:14). + // The revision-4 response is now pinned from the official client: + // body[0] revision 04 · body[1] active flag (exactly 1) · + // body[2:6] epoch u32 LE · body[6:8] subsec u16 + // and protocol reads the epoch at that offset, so the old wrong-offset + // failure mode is gone. // - // if (f.containsKey('alarm_epoch')) { ... } + // Deliberately still NOT authoritative for display: AppState's persisted + // value is what the user set, and this reply is only meaningful when it + // DISAGREES — which is exactly the case worth surfacing, because it means + // the alarm the user believes is armed is not armed on the band. Log the + // disagreement and expose it for diagnostics; never silently overwrite the + // user's alarm with a value read off the wire. + if (f.containsKey('alarm_epoch')) { + final strapEpoch = (f['alarm_epoch'] as num).toInt(); + final active = f['alarm_active'] as bool?; + _strapAlarmEpoch = strapEpoch; + _strapAlarmActive = active; + _log('[ALARM] strap readback: epoch=$strapEpoch active=$active'); + } if (f.containsKey('strap_name')) { // Guard with cleanDeviceLabel: a garbled name read never overwrites the // last good one (keeps "?*" off the UI). @@ -2690,132 +2907,8 @@ class BleEngine { state.wristOn = f['on_wrist'] as bool; onState(state); } - // A GET_CLOCK reply releases the read gate whether or not a usable epoch - // came out of it — "the read completed" and "the read produced a plausible - // clock" are different questions. A revision byte we do not recognise, or a - // corrupt above-ceiling value, yields no `clock_epoch` at all; leaving the - // gate to time out would then cost 3 s on EVERY clock read, stalling both - // the connect-path SET_CLOCK decision and the drain gate. - if (d.kind == 'cmd_response' && - (f['opcode'] == Cmd.getClock || f['opcode'] == Cmd.getClockGen5)) { - final pendingRead = _clockReadPending; - if (pendingRead != null && !pendingRead.isCompleted) { - pendingRead.complete(); - } - } if (f.containsKey('clock_epoch')) { - final dev = f['clock_epoch'] as int; - final wall = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // Assess phone-clock trust from the RAW read, before the alarm-safety gate - // below diverts a future reading. A plausible strap RTC that reads > 1 day - // ahead of the phone means the phone clock is likely slow — history offload - // then DEFERS (see _startHistoricalRefresh) instead of dropping the strap's - // real records as "future" and trimming them off the band. Cleared the - // moment a read agrees (the phone almost always self-corrects via NTP). - final wasSuspect = _phoneClockSuspect; - _phoneClockSuspect = ClockPolicy.phoneClockSuspect(dev, wall); - if (_phoneClockSuspect && !wasSuspect) { - _phoneClockSuspectSince = _monotonicSecs(); - } else if (!_phoneClockSuspect) { - _phoneClockSuspectSince = null; - } - // The read gate is released above, on the reply itself, not here. - // - // UNCORRELATED either way: any GET_CLOCK reply releases the waiter, - // including one answering setClock()'s read-back or the keep-alive poll. - // Telling them apart needs the echoed request seq, which the pinned - // protocol does not surface — see the pin note in pubspec.yaml and - // OpenStrap/protocol#28. The reply that lands is still a real strap read - // from this session, so the verdict is fresh; it may just answer a - // request a few hundred ms older than ours. - if (_phoneClockSuspect != wasSuspect) { - _log(_phoneClockSuspect - ? '[SYNC] Phone clock appears wrong: strap RTC=$dev is > 1 day ahead ' - 'of phone wall=$wall — DEFERRING history offload until they agree.' - : '[SYNC] Phone/strap clocks agree again (strap=$dev wall=$wall) — ' - 'history offload may resume.'); - } - // SANITY GATE, mirroring the one `range_newest` gets below. An - // implausibly far-future `clock_epoch` yields a large NEGATIVE driftSec, - // and setAlarm arms at `when - driftSec` — years out, where the alarm - // silently never fires — while the bounded SET_CLOCK retry budget is - // spent chasing a value that was never real. Reject the read: with no - // correlation the alarm falls back to the raw wall epoch. connect() - // already issues an unconditional SET_CLOCK, and the periodic re-verify - // re-reads, so a genuinely-wrong RTC still gets corrected. - if (dev < kMinPlausibleUnix) { - // UNSET RTC. This read is now surfaced instead of swallowed by the - // decoder (see [_maybeAugmentClockEpoch]) so the SET_CLOCK correction - // below can finally fire for it — but it must NOT become a ClockRef: - // correlating a factory-epoch clock yields a drift of decades, and - // `AlarmPayloads.toStrapFrame` would arm every alarm that far in the - // past. - _log( - '[SYNC] GET_CLOCK clock_epoch=$dev is below the plausible floor — ' - 'the strap RTC was never set. NOT correlating; SET_CLOCK below is ' - 'the fix.', - ); - } else if (!ClockPolicy.acceptsClockRead(dev, wall)) { - _corruptClockReadCount++; - _log( - '[SYNC] GET_CLOCK clock_epoch=$dev is implausibly far in the future ' - '— treating as a corrupt strap RTC read; NOT correlating the strap ' - 'clock (alarms fall back to the raw wall epoch) ' - '(corrupt_clock_reads_total=$_corruptClockReadCount).', - ); - } else { - _clockRef = ClockRef(device: dev, wall: wall); - _log('Clock correlated: device=$dev wall=$wall (drift=${wall - dev}s).'); - } - // CORRECTION RUNS ON THE RAW READ, outside the correlation gate above. - // - // It used to be nested inside the accepted-read branch, which quietly - // made a fast strap RTC unfixable: `acceptsClockRead` rejects anything - // past `wall + kFutureMargin` and `phoneClockSuspect` trips past that - // SAME margin, so the one reading that means "the strap clock is ahead" - // could never reach the one code path that fixes it. History would - // un-defer at grace expiry — having concluded the STRAP is the fast one — - // straight back onto an uncorrected fast RTC, where the record gate - // rejects every future-stamped record and the offload can never bank - // anything. - // - // Rejecting the read for CORRELATION is still right (a junk value would - // arm alarms years out). Rejecting it for CORRECTION never was: SET_CLOCK - // writes real wall time, which is the correct outcome whether the read - // was junk or the RTC is genuinely ahead, and the retry budget is bounded - // at 3 either way. - if (ClockPolicy.shouldSetClock(dev, wall)) { - if (_deferForClock) { - // While the phone is still the suspect party, writing our wall clock - // onto a strap that may well be RIGHT corrupts a correct RTC and - // destroys the evidence — the read-back then "agrees" forever. Hold - // off until the phone corrects (gate clears) or the grace expires - // (the strap is the fast one, and the branch below fixes it). - _log( - 'Clock drift over policy but the PHONE clock is the suspect one ' - '(strap=$dev wall=$wall) — NOT writing SET_CLOCK yet.', - ); - } else if (_clockCorrectTries < 3) { - // BOUND the retries: setClock() reads the clock back and this handler - // re-issues on drift, so an unbounded loop would spin - // SET_CLOCK/GET_CLOCK forever on firmware that never latches. - // Historical records carry their own embedded unix time regardless, - // so giving up after a few tries is safe. - _clockCorrectTries++; - _log( - 'Clock drift over policy — re-issuing SET_CLOCK ' - '(attempt $_clockCorrectTries/3).', - ); - unawaited(setClock()); - } else { - _log( - 'Clock still off after 3 SET_CLOCK attempts — giving up; ' - 'firmware may not accept our payload length.', - ); - } - } else { - _clockCorrectTries = 0; // latched — reset for the next drift episode - } + _absorbClockEpoch(f['clock_epoch'] as int); } if (f.containsKey('range_oldest') && f.containsKey('range_newest')) { final oldest = f['range_oldest'] as int; @@ -2854,15 +2947,23 @@ class BleEngine { state.wristOn = h.wristOn ?? state.wristOn; onState(state); } - // gen5's GET_HELLO (opcode 145) response shape is unrelated to gen4's - // HelloInfo — it carries a device_name + a gated fw_version instead - // (parseCommandResponse's gen5 GET_HELLO branch). No confirmed serial/ - // battery/wrist-on offsets for it yet, so — unlike gen4's HELLO above — - // this is diagnostics-only for now (confirms the untested gen5 handshake - // actually got a byte-parseable reply) rather than wired into `state`. - if (d.kind == 'cmd_response' && f.containsKey('device_name')) { - _log('[HELLO gen5] device_name=${f['device_name']} ' - 'fw_version=${f['fw_version']}'); + // gen5's GET_HELLO (opcode 145) has its own layout, now decoded in full + // against the official revision-1 body map — battery, charge state, the + // strap's own timestamp, serial, firmware and on-body state all come from + // here (doc 01 "Revision-1 hello body"). It used to be diagnostics-only + // because those offsets were unconfirmed, which left gen5 with no serial, + // no battery-at-connect and no wrist state. + if (d.kind == 'cmd_response' && f['gen5_hello'] is Gen5HelloInfo) { + final h = f['gen5_hello'] as Gen5HelloInfo; + _gen5Hello = h; + state.serial = cleanDeviceLabel(h.serial) ?? state.serial; + if (h.batteryPct != null) state.batteryPct = h.batteryPct!.toDouble(); + state.charging = h.charging; + state.wristOn = h.wristOn; + onState(state); + _log('[HELLO gen5] serial=${h.serial} fw=${h.firmwareVersion} ' + 'battery=${h.batteryPct}% charging=${h.charging} ' + 'wrist=${h.wristOn} whoop5=${h.isWhoop5}'); } if (d.kind == 'realtime_hr') { final hr = f['hr'] as int; @@ -2873,6 +2974,35 @@ class BleEngine { onState(state); } } + // Correlation LAST, so everything a reply carries is already applied to + // the engine's state by the time whoever awaited it resumes. + // + // A reply satisfies its await whether or not the body made sense — "the + // read completed" and "the read produced a usable value" are different + // questions, and conflating them cost a full timeout on every clock read + // whose revision byte we did not recognise. + if (d.kind == 'cmd_response') { + final opcode = (f['opcode'] as num?)?.toInt(); + final reqSeq = (f['req_seq'] as num?)?.toInt(); + final outcome = _awaiter.deliver( + opcode: opcode, + reqSeq: reqSeq, + status: (f['cmd_status'] as num?)?.toInt(), + fields: f, + ); + // A near-miss — right opcode but a sequence we never sent, or the right + // sequence carrying a different opcode — is the one symptom worth + // shouting about. It is what a strap that does not echo the originating + // sequence the way doc 02 describes would look like, and it is doc 02's + // own "a sequence match with the wrong opcode is not a success" case. + // Either way the await it belongs to just expires, silently, without it. + final nearMiss = (opcode != null && _awaiter.hasPendingOpcode(opcode)) || + (reqSeq != null && _awaiter.hasPendingSeq(reqSeq)); + if (outcome == CommandDelivery.unmatched && nearMiss) { + _log('[CMD] response opcode=$opcode req_seq=$reqSeq matched no pending ' + 'command (waiting on ${_awaiter.pendingKeys}) — ignored.'); + } + } } /// (Re)arm the 60s idle watchdog. Called on every offload frame (records + @@ -2967,6 +3097,96 @@ class BleEngine { /// blocked reasons. The band keeps the chunk and re-delivers it on the next /// offload; re-delivery is dedup-safe (decoded rows REPLACE by rec_ts, raw /// rows key on the record hex). + /// The count gate refused this burst: persist what arrived, tell the strap + /// the burst FAILED, and let it re-offer the same checkpoint unchanged. + /// + /// Three properties, all load-bearing: + /// 1. `commit(null)` — the records and raws are stored durably, but WITHOUT + /// the trim token, so the cursor does not advance and nothing is deleted + /// from the band. Re-delivery is dedup-safe (`decoded_onehz` REPLACEs by + /// `rec_ts`), so storing now costs nothing and means a burst we keep + /// failing still yields its readable records. + /// 2. The 2-byte `00 00` failure result, which is what makes the strap + /// re-offer rather than sit waiting for a result that never comes. + /// 3. A bounded end: the 15th consecutive failure sends ONE abort and + /// terminates the session — and deliberately does NOT send a 15th failure + /// result, matching the official client. + Future _refuseHistoryEndOnShortCount({ + required DrainController d, + required _Session session, + required String tokenHex, + required int? batchId, + required int? expected, + required int droppedThisBurst, + }) async { + // Store what did arrive, without the token. + final durable = await d.commit(null); + if (!durable) { + _log('[SYNC] short-count burst ALSO failed to commit — bouncing the ' + 'link so the next session retries from a clean batch.'); + if (!_sessionIsStale(session)) { + unawaited( + _teardownSession(intentional: false).then((_) { + _setPhase(BleConnState.idle); + }), + ); + } + return; + } + if (_sessionIsStale(session)) return; + + if (d.consecutiveValidationFailures >= kBurstValidationAttemptLimit) { + // Terminal. One abort, no 15th failure result, and NO same-session + // auto-retry: the strap keeps the uncommitted checkpoint and a later + // connection resumes from it. + _log( + '[SYNC] burst still short after ' + '${d.consecutiveValidationFailures} attempts — aborting history for ' + 'this session (records are stored; the band keeps the checkpoint).', + ); + await _bestEffortLedgerWrite(() => LocalDb.upsertSyncLedgerEntry( + chunkId: 'batch:$tokenHex', + kind: 'historical_batch', + status: 'stuck', + lastError: 'burst_short_count_attempts_exhausted', + metaPatch: { + 'batch_id': batchId, + 'expected_burst_packets': expected, + 'actual_burst_packets': d.currentBurstPacketCount, + 'dropped_this_burst': droppedThisBurst, + 'attempts': d.consecutiveValidationFailures, + }, + )); + await _send(Cmd.abortHistoricalTransmits, const [0x00]); + _setOffloadActive(false); + return; + } + + final ok = await _write( + buildHistoryResultFail(_seq.nextSync(), + profile: _session?.band ?? BandProfile.gen4), + ); + _log( + '[SYNC] sent FAILURE result for token=$tokenHex ' + '(attempt ${d.consecutiveValidationFailures}/' + '$kBurstValidationAttemptLimit, write_ok=$ok) — the band re-offers this ' + 'burst; nothing was trimmed.', + ); + await _bestEffortLedgerWrite(() => LocalDb.upsertSyncLedgerEntry( + chunkId: 'batch:$tokenHex', + kind: 'historical_batch', + status: 'trim_refused', + lastError: 'burst_short_count', + metaPatch: { + 'batch_id': batchId, + 'expected_burst_packets': expected, + 'actual_burst_packets': d.currentBurstPacketCount, + 'dropped_this_burst': droppedThisBurst, + 'attempts': d.consecutiveValidationFailures, + }, + )); + } + Future _refuseHistoryEndTrim( TrimAckVerdict verdict, { required DrainController d, @@ -3184,26 +3404,31 @@ class BleEngine { receivedTrafficCount: d.currentBurstTrafficCount, droppedThisBurst: droppedThisBurst, ); - // ADVISORY ONLY, never a gate: `expectedPacketCount`'s exact semantics - // (which transport packet types the band itself counts — command - // responses interleaved with the burst? retried/duplicate frames?) are - // not fully reverse-engineered, and field data shows the gap between - // expected and actual varies run to run with no fixed offset. What IS - // fully verified is frame-level CRC32 (framing.dart) and the RecordGate - // plausibility check — both already ran on every buffered record before - // we ever get here. So a count mismatch is NOT evidence of corrupt or - // missing data; treating it as fatal was actively harmful: on mismatch - // the OLD behavior discarded the entire buffered chunk (throwing away - // perfectly good, already-CRC-verified, already-gate-passed records), - // told the band FAIL, and re-requested the same block — forever, since - // nothing about a retry changes the count relationship. Zero sync - // progress, "last data" frozen indefinitely. Log the mismatch (still - // useful signal — see the sync-diagnostics screen) and commit anyway. + // THE COUNT GATE. A short burst must NOT be acknowledged. + // + // This was advisory-only because the band's count semantics were unknown, + // and the previous attempt at a gate caused a "fail forever" loop. Both + // problems are now solved rather than avoided: + // + // * SEMANTICS. The strap reports `data_pkt_cnt + event_pkt_cnt`, and each + // complete type-47/48/50/53/54/55 frame counts exactly once (type 49 + // metadata never does). Types 53/54/55 were counted NOWHERE here until + // now, which alone made any burst carrying them look short. + // * NO INFINITE LOOP. A failure is not a discard: the records stay + // buffered and durable, the strap re-offers the SAME burst unchanged, + // and the sequence is bounded — the 15th consecutive failure aborts the + // session instead of retrying forever. The strap also drops its own + // burst size from 50 to 10 after five negative results. + // + // Why refusing is the safe direction: an ACK makes the band TRIM the + // acknowledged pages from flash. Acknowledging a burst we only partly + // received deletes the missing records from the only place they exist. + // Refusing costs a re-delivery; acknowledging costs the data permanently. if (!validated) { _burstMismatchTotal++; _burstMismatchStreak++; _log( - '[SYNC] Burst packet-count mismatch (advisory, NOT blocking commit) ' + '[SYNC] Burst packet-count SHORT — refusing the trim ACK ' '(attempt ${d.consecutiveValidationFailures}, ' 'streak=$_burstMismatchStreak): expected=$expected, ' 'actual=${d.currentBurstPacketCount}, ' @@ -3226,6 +3451,15 @@ class BleEngine { 'burst_shortfall': shortfall, }, )); + await _refuseHistoryEndOnShortCount( + d: d, + session: session, + tokenHex: tokenHex, + batchId: m.batchId, + expected: expected, + droppedThisBurst: droppedThisBurst, + ); + return; } else { _burstMismatchStreak = 0; } @@ -3623,15 +3857,18 @@ class BleEngine { // [drain] is honoured here for the same reason it exists on gen4: the // drain must not start while the phone clock is suspect, or the records // it pulls get stamped against a clock we do not trust. - _log('Sending gen5 CLIENT_HELLO + offload…'); + // No CLIENT_HELLO here any more: the connect path sends and AWAITS it + // during setup, before the clock decision, which is the official order + // (doc 01). Re-sending it at INIT would be a second identity exchange + // after the point every consumer of it has already run. + _log('Sending gen5 offload…'); var ok = false; // try/finally for the same reason the gen4 loop below has one: other // paths rely on `_connectSetup` being cleared here, and a throw anywhere // above the clear leaves the link pinned at setup priority for the whole // connection with `_applyLinkPriority` early-returning forever. try { - ok = await _write(gen5ClientHello()); - await Future.delayed(const Duration(milliseconds: 120)); + ok = true; // hello already completed during connect setup // Opt-in deep-buffer sequence, BEFORE the offload trigger (SET_CONFIG // flags must land before SEND_HISTORICAL_DATA to take effect for this // drain). Default OFF — see [gen5DeepBuffersEnabled]. @@ -3784,42 +4021,162 @@ class BleEngine { final ms = now.millisecondsSinceEpoch; final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units - // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 and a - // body that leads with a revision byte; protocol owns both — see - // `cmdSetClockGen5`. Gen4 keeps the hardware-verified 8-byte body. + // SET_CLOCK(10) with the 8-byte body is the OFFICIAL + // command on BOTH generations. It used to send opcode 146 ("Maverick + // clock") on gen5 — a number that appears nowhere in the official 75-opcode + // enum recovered from WHOOP's own client, and which nothing has ever + // watched latch an RTC. The real gen5 contract is opcode 10, physically + // confirmed: a probe read the clock with GET_CLOCK(11), measured ~2410 ms + // of drift and received SUCCESS for this exact 8-byte form from a WHOOP 5. + // + // This matters beyond tidiness: a rejected clock write is SILENT. The RTC + // simply never latches, and every absolute timestamp afterwards — alarms + // above all — is armed against a clock that was never set. final isGen5 = _session?.band.isGen5 ?? false; - if (isGen5) { - await _write(cmdSetClockGen5(_seq.nextLive(), now: now)); - } else { - await _send(Cmd.setClock, [ - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, - 0, - 0, - ]); - } - _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec ' - 'subsec=$subsec.'); + await _send(Cmd.setClock, [ + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]); + _log('SET_CLOCK${isGen5 ? " (gen5)" : ""} → sec=$sec subsec=$subsec.'); // Read the RTC back so the GET_CLOCK response handler can confirm it latched // (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded). await getClock(); } /// Read the strap RTC. The response carries `clock_epoch`, handled where we - /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its - /// own GET_CLOCK opcode and needs a leading revision byte — protocol's - /// `cmdGetClockGen5` owns both. - Future getClock() { - if (_session?.band.isGen5 ?? false) { - return _write(cmdGetClockGen5(_seq.nextLive())); - } - return _send(Cmd.getClock, const []); + /// verify drift and re-correlate the strap-RTC ↔ wall clock. + /// + /// GET_CLOCK(11) with an EMPTY body on both generations — physically + /// confirmed on a WHOOP 5 (see [setClock] for the evidence and for why the + /// gen5-exclusive opcode 147 was dropped). The reply body is the same + /// `[u32 sec][u32 subsec]` shape on both. + Future getClock() => _send(Cmd.getClock, const []); + /// Apply a strap clock reading: phone-suspect verdict, correlation, and the + /// bounded SET_CLOCK correction. Extracted so the gen5 HELLO timestamp and a + /// GET_CLOCK reply reach IDENTICAL logic — the official gen5 path takes its + /// clock from hello and never sends GET_CLOCK, so without this the two + /// sources would drift apart in behaviour. + void _absorbClockEpoch(int dev) { + final wall = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // Assess phone-clock trust from the RAW read, before the alarm-safety gate + // below diverts a future reading. A plausible strap RTC that reads > 1 day + // ahead of the phone means the phone clock is likely slow — history offload + // then DEFERS (see _startHistoricalRefresh) instead of dropping the strap's + // real records as "future" and trimming them off the band. Cleared the + // moment a read agrees (the phone almost always self-corrects via NTP). + final wasSuspect = _phoneClockSuspect; + _phoneClockSuspect = ClockPolicy.phoneClockSuspect(dev, wall); + if (_phoneClockSuspect && !wasSuspect) { + _phoneClockSuspectSince = _monotonicSecs(); + } else if (!_phoneClockSuspect) { + _phoneClockSuspectSince = null; + } + // The read gate is released above, on the reply itself, not here. + // + // UNCORRELATED either way: any GET_CLOCK reply releases the waiter, + // including one answering setClock()'s read-back or the keep-alive poll. + // Telling them apart needs the echoed request seq, which the pinned + // protocol does not surface — see the pin note in pubspec.yaml and + // OpenStrap/protocol#28. The reply that lands is still a real strap read + // from this session, so the verdict is fresh; it may just answer a + // request a few hundred ms older than ours. + if (_phoneClockSuspect != wasSuspect) { + _log(_phoneClockSuspect + ? '[SYNC] Phone clock appears wrong: strap RTC=$dev is > 1 day ahead ' + 'of phone wall=$wall — DEFERRING history offload until they agree.' + : '[SYNC] Phone/strap clocks agree again (strap=$dev wall=$wall) — ' + 'history offload may resume.'); + } + // SANITY GATE, mirroring the one `range_newest` gets below. An + // implausibly far-future `clock_epoch` yields a large NEGATIVE driftSec, + // and setAlarm arms at `when - driftSec` — years out, where the alarm + // silently never fires — while the bounded SET_CLOCK retry budget is + // spent chasing a value that was never real. Reject the read: with no + // correlation the alarm falls back to the raw wall epoch. connect() + // already issues an unconditional SET_CLOCK, and the periodic re-verify + // re-reads, so a genuinely-wrong RTC still gets corrected. + if (dev < kMinPlausibleUnix) { + // UNSET RTC. This read is now surfaced instead of swallowed by the + // decoder (see [_maybeAugmentClockEpoch]) so the SET_CLOCK correction + // below can finally fire for it — but it must NOT become a ClockRef: + // correlating a factory-epoch clock yields a drift of decades, and + // `AlarmPayloads.toStrapFrame` would arm every alarm that far in the + // past. + _log( + '[SYNC] GET_CLOCK clock_epoch=$dev is below the plausible floor — ' + 'the strap RTC was never set. NOT correlating; SET_CLOCK below is ' + 'the fix.', + ); + } else if (!ClockPolicy.acceptsClockRead(dev, wall)) { + _corruptClockReadCount++; + _log( + '[SYNC] GET_CLOCK clock_epoch=$dev is implausibly far in the future ' + '— treating as a corrupt strap RTC read; NOT correlating the strap ' + 'clock (alarms fall back to the raw wall epoch) ' + '(corrupt_clock_reads_total=$_corruptClockReadCount).', + ); + } else { + _clockRef = ClockRef(device: dev, wall: wall); + _log('Clock correlated: device=$dev wall=$wall (drift=${wall - dev}s).'); + } + // CORRECTION RUNS ON THE RAW READ, outside the correlation gate above. + // + // It used to be nested inside the accepted-read branch, which quietly + // made a fast strap RTC unfixable: `acceptsClockRead` rejects anything + // past `wall + kFutureMargin` and `phoneClockSuspect` trips past that + // SAME margin, so the one reading that means "the strap clock is ahead" + // could never reach the one code path that fixes it. History would + // un-defer at grace expiry — having concluded the STRAP is the fast one — + // straight back onto an uncorrected fast RTC, where the record gate + // rejects every future-stamped record and the offload can never bank + // anything. + // + // Rejecting the read for CORRELATION is still right (a junk value would + // arm alarms years out). Rejecting it for CORRECTION never was: SET_CLOCK + // writes real wall time, which is the correct outcome whether the read + // was junk or the RTC is genuinely ahead, and the retry budget is bounded + // at 3 either way. + if (ClockPolicy.shouldSetClock(dev, wall)) { + if (_deferForClock) { + // While the phone is still the suspect party, writing our wall clock + // onto a strap that may well be RIGHT corrupts a correct RTC and + // destroys the evidence — the read-back then "agrees" forever. Hold + // off until the phone corrects (gate clears) or the grace expires + // (the strap is the fast one, and the branch below fixes it). + _log( + 'Clock drift over policy but the PHONE clock is the suspect one ' + '(strap=$dev wall=$wall) — NOT writing SET_CLOCK yet.', + ); + } else if (_clockCorrectTries < 3) { + // BOUND the retries: setClock() reads the clock back and this handler + // re-issues on drift, so an unbounded loop would spin + // SET_CLOCK/GET_CLOCK forever on firmware that never latches. + // Historical records carry their own embedded unix time regardless, + // so giving up after a few tries is safe. + _clockCorrectTries++; + _log( + 'Clock drift over policy — re-issuing SET_CLOCK ' + '(attempt $_clockCorrectTries/3).', + ); + unawaited(setClock()); + } else { + _log( + 'Clock still off after 3 SET_CLOCK attempts — giving up; ' + 'firmware may not accept our payload length.', + ); + } + } else { + _clockCorrectTries = 0; // latched — reset for the next drift episode + } } + /// GET_CLOCK, awaited to the *response* rather than to the write. /// /// Both clock gates (the connect-path SET_CLOCK decision and the history @@ -3837,25 +4194,148 @@ class BleEngine { /// never see is a strap we never SET_CLOCK (it ships RTC-unset) and never /// sync. Callers proceed on the last known verdict; the log line is the /// signal that the read never landed. - Future _readClock() async { - final pending = _clockReadPending = Completer(); - await getClock(); // band-correct opcode + body; gen4 sent to a gen5 strap is silence - try { - await pending.future.timeout(_clockReadTimeout); - return true; - } on TimeoutException { - _log( - '[SYNC] GET_CLOCK went unanswered for ${_clockReadTimeout.inSeconds}s ' - '— clock verdict is UNVERIFIED for this read; proceeding on the last ' - 'known state (phone_clock_suspect=$_phoneClockSuspect).', - ); + /// Send the gen5 `GET_HELLO(0x91)` and wait for its reply. + /// + /// This runs BEFORE any clock work, which is the official order (doc 01): + /// hello carries the strap's own timestamp, identity, battery, charge and + /// on-body state, and the official client feeds that timestamp straight into + /// the clock decision rather than spending a GET_CLOCK round trip. Sending it + /// late — as this app used to, inside INIT — meant the clock had already been + /// read and written by then, so hello's timestamp could never be used and its + /// identity fields arrived after everything that wanted them. + /// + /// Returns whether a reply landed. A timeout is NOT fatal: the caller falls + /// back to the GET_CLOCK path, which is exactly what the official client does + /// when hello supplies no timestamp. + /// Correlated through the [CommandAwaiter]: the reply must echo THIS hello's + /// sequence and opcode 145. GET_HELLO is also one of the two commands whose + /// `PENDING` is not terminal (doc 02), so a deferred reply keeps the await + /// open for the real result instead of reporting the strap as answered. + Future _readGen5Hello() async { + final out = await _sendAwaited( + Cmd.getHello, + const [0x01], + timeout: _helloTimeout, + frameBuilder: (seq) => gen5ClientHello(seq: seq), + ); + if (!out.written) { + _log('[HELLO gen5] write failed — falling back to the clock read.'); + await _noteHelloFailure('write failed'); return false; - } finally { - if (identical(_clockReadPending, pending)) _clockReadPending = null; + } + final resp = await out.response; + if (resp == null) { + _log('[HELLO gen5] no reply in ${_helloTimeout.inSeconds}s — falling ' + 'back to GET_CLOCK for the clock decision.'); + await _noteHelloFailure('no reply'); + return false; + } + // A non-success result leaves the body unpopulated, and an unparseable + // body leaves `_gen5Hello` null — either way there is no identity, no + // timestamp and nothing for the clock decision, which is doc 01's + // "missing or failed hello". + final hello = _gen5Hello; + if (!resp.success || hello == null) { + _log('[HELLO gen5] reply status=${resp.status} ' + 'body=${hello == null ? 'unparsed' : 'parsed'} — treating as a ' + 'failed hello.'); + await _noteHelloFailure('status=${resp.status}'); + return false; + } + _noteHelloSuccess(hello); + return true; + } + + /// Matches the official 5-second command timeout (doc 02). + static const Duration _helloTimeout = Duration(seconds: 5); + + /// doc 01 §"What gates READY" (identity half) — recorded and logged, never a + /// disconnect. See [HelloIdentity] for why this stays observable. + void _noteHelloSuccess(Gen5HelloInfo h) { + _helloFailures = 0; + final id = HelloIdentity.evaluate( + serial: h.serial, + cpuHex: h.cpuHex, + eepromFailureSignal: h.serialLooksEepromFailure, + ); + _helloIdentity = id; + if (!id.ok) { + _log('[HELLO gen5] identity gate FAILED ($id) — the official client ' + 'requires serial and CPU to be alphanumeric; logged, not enforced.'); + } + if (id.eepromFailureSignal) { + _log('[HELLO gen5] serial is all zeros — the strap is reporting an ' + 'EEPROM failure. Not a reject (doc 01); the band stays usable.'); } } - /// How long [_readClock] waits for `clock_epoch`. A connected-link round trip + /// doc 01 §"Hello failure handling": record the failure, and at the fifth + /// one reset the counter and remove the platform bond before starting over. + Future _noteHelloFailure(String why) async { + _helloFailures++; + _log('[HELLO gen5] failure $_helloFailures/$kHelloFailuresBeforeBondReset ' + '($why) — counted across reconnect attempts.'); + if (_helloFailures < kHelloFailuresBeforeBondReset) return; + _helloFailures = 0; + await _removePlatformBond(); + } + + /// Drop the OS-level bond so the next attempt re-pairs from scratch. + /// + /// Android only: iOS gives no API for removing a pairing, so there the user + /// has to forget the device in Settings — say so in the log rather than + /// pretending the reset happened. + Future _removePlatformBond() async { + final device = _session?.device; + if (!Platform.isAndroid) { + _log('[HELLO gen5] $kHelloFailuresBeforeBondReset failed hellos — a bond ' + 'reset is due, but this platform cannot remove a bond ' + 'programmatically; the user must forget the device manually.'); + return; + } + if (device == null) { + _log('[HELLO gen5] bond reset due but there is no device to unbond.'); + return; + } + try { + await device.removeBond(); + _log('[HELLO gen5] $kHelloFailuresBeforeBondReset failed hellos — ' + 'platform bond removed; the next attempt re-pairs.'); + } catch (e) { + _log('[HELLO gen5] bond removal failed: $e'); + } + } + + Future _readClock() async { + // Correlated on (sequence, opcode 11): the periodic RTC re-verify and the + // read-back inside setClock() both put GET_CLOCK replies on this link, and + // before correlation any of them could release this gate — including one + // belonging to the PREVIOUS request. + // + // The 3 s ceiling is kept rather than doc 02's generic 5 s: this read sits + // in the connect path and in the drain gate, and its timeout is a + // proceed-on-the-last-verdict fallback, not a failure. + final out = await _sendAwaited( + Cmd.getClock, // band-correct opcode + body; gen4 sent to gen5 is silence + const [], + timeout: _clockReadTimeout, + ); + if (await out.response != null) return true; + _log( + out.written + ? '[SYNC] GET_CLOCK went unanswered for ' + '${_clockReadTimeout.inSeconds}s — clock verdict is UNVERIFIED ' + 'for this read; proceeding on the last known state ' + '(phone_clock_suspect=$_phoneClockSuspect).' + : '[SYNC] GET_CLOCK was never written — clock verdict is UNVERIFIED ' + 'for this read; proceeding on the last known state ' + '(phone_clock_suspect=$_phoneClockSuspect).', + ); + return false; + } + + /// How long [_readClock] waits for its correlated reply. A connected-link + /// round trip /// is tens of milliseconds; this is sized to survive a burst of historical /// frames queued ahead of the response, not to be a plausible steady state. static const Duration _clockReadTimeout = Duration(seconds: 3); @@ -3958,6 +4438,32 @@ class BleEngine { await _send(Cmd.runAlarm, AlarmPayloads.runNow); } + /// Fire the STORED alarm now — the real wake, not a test pulse. + /// + /// This is a different thing from [runAlarm]. That one plays a short buzz so + /// the user can feel that the strap works; this one tells the firmware to + /// enter its ALARM state for the alarm already programmed in [slot], which is + /// the full stored waveform with its loop count, 30 s cap and 50%→100% + /// strength progression, terminated by timeout, error or a user double-tap. + /// A short test pulse does not wake a sleeping person; this does. + /// + /// It is also the ONLY band command needed to wake someone early: the band + /// holds an absolute deadline and can be told to run it ahead of time + /// (reversing-whoop doc 14 "Run alarm now — opcode 68" / "Wake in green"). + /// + /// Body per that doc: revision 2 plus the alarm ID, i.e. `02 01` for the ID 1 + /// the official client uses. NOTE the existing gen5 note on [runAlarm] — that + /// "RUN_ALARM does not buzz" on gen5 — was very likely observed with the + /// gen4 revision-1 body `[0x01]`, which protocol documents as doing nothing + /// on gen5. The rev-2 form has not been re-tested on hardware yet, so callers + /// must treat a wake driven by this as unconfirmed until it has (tracked in + /// reversing-whoop doc 15 G6). + Future runStoredAlarm({int? slot}) async { + final band = _session?.band ?? BandProfile.gen4; + final id = slot ?? (band.isGen5 ? AlarmPayloads.gen5Slot : null); + return _write(cmdRunAlarm(_seq.nextLive(), mode: id, profile: band)); + } + /// Cancel the on-device alarm (DISABLE_ALARM = 0x45). gen4 body `[0x01]` /// (the earlier `[0x00]` body was ACKed but did not clear the alarm); gen5 /// needs revision 2 plus the alarm id, defaulting to "all slots" — see @@ -4195,6 +4701,10 @@ class BleEngine { // skips the clear in sendInit's finally, which would leave the target // pinned at `high` for the life of the process. _connectSetup = false; + // Nothing outstanding can be answered by a link that is going away, and a + // 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(); _linkGeneration++; _drain?.onLinkDown(); _drain = null; @@ -4548,6 +5058,7 @@ class DrainController { expectedPacketCount: expectedPacketCount, actualBurstPacketCount: currentBurstPacketCount, droppedThisBurst: droppedThisBurst, + consecutiveFailedValidations: consecutiveValidationFailures, )) { consecutiveValidationFailures = 0; return true; diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 54cc99c8..9c58b9ae 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -8,6 +8,7 @@ // Keeping this layer pure makes the race-prone transitions unit-testable // without a real WHOOP band. +import 'dart:async'; import 'dart:math'; import '../sync/sync_policy.dart' show isPlausibleUnix; @@ -934,3 +935,363 @@ class AlarmConfirmation { } } } + +/// What a [ConditionalWakePolicy] tick wants the caller to do. +enum ConditionalWakeAction { + /// Nothing to do — outside the window, or already handled. + none, + + /// Open the wake window: ask the strap for more frequent sync prompts. + openWindow, + + /// Close it again (window passed, alarm cleared, or feature turned off). + closeWindow, + + /// The condition is met — fire the STORED alarm NOW, once. + fireNow, +} + +/// The "wake me when I'm recovered, but no later than X" decision, as a pure +/// function of time and inputs. The engine/app owns the I/O; this owns the +/// rules. +/// +/// The band does NOT decide this. It holds one absolute deadline and can be +/// told to run that alarm early — that is the whole mechanism (reversing-whoop +/// doc 14 "The implementation boundary" / "Wake in green"). WHOOP's own client +/// asks its server whether the condition is met; an on-device app that already +/// computes recovery locally can answer the same question itself, with no +/// network at all — and unlike the official flow, it still works offline. +/// +/// Two properties matter more than cleverness here, because the failure mode is +/// waking a person at the wrong time: +/// +/// * **The deadline is the safety net.** The stored alarm is programmed first +/// and left armed. Everything below only ever moves the wake EARLIER, inside +/// the window. If this policy never fires — app killed, band out of range, +/// condition never met — the strap still wakes them from its own RTC. +/// * **Fire exactly once.** [fired] latches, so a second qualifying tick (or a +/// replayed/duplicated input) cannot wake someone twice. The caller must +/// persist the latch before doing anything retryable, per the same doc. +class ConditionalWakePolicy { + /// How long before the deadline the window opens. The official client uses + /// two hours, and the high-frequency sync duration (7200 s) matches it. + static const Duration window = Duration(hours: 2); + + /// Latched once the early wake has been sent, so it can never repeat. + bool fired = false; + + /// True while the strap has been asked for frequent prompts. + bool windowOpen = false; + + /// The deadline this policy is currently tracking, so a rescheduled alarm + /// resets the latch instead of inheriting the previous night's. + int? trackedDeadlineEpoch; + + /// Decide what to do at [nowEpoch]. + /// + /// [deadlineEpoch] is the armed stored alarm (null = no alarm). [conditionMet] + /// is the caller's own answer to "is the user recovered?" — deliberately a + /// plain bool, because this class must not know or care how that was computed. + /// [enabled] is the user's opt-in. + ConditionalWakeAction tick({ + required int nowEpoch, + required int? deadlineEpoch, + required bool conditionMet, + required bool enabled, + }) { + // A new/changed/cleared deadline is a new night: forget the old latch. + if (deadlineEpoch != trackedDeadlineEpoch) { + trackedDeadlineEpoch = deadlineEpoch; + fired = false; + } + if (!enabled || deadlineEpoch == null) { + return _close(); + } + // Past the deadline the strap's own RTC owns the wake; nothing to add. + if (nowEpoch >= deadlineEpoch) return _close(); + + final opensAt = deadlineEpoch - window.inSeconds; + if (nowEpoch < opensAt) return _close(); + + // Inside the window. + if (conditionMet && !fired) { + fired = true; + // Leave the window open: the caller still wants the strap reachable, and + // closing it is a separate decision once the wake is acknowledged. + return ConditionalWakeAction.fireNow; + } + if (!windowOpen) { + windowOpen = true; + return ConditionalWakeAction.openWindow; + } + return ConditionalWakeAction.none; + } + + ConditionalWakeAction _close() { + if (!windowOpen) return ConditionalWakeAction.none; + windowOpen = false; + return ConditionalWakeAction.closeWindow; + } + + /// Restore the fire-once latch from storage (call before the first [tick] of + /// a process, so a restart cannot re-wake the user). + void restore({required int? deadlineEpoch, required bool alreadyFired}) { + trackedDeadlineEpoch = deadlineEpoch; + fired = alreadyFired; + } +} + +// ── command/response correlation (doc 02) ──────────────────────────────────── + +/// A command response that was matched to a request we actually made. +/// +/// Wire layout (doc 02 "Command response"): +/// `[36][response seq][echoed opcode][originating seq][result][body…]`. +class CorrelatedResponse { + /// The echoed opcode — equal to the opcode of the request by construction. + final int opcode; + + /// The sequence WE allocated for the request (not necessarily the byte on + /// the wire: see [viaSeqZeroFallback]). + final int seq; + + /// `result`: 0 FAILURE, 1 SUCCESS, 2 PENDING, 3 UNSUPPORTED. `-1` when the + /// response was too short to carry one. + final int status; + + /// The decoded response field map (whatever the protocol decoder produced). + final Map fields; + + /// True when this reply carried originating sequence 0 and was matched by + /// opcode alone — the doc-02 compatibility path. + final bool viaSeqZeroFallback; + + const CorrelatedResponse({ + required this.opcode, + required this.seq, + required this.status, + this.fields = const {}, + this.viaSeqZeroFallback = false, + }); + + bool get success => status == CommandAwaiter.statusSuccess; + bool get failed => status == CommandAwaiter.statusFailure; + bool get unsupported => status == CommandAwaiter.statusUnsupported; +} + +/// What [CommandAwaiter.deliver] did with a response. +enum CommandDelivery { + /// It satisfied a pending request, which is now complete. + completed, + + /// It matched a pending request whose PENDING is non-terminal (doc 02's + /// per-command table) — the await stays open for the terminal result. + pendingHeld, + + /// Nothing was waiting for it, or it failed the match rules (wrong opcode + /// for that sequence, or an ambiguous sequence-zero fallback). + unmatched, +} + +/// One outstanding command transaction. Created by [CommandAwaiter.register] +/// BEFORE the write goes out (doc 02 "Ordering"). +class PendingCommand { + final int seq; + final int opcode; + final Duration timeout; + final CommandAwaiter _owner; + final Completer _completer = + Completer(); + + PendingCommand._(this._owner, this.seq, this.opcode, this.timeout); + + /// The correlated reply, or null once [timeout] expires. + /// + /// The timeout is applied EXACTLY ONCE and there is no automatic resend + /// (doc 02 "Timeouts and retries") — retry, disconnect and abort belong to + /// the calling state machine. Lazily built, so registering a command that is + /// never awaited never arms a timer. + late final Future response = _completer.future.timeout( + timeout, + onTimeout: () { + _owner._forget(this); + return null; + }, + ); + + bool get isCompleted => _completer.isCompleted; + + /// Give up without waiting out the timeout — the write never went out, or + /// the link died under it. + void cancel() { + _owner._forget(this); + if (!_completer.isCompleted) _completer.complete(null); + } + + void _complete(CorrelatedResponse r) { + _owner._forget(this); + if (!_completer.isCompleted) _completer.complete(r); + } +} + +/// The registry that turns fire-and-forget writes into real request/response +/// transactions (doc 02 "Sequence allocation and response correlation"). +/// +/// Match rule — a response is accepted only when **both** fields agree: +/// ```text +/// response.originating_sequence == request.sequence +/// response.echoed_opcode == request.opcode +/// ``` +/// A sequence match with the wrong opcode is NOT a success: it is rejected and +/// the surrounding await is left to time out. That is the whole point — the +/// old ad-hoc completers in the engine keyed off "a reply of roughly the right +/// shape arrived", so an unrelated command's answer could satisfy a read the +/// app then acted on. +/// +/// This class is deliberately transport-free: the engine allocates the +/// sequence, frames and writes; this only says which reply belongs to which +/// request. +class CommandAwaiter { + /// doc 02 "Timeouts and retries" — the generic command await. + static const Duration defaultTimeout = Duration(milliseconds: 5000); + + static const int statusFailure = 0; + static const int statusSuccess = 1; + static const int statusPending = 2; + static const int statusUnsupported = 3; + + /// The only commands whose `PENDING` is NON-terminal (doc 02 "`PENDING` is + /// per-command"): GET_HELLO(145) and GET_DATA_RANGE(34) keep waiting for a + /// terminal failure/success/unsupported. Every other command completes on + /// the first matching response, PENDING included. + static const Set pendingIsNonTerminal = {0x91, 0x22}; + + /// Whether to honour doc 02's optional "Sequence-zero compatibility path": + /// a response whose originating sequence is 0 may match a nonzero request by + /// opcode. The doc's own caveat is that two outstanding requests with the + /// same opcode then become ambiguous — so a fallback match is only taken + /// when EXACTLY ONE pending request carries that opcode, and refused + /// otherwise rather than guessing. + final bool seqZeroFallback; + + CommandAwaiter({this.seqZeroFallback = true}); + + final List _pending = []; + + int get pendingCount => _pending.length; + + /// The (seq, opcode) pairs currently outstanding — diagnostics/tests. + List get pendingKeys => + _pending.map((p) => '${p.seq}/${p.opcode}').toList(growable: false); + + bool hasPendingOpcode(int opcode) => _pending.any((p) => p.opcode == opcode); + + bool hasPendingSeq(int seq) => _pending.any((p) => p.seq == seq); + + /// Install an observer for a command about to be written. Call this BEFORE + /// the write (doc 02 "Ordering") so a fast response cannot arrive before its + /// observer exists. + PendingCommand register( + int seq, + int opcode, { + Duration timeout = defaultTimeout, + }) { + final p = PendingCommand._(this, seq, opcode, timeout); + _pending.add(p); + return p; + } + + /// Offer a decoded command response to the registry. + CommandDelivery deliver({ + required int? opcode, + required int? reqSeq, + int? status, + Map fields = const {}, + }) { + // Without an echoed opcode or an originating sequence there is nothing to + // correlate on, so nothing may be satisfied. + if (opcode == null || reqSeq == null) return CommandDelivery.unmatched; + PendingCommand? match; + var viaFallback = false; + for (final p in _pending) { + if (p.seq == reqSeq && p.opcode == opcode) { + match = p; + break; + } + } + if (match == null && seqZeroFallback && reqSeq == 0) { + final sameOpcode = _pending.where((p) => p.opcode == opcode).toList(); + if (sameOpcode.length != 1) return CommandDelivery.unmatched; + match = sameOpcode.single; + viaFallback = true; + } + if (match == null) return CommandDelivery.unmatched; + final result = status ?? -1; + if (result == statusPending && pendingIsNonTerminal.contains(opcode)) { + return CommandDelivery.pendingHeld; + } + match._complete(CorrelatedResponse( + opcode: opcode, + seq: match.seq, + status: result, + fields: fields, + viaSeqZeroFallback: viaFallback, + )); + return CommandDelivery.completed; + } + + /// Abandon every outstanding command (the link went down). Each await + /// resolves null immediately instead of holding its caller for the full + /// timeout on a connection that no longer exists. + void failAll() { + for (final p in List.of(_pending)) { + p.cancel(); + } + _pending.clear(); + } + + void _forget(PendingCommand p) => _pending.remove(p); +} + +/// The identity half of doc 01 §"What gates READY", as an OBSERVATION. +/// +/// The official client requires the serial and CPU strings to match +/// `[a-zA-Z0-9]+` before it calls a connection ready. This app records the +/// verdict and logs it rather than dropping the link: a hard disconnect on an +/// identity read we have far less hardware evidence for would turn a cosmetic +/// mismatch into an unreachable band, and the CPU string is lowercase hex by +/// construction so it can only fail if it is empty. +/// +/// An all-zero serial is an EEPROM-failure signal, NOT a rejection — it passes +/// the alphanumeric gate, and the doc says so explicitly. +class HelloIdentity { + static final RegExp alphanumeric = RegExp(r'^[a-zA-Z0-9]+$'); + + final bool serialOk; + final bool cpuOk; + final bool eepromFailureSignal; + + const HelloIdentity({ + required this.serialOk, + required this.cpuOk, + required this.eepromFailureSignal, + }); + + bool get ok => serialOk && cpuOk; + + static HelloIdentity evaluate({ + required String serial, + required String cpuHex, + bool eepromFailureSignal = false, + }) => + HelloIdentity( + serialOk: alphanumeric.hasMatch(serial), + cpuOk: alphanumeric.hasMatch(cpuHex), + eepromFailureSignal: eepromFailureSignal, + ); + + @override + String toString() => 'serial=${serialOk ? 'ok' : 'BAD'} ' + 'cpu=${cpuOk ? 'ok' : 'BAD'}' + '${eepromFailureSignal ? ' serial=all-zero(EEPROM)' : ''}'; +} diff --git a/test/alarm_test.dart b/test/alarm_test.dart index b5acee9a..724304d5 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -233,4 +233,90 @@ void main() { expect(a.targetEpoch, 1750000000); }); }); + + group('ConditionalWakePolicy — wake early, never later', () { + const deadline = 1750000000; // the armed stored alarm + const opensAt = deadline - 2 * 3600; // official 2-hour window + + ConditionalWakeAction run( + ConditionalWakePolicy p, { + required int now, + bool met = false, + bool enabled = true, + int? dl = deadline, + }) => + p.tick( + nowEpoch: now, + deadlineEpoch: dl, + conditionMet: met, + enabled: enabled, + ); + + test('does nothing before the window, opens it on entry', () { + final p = ConditionalWakePolicy(); + expect(run(p, now: opensAt - 60, met: true), ConditionalWakeAction.none, + reason: 'a met condition before the window must NOT wake anyone'); + expect(run(p, now: opensAt + 1), ConditionalWakeAction.openWindow); + expect(run(p, now: opensAt + 2), ConditionalWakeAction.none, + reason: 'window already open — no repeat command'); + }); + + test('fires once, and only once, when the condition is met inside it', () { + final p = ConditionalWakePolicy(); + run(p, now: opensAt + 1); // opens + expect(run(p, now: opensAt + 600, met: true), + ConditionalWakeAction.fireNow); + // Every later tick — including more met ticks — must stay silent. + expect( + run(p, now: opensAt + 601, met: true), ConditionalWakeAction.none); + expect( + run(p, now: opensAt + 900, met: true), ConditionalWakeAction.none); + expect(p.fired, isTrue); + }); + + test('past the deadline the strap RTC owns the wake — never a late fire', + () { + final p = ConditionalWakePolicy(); + run(p, now: opensAt + 1); + // The single most important negative: this policy may only move a wake + // EARLIER. After the deadline it must go quiet and let the band fire. + expect(run(p, now: deadline, met: true), ConditionalWakeAction.closeWindow); + expect(run(p, now: deadline + 60, met: true), ConditionalWakeAction.none); + expect(p.fired, isFalse); + }); + + test('opt-out and a cleared alarm both close the window', () { + final p = ConditionalWakePolicy(); + run(p, now: opensAt + 1); + expect(run(p, now: opensAt + 2, enabled: false), + ConditionalWakeAction.closeWindow); + + final q = ConditionalWakePolicy(); + run(q, now: opensAt + 1); + expect(run(q, now: opensAt + 2, dl: null), + ConditionalWakeAction.closeWindow); + }); + + test('a rescheduled alarm is a new night: the latch resets', () { + final p = ConditionalWakePolicy(); + run(p, now: opensAt + 1); + expect(run(p, now: opensAt + 60, met: true), ConditionalWakeAction.fireNow); + + const tomorrow = deadline + 86400; + // Same policy object, new deadline — the previous night's latch must not + // suppress tomorrow's early wake. + expect( + run(p, now: tomorrow - 3600, met: true, dl: tomorrow), + ConditionalWakeAction.fireNow, + ); + }); + + test('a restored latch survives a restart and cannot re-wake', () { + final p = ConditionalWakePolicy() + ..restore(deadlineEpoch: deadline, alreadyFired: true); + expect(run(p, now: opensAt + 600, met: true), ConditionalWakeAction.openWindow, + reason: 'window may reopen, but the wake must not repeat'); + expect(run(p, now: opensAt + 700, met: true), ConditionalWakeAction.none); + }); + }); } diff --git a/test/ble_clock_gate_test.dart b/test/ble_clock_gate_test.dart index 440c6aaa..25f5a458 100644 --- a/test/ble_clock_gate_test.dart +++ b/test/ble_clock_gate_test.dart @@ -131,11 +131,17 @@ void main() { /// see. void _transportTests() { /// Opcode of an outgoing command frame: inner starts at byte 4 and is - /// `[pktType, seq, opcode, ...]`, so the opcode is at 6. + /// `[pktType, seq, opcode, ...]`, so the opcode is at 6 and the sequence at 5. int opcodeOf(Uint8List frame) => frame[6]; + int seqOf(Uint8List frame) => frame[5]; - Decoded clockReply(int strapEpoch) => Decoded('cmd_response', { + /// A GET_CLOCK reply CORRELATED to the request that asked for it: the read + /// only accepts a reply echoing both its sequence and its opcode (doc 02), so + /// a test reply without the sequence proves nothing about the gate. + Decoded clockReply(int strapEpoch, int reqSeq) => Decoded('cmd_response', { 'opcode': Cmd.getClock, + 'req_seq': reqSeq, + 'cmd_status': 1, 'clock_epoch': strapEpoch, }); @@ -147,6 +153,7 @@ void _transportTests() { 'the drain', () async { final sent = []; + var clockSeq = 0; final clockAsked = Completer(); final engine = BleEngine( onRecord: (sample, raw) async {}, @@ -155,6 +162,7 @@ void _transportTests() { engine.debugInstallFakeLink(onWrite: (frame) async { sent.add(opcodeOf(frame)); if (opcodeOf(frame) == Cmd.getClock && !clockAsked.isCompleted) { + clockSeq = seqOf(frame); clockAsked.complete(); } return true; @@ -177,7 +185,7 @@ void _transportTests() { 'false, and history drained before the strap had answered', ); - engine.debugAbsorbDecoded(clockReply(wallNow() + 2 * 86400)); + engine.debugAbsorbDecoded(clockReply(wallNow() + 2 * 86400, clockSeq)); expect(await refresh, isFalse); expect(sent, isNot(contains(Cmd.sendHistoricalData)), @@ -188,11 +196,13 @@ void _transportTests() { test('a healthy clock reply lets the drain through', () async { final sent = []; + var clockSeq = 0; final clockAsked = Completer(); final engine = BleEngine(onRecord: (s, r) async {}, onState: (_) {}); engine.debugInstallFakeLink(onWrite: (frame) async { sent.add(opcodeOf(frame)); if (opcodeOf(frame) == Cmd.getClock && !clockAsked.isCompleted) { + clockSeq = seqOf(frame); clockAsked.complete(); } return true; @@ -200,7 +210,7 @@ void _transportTests() { final refresh = engine.debugStartHistoricalRefresh(); await clockAsked.future; - engine.debugAbsorbDecoded(clockReply(wallNow())); + engine.debugAbsorbDecoded(clockReply(wallNow(), clockSeq)); expect(await refresh, isTrue); expect(sent, contains(Cmd.sendHistoricalData)); @@ -208,11 +218,15 @@ void _transportTests() { test('a failed SEND_HISTORICAL_DATA write is reported as not sent', () async { + var clockSeq = 0; final clockAsked = Completer(); final engine = BleEngine(onRecord: (s, r) async {}, onState: (_) {}); engine.debugInstallFakeLink(onWrite: (frame) async { if (opcodeOf(frame) == Cmd.getClock) { - if (!clockAsked.isCompleted) clockAsked.complete(); + if (!clockAsked.isCompleted) { + clockSeq = seqOf(frame); + clockAsked.complete(); + } return true; } // The radio drops exactly the command that matters. @@ -221,7 +235,7 @@ void _transportTests() { final refresh = engine.debugStartHistoricalRefresh(); await clockAsked.future; - engine.debugAbsorbDecoded(clockReply(wallNow())); + engine.debugAbsorbDecoded(clockReply(wallNow(), clockSeq)); expect(await refresh, isFalse, reason: 'claiming success wedges _offloadActive on a strap that was ' diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 3234f63a..7a5e3c83 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -121,6 +121,56 @@ void main() { isFalse, ); }); + + // The official rule is one-sided with a failure-dependent slack, NOT + // equality (doc 05 "Collector and count gate"). + test('SURPLUS passes — there is no upper bound', () { + // The strap re-offers an unacknowledged burst and may re-deliver frames, + // so tallying more than expected is normal. Equality failed this. + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 53, + droppedThisBurst: 0, + ), + isTrue, + ); + }); + + test('slack is 0 for the first three attempts, then 2', () { + expect(burstCountSlack(0), 0); + expect(burstCountSlack(1), 0); + expect(burstCountSlack(2), 0); + expect(burstCountSlack(3), 2); + expect(burstCountSlack(14), 2); + + bool shortByTwo(int failures) => burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 48, + droppedThisBurst: 0, + consecutiveFailedValidations: failures, + ); + expect(shortByTwo(0), isFalse, reason: 'early attempts demand them all'); + expect(shortByTwo(3), isTrue, reason: 'from the 4th, 2 missing is ok'); + + // Slack never stretches to three. + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 47, + droppedThisBurst: 0, + consecutiveFailedValidations: 9, + ), + isFalse, + ); + }); + + test('the retry boundary is bounded, so a short burst cannot loop forever', + () { + // The whole reason a real gate is safe: attempts 1..14 fail and the strap + // re-offers; the 15th is terminal and aborts instead of re-requesting. + expect(kBurstValidationAttemptLimit, 15); + }); }); group('burst completeness shortfall (log-only would-flag signal)', () { @@ -204,7 +254,7 @@ void main() { ); }); - test('shortfall==0 is exactly burstPacketCountMatches', () { + test('a zero shortfall passes the count gate', () { const expected = 50, received = 26, dropped = 24; final matches = burstPacketCountMatches( expectedPacketCount: expected, @@ -216,7 +266,32 @@ void main() { receivedTrafficCount: received, droppedThisBurst: dropped, ); - expect(matches, (shortfall == 0)); + expect(shortfall, 0); + expect(matches, isTrue); + }); + + test('a NEGATIVE shortfall (surplus) also passes — they are not equivalent', + () { + // shortfall < 0 means we tallied more than the band reported, which is + // not loss. The gate is one-sided, so this passes even though the two + // values are not equal. + const expected = 50, received = 55, dropped = 0; + expect( + burstPacketShortfall( + expectedPacketCount: expected, + receivedTrafficCount: received, + droppedThisBurst: dropped, + ), + lessThan(0), + ); + expect( + burstPacketCountMatches( + expectedPacketCount: expected, + actualBurstPacketCount: received, + droppedThisBurst: dropped, + ), + isTrue, + ); }); }); diff --git a/test/command_correlation_test.dart b/test/command_correlation_test.dart new file mode 100644 index 00000000..7050845c --- /dev/null +++ b/test/command_correlation_test.dart @@ -0,0 +1,565 @@ +// Command/response correlation — doc 02 "Sequence allocation and response +// correlation", "Sequence-zero compatibility path", "Ordering", "`PENDING` is +// per-command" and "Timeouts and retries". +// +// What this stands in for: the engine used to await band replies with two +// ad-hoc one-shot completers (HELLO and GET_CLOCK) that fired on "a reply of +// roughly the right shape arrived". Any reply for that opcode — an earlier +// request's, a periodic poll's, a different command's answer landing on the +// same characteristic — released the gate, and the app then acted on it as if +// it were the answer to the question it had just asked. Correlation is what +// makes "the strap answered ME" a fact rather than an assumption. +// +// The pure half exercises the match rules; the wiring half drives the real +// engine over the debugWriteHook seam, where the ordering (observer installed +// BEFORE the write) is the thing that can only be checked end to end. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +const _fast = Duration(milliseconds: 40); + +/// A synthetic revision-1 gen5 hello body (doc 01 "Revision-1 hello body"), +/// parsed by the real protocol decoder so the identity fields under test are +/// the ones a strap would actually produce. +Uint8List _helloBody({String serial = 'W5AB12CD34', int tsSeconds = 0}) { + final body = Uint8List(Gen5HelloInfo.semanticBodyLen); + final v = ByteData.sublistView(body); + body[0] = 1; // hello revision + v.setUint32(1, 730, Endian.little); // 73.0% → 73 + v.setUint32(6, tsSeconds, Endian.little); + for (var i = 0; i < serial.length && 14 + i < 25; i++) { + body[14 + i] = serial.codeUnitAt(i); + } + v.setUint32(87, 82, Endian.little); // optical discriminator ⇒ WHOOP 5 + body[91] = 50; + body[92] = 40; + body[93] = 1; // firmware 50.40.1 + body[102] = 1; // on wrist + return body; +} + +Decoded _helloReply( + int seq, { + int status = CommandAwaiter.statusSuccess, + String serial = 'W5AB12CD34', + int tsSeconds = 0, +}) => + Decoded('cmd_response', { + 'opcode': Cmd.getHello, + 'req_seq': seq, + 'cmd_status': status, + if (status == CommandAwaiter.statusSuccess) + 'gen5_hello': + Gen5HelloInfo.parse(_helloBody(serial: serial, tsSeconds: tsSeconds))!, + }); + +/// A gen5 link with no radio behind it, plus the seq of every command written. +class _Link { + final logs = []; + final written = <({int seq, int opcode})>[]; + late final BleEngine engine; + + _Link({ + bool writesSucceed = true, + Decoded? Function(int seq, int opcode)? replyTo, + }) { + engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + ); + engine.debugInstallFakeLink( + band: BandProfile.gen5, + onWrite: (frame) async { + final inner = parseFrame(frame, profile: BandProfile.gen5)!.inner; + final seq = inner[1]; + final opcode = inner[2]; + written.add((seq: seq, opcode: opcode)); + if (!writesSucceed) return false; + // The reply is injected from INSIDE the write, i.e. before the write + // call has even returned to `_sendAwaited`. That is the ordering doc 02 + // demands: install the observer, then write. A registry built the other + // way round loses every fast response. + final reply = replyTo?.call(seq, opcode); + if (reply != null) engine.debugAbsorbDecoded(reply); + return true; + }, + ); + } + + int seqOf(int opcode) => + written.lastWhere((w) => w.opcode == opcode).seq; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(BleEngine.resetBandClaimForTest); + tearDown(BleEngine.resetBandClaimForTest); + + group('CommandAwaiter — both fields must match (doc 02)', () { + test('a reply echoing the sequence AND the opcode satisfies the await', + () async { + final a = CommandAwaiter(); + final p = a.register(0xA0, Cmd.getClock, timeout: _fast); + expect(a.pendingCount, 1); + + expect( + a.deliver(opcode: Cmd.getClock, reqSeq: 0xA0, status: 1, fields: const { + 'clock_epoch': 42, + }), + CommandDelivery.completed, + ); + + final r = await p.response; + expect(r, isNotNull); + expect(r!.opcode, Cmd.getClock); + expect(r.seq, 0xA0); + expect(r.success, isTrue); + expect(r.fields['clock_epoch'], 42); + expect(r.viaSeqZeroFallback, isFalse); + expect(a.pendingCount, 0, reason: 'a satisfied command is forgotten'); + }); + + test('a sequence match with the WRONG opcode is rejected and times out', + () async { + final a = CommandAwaiter(); + final p = a.register(0xA0, Cmd.getHello, timeout: _fast); + + expect( + a.deliver(opcode: Cmd.getClock, reqSeq: 0xA0, status: 1), + CommandDelivery.unmatched, + reason: 'doc 02: a sequence match by itself is insufficient', + ); + expect(a.pendingCount, 1, reason: 'the await must stay open'); + expect(await p.response, isNull, reason: 'and then expire'); + }); + + test('an opcode match with a sequence we never sent is rejected', () async { + final a = CommandAwaiter(); + final p = a.register(0xA0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 0xA1, status: 1), + CommandDelivery.unmatched); + expect(await p.response, isNull); + }); + + test('a reply carrying no correlation fields satisfies nothing', () async { + final a = CommandAwaiter(); + a.register(0xA0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: null, status: 1), + CommandDelivery.unmatched); + expect(a.deliver(opcode: null, reqSeq: 0xA0, status: 1), + CommandDelivery.unmatched); + expect(a.pendingCount, 1); + }); + + test('sequence zero is a valid sequence, matched exactly', () async { + final a = CommandAwaiter(); + final p = a.register(0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 0, status: 1), + CommandDelivery.completed); + final r = await p.response; + expect(r!.viaSeqZeroFallback, isFalse, + reason: 'this is an exact match, not the compatibility path'); + }); + }); + + group('CommandAwaiter — sequence-zero compatibility path (doc 02)', () { + test('an originating seq of 0 matches a nonzero request by opcode', + () async { + final a = CommandAwaiter(); + final p = a.register(0xA0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 0, status: 1), + CommandDelivery.completed); + final r = await p.response; + expect(r!.seq, 0xA0, reason: 'the request keeps its own sequence'); + expect(r.viaSeqZeroFallback, isTrue); + }); + + test('the opcode must still match', () async { + final a = CommandAwaiter(); + final p = a.register(0xA0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getHello, reqSeq: 0, status: 1), + CommandDelivery.unmatched); + expect(await p.response, isNull); + }); + + test('two outstanding requests for one opcode make it AMBIGUOUS — refuse', + () async { + // doc 02's own caveat: "if you implement this fallback, serialize command + // transactions, otherwise two outstanding requests with the same opcode + // become ambiguous". Guessing which one a seq-0 reply belongs to is how + // an old request's answer becomes the new request's result. + final a = CommandAwaiter(); + final first = a.register(0xA0, Cmd.getClock, timeout: _fast); + final second = a.register(0xA1, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 0, status: 1), + CommandDelivery.unmatched); + expect(a.pendingCount, 2); + expect(await first.response, isNull); + expect(await second.response, isNull); + }); + + test('the fallback can be switched off entirely', () async { + final a = CommandAwaiter(seqZeroFallback: false); + final p = a.register(0xA0, Cmd.getClock, timeout: _fast); + + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 0, status: 1), + CommandDelivery.unmatched); + expect(await p.response, isNull); + }); + }); + + group('CommandAwaiter — PENDING is per-command (doc 02)', () { + test('GET_HELLO(145) waits past PENDING for a terminal result', () async { + final a = CommandAwaiter(); + final p = a.register(7, Cmd.getHello, timeout: _fast); + + expect( + a.deliver( + opcode: Cmd.getHello, + reqSeq: 7, + status: CommandAwaiter.statusPending), + CommandDelivery.pendingHeld, + ); + expect(a.pendingCount, 1, reason: 'PENDING is not an answer here'); + + expect( + a.deliver( + opcode: Cmd.getHello, + reqSeq: 7, + status: CommandAwaiter.statusSuccess), + CommandDelivery.completed, + ); + expect((await p.response)!.success, isTrue); + }); + + test('GET_DATA_RANGE(34) waits past PENDING too, and FAILURE is terminal', + () async { + final a = CommandAwaiter(); + final p = a.register(9, Cmd.getDataRange, timeout: _fast); + + expect( + a.deliver( + opcode: Cmd.getDataRange, + reqSeq: 9, + status: CommandAwaiter.statusPending), + CommandDelivery.pendingHeld, + ); + expect( + a.deliver( + opcode: Cmd.getDataRange, + reqSeq: 9, + status: CommandAwaiter.statusFailure), + CommandDelivery.completed, + ); + final r = await p.response; + expect(r!.failed, isTrue); + expect(r.success, isFalse); + }); + + test('every other command completes on the FIRST matching response', + () async { + // SET_CLOCK(10), GET_ADVERTISING_NAME(141), 22, 23 and 20 all take the + // base policy. Only 145 and 34 are listed as waiting past PENDING. + for (final opcode in [ + Cmd.setClock, + Cmd.getCustomAdvertisingName, + Cmd.sendHistoricalData, + Cmd.historicalDataResult, + Cmd.abortHistoricalTransmits, + ]) { + final a = CommandAwaiter(); + final p = a.register(11, opcode, timeout: _fast); + expect( + a.deliver( + opcode: opcode, + reqSeq: 11, + status: CommandAwaiter.statusPending), + CommandDelivery.completed, + reason: 'opcode $opcode must not wait past PENDING', + ); + expect((await p.response)!.status, CommandAwaiter.statusPending); + } + expect(CommandAwaiter.pendingIsNonTerminal, {145, 34}); + }); + + test('UNSUPPORTED is terminal for everything', () async { + final a = CommandAwaiter(); + final p = a.register(3, Cmd.getHello, timeout: _fast); + expect( + a.deliver( + opcode: Cmd.getHello, + reqSeq: 3, + status: CommandAwaiter.statusUnsupported), + CommandDelivery.completed, + ); + expect((await p.response)!.unsupported, isTrue); + }); + }); + + group('CommandAwaiter — timeouts and lifetime (doc 02)', () { + test('the timeout is 5,000 ms, applied once, with no resend', () async { + expect(CommandAwaiter.defaultTimeout, const Duration(milliseconds: 5000)); + final a = CommandAwaiter(); + final p = a.register(1, Cmd.getClock, timeout: _fast); + + expect(await p.response, isNull); + expect(a.pendingCount, 0, reason: 'an expired command is forgotten'); + // Nothing here resends: a late reply for an expired request finds no + // waiter, which is the point — a duplicate write after a slow-but- + // successful response is a real hazard for state-mutating commands. + expect(a.deliver(opcode: Cmd.getClock, reqSeq: 1, status: 1), + CommandDelivery.unmatched); + expect(await p.response, isNull, reason: 'and it stays expired'); + }); + + test('a reply that lands before anyone awaits it is still captured', + () async { + // The ordering rule in registry form: the observer exists from `register` + // onwards, not from the first `await`. + final a = CommandAwaiter(); + final p = a.register(2, Cmd.getClock, timeout: _fast); + a.deliver(opcode: Cmd.getClock, reqSeq: 2, status: 1); + expect(await p.response, isNotNull); + }); + + test('cancel releases a command without waiting out its timeout', + () async { + final a = CommandAwaiter(); + final p = a.register(4, Cmd.getClock, timeout: const Duration(hours: 1)); + p.cancel(); + expect(await p.response, isNull); + expect(a.pendingCount, 0); + }); + + test('failAll drains the registry (the link went down)', () async { + final a = CommandAwaiter(); + final p1 = a.register(5, Cmd.getClock, timeout: const Duration(hours: 1)); + final p2 = a.register(6, Cmd.getHello, timeout: const Duration(hours: 1)); + a.failAll(); + expect(a.pendingCount, 0); + expect(await p1.response, isNull); + expect(await p2.response, isNull); + }); + }); + + group('HelloIdentity — doc 01 "What gates READY", observed not enforced', () { + test('alphanumeric serial and CPU pass', () { + final id = HelloIdentity.evaluate(serial: 'W5AB12CD34', cpuHex: 'abc123'); + expect(id.ok, isTrue); + expect(id.eepromFailureSignal, isFalse); + }); + + test('a serial with punctuation or spaces fails the gate', () { + expect(HelloIdentity.evaluate(serial: 'W5-AB', cpuHex: 'ab').serialOk, + isFalse); + expect(HelloIdentity.evaluate(serial: 'W5 AB', cpuHex: 'ab').serialOk, + isFalse); + expect( + HelloIdentity.evaluate(serial: '', cpuHex: 'ab').serialOk, isFalse, + reason: 'the regex is +, not *'); + }); + + test('an empty CPU string fails; hex is alphanumeric by construction', () { + expect(HelloIdentity.evaluate(serial: 'W5', cpuHex: '').cpuOk, isFalse); + expect(HelloIdentity.evaluate(serial: 'W5', cpuHex: '00ff').cpuOk, isTrue); + }); + + test('an all-zero serial is an EEPROM signal that still PASSES', () { + final id = HelloIdentity.evaluate( + serial: '00000000000', + cpuHex: 'ab', + eepromFailureSignal: true, + ); + expect(id.ok, isTrue, reason: 'doc 01: not a reject on its own'); + expect(id.eepromFailureSignal, isTrue); + }); + }); + + group('engine wiring — the hello await is correlated', () { + test('a reply injected DURING the write still finds its observer', + () async { + final link = _Link( + replyTo: (seq, opcode) => + opcode == Cmd.getHello ? _helloReply(seq) : null, + ); + + expect(await link.engine.debugReadGen5Hello(), isTrue); + expect(link.engine.pendingCommandCount, 0); + expect(link.engine.helloFailureCount, 0); + expect(link.engine.helloIdentity!.ok, isTrue); + }); + + test('a WRONG-OPCODE reply on the hello sequence does not satisfy it', + () async { + // The exact failure correlation exists to prevent: the strap answers a + // different command, the reply carries our sequence, and the old + // completer fired on it. + late final _Link link; + link = _Link( + replyTo: (seq, opcode) => opcode == Cmd.getHello + ? Decoded('cmd_response', { + 'opcode': Cmd.getClock, + 'req_seq': seq, + 'cmd_status': CommandAwaiter.statusSuccess, + }) + : null, + ); + + final hello = link.engine.debugReadGen5Hello(); + await pumpEventQueue(); + expect(link.engine.pendingCommandCount, 1, + reason: 'the hello await must still be open'); + expect(link.logs.any((l) => l.contains('matched no pending command')), + isTrue, + reason: 'a near miss is the symptom worth surfacing'); + + // The real answer, correlated, closes it. + link.engine.debugAbsorbDecoded(_helloReply(link.seqOf(Cmd.getHello))); + expect(await hello, isTrue); + expect(link.engine.pendingCommandCount, 0); + }); + + test('a PENDING hello keeps waiting for the terminal result', () async { + late final _Link link; + link = _Link( + replyTo: (seq, opcode) => opcode == Cmd.getHello + ? _helloReply(seq, status: CommandAwaiter.statusPending) + : null, + ); + + final hello = link.engine.debugReadGen5Hello(); + await pumpEventQueue(); + expect(link.engine.pendingCommandCount, 1, + reason: 'GET_HELLO(145) waits past PENDING (doc 02)'); + + link.engine.debugAbsorbDecoded(_helloReply(link.seqOf(Cmd.getHello))); + expect(await hello, isTrue); + }); + + test('the hello carries the sequence it was allocated', () async { + final link = _Link( + replyTo: (seq, opcode) => + opcode == Cmd.getHello ? _helloReply(seq) : null, + ); + await link.engine.debugReadGen5Hello(); + // Live commands come from the high range; the canonical hello frame's + // hard-coded seq 1 would collide with the INIT range. + expect(link.seqOf(Cmd.getHello), greaterThanOrEqualTo(SeqAllocator.liveFloor)); + }); + }); + + group('engine wiring — hello failures and the bond reset (doc 01)', () { + test('failures accumulate and the fifth resets the counter + the bond', + () async { + final link = _Link(writesSucceed: false); + + for (var i = 1; i <= 4; i++) { + expect(await link.engine.debugReadGen5Hello(), isFalse); + expect(link.engine.helloFailureCount, i, + reason: 'the count survives attempts, it is not per-connection'); + } + expect(await link.engine.debugReadGen5Hello(), isFalse); + expect(link.engine.helloFailureCount, 0, + reason: 'doc 01: at five, reset the counter'); + expect(link.logs.any((l) => l.contains('bond')), isTrue, + reason: 'and remove the platform bond before starting over'); + expect(BleEngine.kHelloFailuresBeforeBondReset, 5); + }); + + test('a non-success status counts as a failed hello', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == Cmd.getHello + ? _helloReply(seq, status: CommandAwaiter.statusFailure) + : null, + ); + + expect(await link.engine.debugReadGen5Hello(), isFalse); + expect(link.engine.helloFailureCount, 1); + }); + + test('a successful hello clears the accumulated failures', () async { + final failing = _Link(writesSucceed: false); + await failing.engine.debugReadGen5Hello(); + await failing.engine.debugReadGen5Hello(); + expect(failing.engine.helloFailureCount, 2); + + final link = _Link( + replyTo: (seq, opcode) => + opcode == Cmd.getHello ? _helloReply(seq) : null, + ); + await link.engine.debugReadGen5Hello(); + expect(link.engine.helloFailureCount, 0); + }); + }); + + group('engine wiring — identity is logged, never enforced (doc 01)', () { + test('a non-alphanumeric serial is flagged but the hello still succeeds', + () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == Cmd.getHello + ? _helloReply(seq, serial: 'W5-AB12') + : null, + ); + + expect(await link.engine.debugReadGen5Hello(), isTrue, + reason: 'a hard disconnect here would brick reconnects'); + expect(link.engine.helloIdentity!.ok, isFalse); + expect(link.logs.any((l) => l.contains('identity gate FAILED')), isTrue); + expect(link.engine.offloadSnapshot['hello_identity_ok'], isFalse); + }); + + test('an all-zero serial is reported as an EEPROM failure and passes', + () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == Cmd.getHello + ? _helloReply(seq, serial: '0000000000') + : null, + ); + + expect(await link.engine.debugReadGen5Hello(), isTrue); + expect(link.engine.helloIdentity!.ok, isTrue); + expect(link.engine.helloIdentity!.eepromFailureSignal, isTrue); + expect(link.logs.any((l) => l.contains('EEPROM')), isTrue); + expect( + link.engine.offloadSnapshot['hello_serial_eeprom_failure'], isTrue); + }); + }); + + group('engine wiring — the battery poll correlates without blocking', () { + test('the poll returns on the WRITE and the reply is correlated after', + () async { + final link = _Link(); // writes succeed, nothing ever answers + + final sw = Stopwatch()..start(); + await link.engine.getBattery(); + sw.stop(); + expect(sw.elapsed, lessThan(const Duration(seconds: 1)), + reason: 'a display value must never hold the session-open path for ' + 'the full command timeout'); + expect(link.engine.pendingCommandCount, 1, + reason: 'the observer is still there waiting for the reply'); + + link.engine.debugAbsorbDecoded(Decoded('cmd_response', { + 'opcode': Cmd.getBatteryLevel, + 'req_seq': link.seqOf(Cmd.getBatteryLevel), + 'cmd_status': CommandAwaiter.statusSuccess, + 'battery_pct': 42.0, + })); + + expect(link.engine.pendingCommandCount, 0); + expect(link.engine.state.batteryPct, 42.0); + }); + }); +} diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index de80f29c..8eac9117 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -345,10 +345,82 @@ void main() { expect(engine.offloadSnapshot['high_freq_requested'], isFalse); }); - test('the gen5 clock commands carry the revision byte', () async { + test('INIT no longer re-sends the hello — it belongs to connect setup', + () async { + // The official order is hello FIRST, during setup, so its timestamp can + // drive the clock decision and its identity fields are available to + // everything after. Sending it again at INIT would be a second identity + // exchange after every consumer has already run. + final w = _Wire(band: BandProfile.gen5); + await w.engine.sendInit(); + final opcodes = w.frames + .map((f) => parseFrame(f, profile: BandProfile.gen5)) + .where((p) => p != null && p.valid) + .map((p) => p!.inner[2]) + .toList(); + expect(opcodes, isNot(contains(Cmd.getHello))); + expect(opcodes, contains(Cmd.sendHistoricalData)); + }); + + test('the wake window uses the official 180 s / 7200 s Smart Alarm values', + () async { + // doc 14: ENTER_HIGH_FREQ_SYNC(96) body `02 b4 00 20 1c` — rev 2, then + // interval 180 s and duration 7200 s as u16 LE. The old 61 s/90 min + // defaults were picked only to clear gen5's "> 60" floor. + final w = _Wire(band: BandProfile.gen5); + await w.engine.applyHighFreqWakeWindow( + enabled: true, + targetWake: DateTime.now().add(const Duration(hours: 2)), + ); + expect(w.lastCommandOf(6), [ + Cmd.enterHighFreqSync, + 0x02, // revision + 0xb4, 0x00, // interval 180 s, u16 LE + 0x20, 0x1c, // duration 7200 s, u16 LE + ]); + }); + + test('runStoredAlarm sends the official rev-2 body with the alarm id', + () async { + // doc 14 "Run alarm now — opcode 68": body `02 01`. This is the early-wake + // mechanism; the rev-1 gen4 body does nothing on gen5. + final w = _Wire(band: BandProfile.gen5); + await w.engine.runStoredAlarm(); + expect(w.lastCommandOf(3), + [Cmd.runAlarm, 0x02, AlarmPayloads.gen5Slot]); + expect(AlarmPayloads.gen5Slot, 1); + }); + + test('gen5 reads the clock with the OFFICIAL GET_CLOCK(11), empty body', + () async { + // Opcode 147 ("GET_CLOCK_GEN5") appears nowhere in the official 75-opcode + // enum. The confirmed gen5 contract is the shared opcode 11 with an EMPTY + // body — physically exercised on a real WHOOP 5 (the probe read the clock + // this way and measured ~2410 ms drift before setting it). final w = _Wire(band: BandProfile.gen5); await w.engine.getClock(); - expect(w.lastCommandOf(2), [Cmd.getClockGen5, 0x01]); + expect(w.lastCommandOf(1), [Cmd.getClock]); + expect(Cmd.getClock, 11); + }); + + test('gen5 sets the clock with the OFFICIAL SET_CLOCK(10), 8-byte body', + () async { + // , no revision byte — the form that + // returned SUCCESS from a real WHOOP 5. A wrong clock write is silent: + // the RTC never latches and every alarm is then armed against it. + final w = _Wire(band: BandProfile.gen5); + await w.engine.setClock(); + // setClock() reads the RTC back afterwards, so SET is not the last frame. + final set = w.frames + .map((f) => parseFrame(f, profile: BandProfile.gen5)) + .where((p) => p != null && p.valid) + .map((p) => p!.inner.sublist(2)) + .firstWhere((c) => c.first == Cmd.setClock); + expect(Cmd.setClock, 10); + // opcode + 8 body bytes (the frame is 4-byte padded beyond that). + expect(set.sublist(0, 9).length, 9); + // Subseconds are a u16 in the low half of the second u32; top 2 bytes 0. + expect(set.sublist(7, 9), [0, 0]); }); }); From eac10061597cb9dbd9b84c35480826ae43b7b738 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Wed, 19 Aug 2026 15:25:48 +0200 Subject: [PATCH 02/11] surface band-volunteered events and judge alarm arms on the reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strap's own condition reports (event 29) and haptics terminations (event 100) now land in the offload snapshot and the log: live pages-behind/backlog/charge from the band's side of the sync, and whether an alarm ended by timeout, error or the wearer's double-tap. Observability only — no sync is triggered and no alarm behaviour changes. Arming an alarm is now judged on the strap's correlated reply instead of the GATT write. A reply whose outer result is FAILURE/UNSUPPORTED, or whose alarm status is in the input-rejection family (invalid waveform, loop count, duration, alarm time or alarm id), returns null so nothing records an alarm the band refused — previously a refused arm looked identical to a successful one. An unanswered reply keeps the old write-is-the-arm semantics so straps that do not echo the originating sequence still arm; it is logged as unconfirmed for getAlarm() to verify. RUN_ALARM goes through the same correlation and its [revision, status] reply is recorded in the snapshot — the paper trail for verifying the early-wake path on hardware. --- lib/ble/ble_engine.dart | 154 +++++++++++++++++++++++++- lib/ble/ble_state.dart | 39 +++++++ lib/state/app_state.dart | 8 +- test/alarm_test.dart | 218 ++++++++++++++++++++++++++++++++++++- test/gen5_wiring_test.dart | 108 ++++++++++++++++++ 5 files changed, 515 insertions(+), 12 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 372062a7..418b3568 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -866,6 +866,14 @@ class BleEngine { @visibleForTesting void debugIngestHistoricalFrame(Frame frame) => _ingestHistoricalFrame(frame); + /// Feed one inbound control frame through the real immediate-receive path + /// (decode → event handling → state absorb). + /// + /// Type-48 events are telemetry the band volunteers — nothing here is ever + /// requested — so this path is otherwise only reachable behind a radio. + @visibleForTesting + void debugProcessImmediateFrame(Frame frame) => _processImmediateFrame(frame); + /// Drive the canonical historical-refresh path. Returns whether /// SEND_HISTORICAL_DATA actually went out. @visibleForTesting @@ -986,6 +994,29 @@ class BleEngine { int? _strapAlarmEpoch; bool? _strapAlarmActive; + /// Last STRAP_CONDITION_REPORT(29) event — the band's own view of its + /// backlog, charge and wear. Observability only; see [StrapConditionReport]. + StrapConditionReport? _strapCondition; + + /// Why the last running haptics pattern stopped (HAPTICS_TERMINATED(100), + /// doc 07): `expired`, `error` or `user_double_tap`. The double tap is the + /// only way to learn the WEARER dismissed an alarm rather than letting it + /// time out. Recorded and logged; the alarm flow is unchanged. + String? _lastHapticsTermination; + int? _lastHapticsTerminationTs; + + /// What the strap answered the last [runStoredAlarm] with: the alarm/haptics + /// status byte from the correlated `RUN_ALARM(68)` reply (doc 07 + /// §"Alarm/haptics status codes"), plus the wall second it landed. + /// + /// This is the wake-in-green trigger's only evidence trail. RUN_ALARM has + /// never been verified on WHOOP 5 hardware with the rev-2 body (see + /// [runStoredAlarm]), so "did the strap say it played?" is exactly the + /// question a hardware re-test needs answered from the field. + int? _lastRunAlarmStatus; + String? _lastRunAlarmStatusName; + int? _lastRunAlarmTs; + // ── reconnect/offload policy ──────────────────────────────────────────────── // Marginal-radio + post-bond-loop persist ACROSS reconnects (they count // consecutive bad cycles), so they live for the engine's lifetime and self-reset @@ -1336,6 +1367,20 @@ class BleEngine { // What the STRAP reports it holds (GET_ALARM_TIME), not what we set. 'strap_alarm_epoch': _strapAlarmEpoch, 'strap_alarm_active': _strapAlarmActive, + // Unsolicited strap telemetry (doc 04 event 29 / doc 07 event 100). + // Observability only — neither drives a sync nor the alarm flow. + 'condition_pages_behind': _strapCondition?.pagesBehind, + 'condition_backlog': _strapCondition?.backlog, + 'condition_soc_pct': _strapCondition?.socPct, + 'condition_charging': _strapCondition?.charging, + 'condition_wrist_state': _strapCondition?.wristState, + 'condition_ts': _strapCondition?.tsEpoch, + 'last_haptics_termination': _lastHapticsTermination, + 'last_haptics_termination_ts': _lastHapticsTerminationTs, + // doc 07: what the strap answered the last RUN_ALARM with. + 'last_run_alarm_status': _lastRunAlarmStatus, + 'last_run_alarm_status_name': _lastRunAlarmStatusName, + 'last_run_alarm_ts': _lastRunAlarmTs, // doc 01/02: hello health and the identity gate, both observable rather // than enforced. `hello_failures` counts ACROSS reconnects and resets // itself at the bond-reset threshold. @@ -3026,7 +3071,34 @@ class BleEngine { } void _handleEventInfo(EventInfo event) { + final f = event.decoded; switch (event.eventId) { + case EventId.strapConditionReport: + // doc 04 §"Type 48 — events": free sync-progress telemetry, sent + // unasked. Recorded and logged ONLY — deliberately no offload trigger + // here, so the backfill policy stays the single place that decides + // when to sync. + _strapCondition = StrapConditionReport( + tsEpoch: event.tsEpoch, + pagesBehind: (f['condition_pages_behind'] as num?)?.toInt(), + backlog: (f['condition_backlog'] as num?)?.toDouble(), + socPct: (f['condition_soc_pct'] as num?)?.toDouble(), + flash: (f['condition_flash'] as num?)?.toInt(), + charging: f['condition_charging'] as bool?, + wristState: (f['condition_wrist_state'] as num?)?.toInt(), + ); + _log('[SYNC] strap condition: $_strapCondition'); + return; + case EventId.hapticsTerminated: + // doc 07 §"Termination event". `user_double_tap` is the wearer + // dismissing a running alarm — a different fact from an alarm that ran + // its course. Observed, not acted on: the alarm flow is unchanged. + _lastHapticsTermination = + f['haptics_termination'] as String? ?? 'unknown'; + _lastHapticsTerminationTs = event.tsEpoch; + _log('[ALARM] haptics terminated: cause=$_lastHapticsTermination ' + 'code=${f['haptics_termination_code']} ts=${event.tsEpoch}'); + return; case EventId.highFreqSyncPrompt: _log( '[SYNC] HighFreq prompt received — scheduling a one-shot historical refresh.', @@ -4354,8 +4426,25 @@ class BleEngine { /// time-only form ([setAlarmSimple]) is ACKed but never buzzes. The strap /// confirms via event 56 and reports firing via 57/58 + 60. /// - /// Returns the wall-clock instant armed, or null if the write failed (so the - /// caller does not persist a phantom alarm). + /// Returns the wall-clock instant armed, or null when the strap did not take + /// the alarm — so the caller never persists a phantom alarm. Null means one + /// of two things, both of them "there is no alarm on that band": + /// + /// * the write never left the phone, or + /// * the strap answered and REFUSED it — a FAILURE/UNSUPPORTED outer result, + /// or an alarm-status byte from the input-rejection family (doc 07 + /// §"Alarm/haptics status codes": invalid waveform/loop/duration/time/ID). + /// That byte is "in addition to" the outer result and the doc says to + /// check both — a strap can answer SUCCESS and still report `invalid + /// alarm time`. The `arm info is invalid, error 0xb` seen when arming slot + /// 0 on a WHOOP 5 is precisely status 11, `invalid_alarm_id`, arriving + /// through this byte. + /// + /// An UNANSWERED arm is deliberately NOT a refusal: it returns [when] as + /// before. Correlation is new here and unproven on every strap; failing an + /// arm because a read-back never came back would break wake alarms on any + /// band that does not echo the originating sequence. The log line is the + /// signal that the arm went out unconfirmed. Future setAlarm( DateTime when, { int index = 0, @@ -4385,16 +4474,41 @@ class BleEngine { index: index, haptics: haptics, ); - final ok = await _send(Cmd.setAlarmTime, payload); + final out = await _sendAwaited(Cmd.setAlarmTime, payload); _log( 'SET_ALARM_TIME (${isGen5 ? "gen5 rich index1" : "rich"} ${payload.length}B) ' '→ wallSec=${when.millisecondsSinceEpoch ~/ 1000} ' 'strapSec=${armWhen.millisecondsSinceEpoch ~/ 1000} drift=${driftSec}s ' 'correlated=${ref != null} subsec=${AlarmPayloads.subsecOf(armWhen)} ' 'idx=${payload.length >= 2 ? payload[1] : -1} ' - 'write=${ok ? 'ok' : 'FAILED'}', + 'write=${out.written ? 'ok' : 'FAILED'}', ); - return ok ? when : null; + if (!out.written) return null; + // Worst case here is the awaiter's single 5 s timeout, applied once, with + // no resend — arming is a user-facing action, not a background poll, and a + // duplicate SET after a slow-but-successful one would rewrite the strap's + // stored deadline. + final resp = await out.response; + if (resp == null) { + _log('[ALARM] arm UNCONFIRMED — no correlated SET_ALARM_TIME reply. ' + 'Treating the write as the arm (the strap may not echo the ' + 'originating sequence); verify with getAlarm().'); + return when; + } + final code = (resp.fields['alarm_status'] as num?)?.toInt(); + final name = resp.fields['alarm_status_name'] as String?; + final rejected = resp.failed || + resp.unsupported || + (code != null && AlarmStatus.isInputRejection(code)); + if (rejected) { + _log('[ALARM] arm REJECTED by the strap — result=${resp.status} ' + 'alarm_status=$code ($name). NOT recording an alarm: there is ' + 'nothing armed on the band.'); + return null; + } + _log('[ALARM] arm accepted — result=${resp.status} ' + 'alarm_status=${code ?? 'absent'} (${name ?? 'no status byte'}).'); + return when; } /// Time-only alarm (SET_ALARM_TIME = 0x42), SHORT 7-byte form: @@ -4458,10 +4572,38 @@ class BleEngine { /// on gen5. The rev-2 form has not been re-tested on hardware yet, so callers /// must treat a wake driven by this as unconfirmed until it has (tracked in /// reversing-whoop doc 15 G6). + /// + /// Returns whether the WRITE went out — callers treat the wake as + /// best-effort and must not block on the band. The reply (`[02, status]`, + /// doc 07) is correlated in the background and recorded in + /// [offloadSnapshot] as `last_run_alarm_status*`: on hardware that never + /// answered this command, whether the strap reports `played_successfully` + /// is the evidence the re-test needs, and it can only be collected from a + /// real band. Future runStoredAlarm({int? slot}) async { final band = _session?.band ?? BandProfile.gen4; final id = slot ?? (band.isGen5 ? AlarmPayloads.gen5Slot : null); - return _write(cmdRunAlarm(_seq.nextLive(), mode: id, profile: band)); + final out = await _sendAwaited( + Cmd.runAlarm, + const [], + frameBuilder: (seq) => cmdRunAlarm(seq, mode: id, profile: band), + ); + if (!out.written) return false; + unawaited(out.response.then((resp) { + if (resp == null) { + _log('[ALARM] RUN_ALARM went unanswered — the early wake is ' + 'UNCONFIRMED (write ok, no correlated reply).'); + return; + } + final code = (resp.fields['alarm_status'] as num?)?.toInt(); + final name = resp.fields['alarm_status_name'] as String?; + _lastRunAlarmStatus = code; + _lastRunAlarmStatusName = name; + _lastRunAlarmTs = _wallSecs().round(); + _log('[ALARM] RUN_ALARM reply — result=${resp.status} ' + 'alarm_status=${code ?? 'absent'} (${name ?? 'no status byte'}).'); + })); + return true; } /// Cancel the on-device alarm (DISABLE_ALARM = 0x45). gen4 body `[0x01]` diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 9c58b9ae..ef171d58 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1295,3 +1295,42 @@ class HelloIdentity { 'cpu=${cpuOk ? 'ok' : 'BAD'}' '${eepromFailureSignal ? ' serial=all-zero(EEPROM)' : ''}'; } + +/// The last `STRAP_CONDITION_REPORT(29)` event the band volunteered +/// (doc 04 §"Type 48 — events"). +/// +/// OBSERVABILITY ONLY. This is the band telling us, unasked, how far behind its +/// flash we are — the cheapest sync-progress signal there is — but nothing here +/// starts an offload; the backfill triggers are unchanged. +/// +/// [pagesBehind] is the SAME modular PAGE span `GET_DATA_RANGE` reports, not a +/// packet or record count (doc 05, ~15 records/page nominal). [wristState] is +/// the raw tri-state byte: the doc names no mapping for its three values, so +/// wear truth still comes from WRIST_ON/WRIST_OFF and hello, never from here. +/// Every field is nullable because a short body decodes to its prefix only. +class StrapConditionReport { + /// The event's own strap timestamp — the band re-serves buffered events on + /// connect, so a report is only evidence about the moment it names. + final int tsEpoch; + final int? pagesBehind; + final double? backlog; + final double? socPct; + final int? flash; + final bool? charging; + final int? wristState; + + const StrapConditionReport({ + required this.tsEpoch, + this.pagesBehind, + this.backlog, + this.socPct, + this.flash, + this.charging, + this.wristState, + }); + + @override + String toString() => 'pages_behind=$pagesBehind backlog=$backlog ' + 'soc=$socPct% flash=$flash charging=$charging wrist=$wristState ' + 'ts=$tsEpoch'; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 489f10db..4e254f6f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3041,8 +3041,12 @@ class AppState extends ChangeNotifier { if (armed == null) { // Do NOT persist or start the confirmation machine, or we'd strand a // phantom alarm "waiting for the strap to confirm" that can never fire. - _log('[alarm] arm write FAILED — not persisting; alarm not set.'); - throw Exception('Alarm not sent — the strap did not accept the write'); + // Null now covers two cases: the write never left the phone, and the + // strap answered and REFUSED the alarm (doc 07's alarm-status byte — + // see BleEngine.setAlarm). Both mean the band holds no alarm, so both + // must stay out of persistence; the engine log says which one it was. + _log('[alarm] the band did not take the alarm — not persisting.'); + throw Exception('Alarm not set — the strap did not accept it'); } final epoch = armed.millisecondsSinceEpoch ~/ 1000; _savedAlarm = epoch; diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 724304d5..8e572e8b 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -1,15 +1,69 @@ -// Pure-logic tests for the on-device wake alarm: +// Tests for the on-device wake alarm: // - the exact SET_ALARM_TIME byte layouts (rich 20-byte firing form + short -// 7-byte time-only form) and the RUN/DISABLE bodies (AlarmPayloads), and -// - the strap-event confirmation state machine (AlarmConfirmation). -// No BLE / DB — everything here is deterministic. +// 7-byte time-only form) and the RUN/DISABLE bodies (AlarmPayloads), +// - the strap-event confirmation state machine (AlarmConfirmation), and +// - the arm/run decision made on the correlated reply's alarm-status byte +// (doc 07), driven over the engine's fake-link seam. +// No radio and no DB — everything here is deterministic. +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; import 'package:openstrap_edge/ble/ble_state.dart'; import 'package:openstrap_edge/sync/sync_policy.dart' show ClockRef; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; +/// A gen5 link with no radio behind it, plus the seq of every command written. +/// Same seam as `command_correlation_test.dart`: the reply is injected from +/// INSIDE the write, i.e. before the write call returns, which is the ordering +/// doc 02 demands and the one a fast strap actually produces. +class _Link { + final logs = []; + final written = <({int seq, int opcode})>[]; + late final BleEngine engine; + + _Link({proto.Decoded? Function(int seq, int opcode)? replyTo}) { + engine = BleEngine(onRecord: (_, _) async {}, onState: (_) {}, log: logs.add); + engine.debugInstallFakeLink( + band: proto.BandProfile.gen5, + onWrite: (frame) async { + final inner = proto.parseFrame(frame, profile: proto.BandProfile.gen5)!.inner; + written.add((seq: inner[1], opcode: inner[2])); + final reply = replyTo?.call(inner[1], inner[2]); + if (reply != null) engine.debugAbsorbDecoded(reply); + return true; + }, + ); + } + + bool logged(String needle) => logs.any((l) => l.contains(needle)); +} + +/// A COMMAND_RESPONSE carrying the doc-07 alarm/haptics status byte, decoded by +/// the REAL protocol parser so the test asserts on the wire layout rather than +/// on a hand-written field map: `[0x24][strap seq][opcode][echoed seq][result]` +/// then body `[revision][alarm status]`. +proto.Decoded _alarmReply( + int opcode, + int seq, + int alarmStatus, { + int outer = 1, + int revision = 3, +}) { + final inner = Uint8List.fromList( + [0x24, 0x55, opcode, seq, outer, revision, alarmStatus]); + final r = + proto.parseCommandResponse(inner, profile: proto.BandProfile.gen5)!; + return proto.Decoded('cmd_response', {'opcode': r.opcode, ...r.decoded}); +} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(BleEngine.resetBandClaimForTest); + tearDown(BleEngine.resetBandClaimForTest); + group('AlarmPayloads byte layout', () { // A hand-computed vector: // sec = 0x01020304 = 16909060 → LE [04 03 02 01] @@ -319,4 +373,160 @@ void main() { expect(run(p, now: opensAt + 700, met: true), ConditionalWakeAction.none); }); }); + + // doc 07 §"Command bodies": the SET_ALARM_TIME reply carries a haptics/alarm + // status byte "in addition to the ordinary outer command result — check + // both". Before this, the engine treated a successful WRITE as an armed + // alarm, so a strap that answered `invalid alarm time` left the app showing + // a wake alarm that did not exist on the band. + group('engine wiring — an arm is judged on the strap\'s reply', () { + final wake = DateTime.fromMillisecondsSinceEpoch(1750000000 * 1000); + + test('a rejected alarm time returns null — nothing to persist', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.setAlarmTime + ? _alarmReply(opcode, seq, proto.AlarmStatus.invalidAlarmTime) + : null, + ); + + expect(await link.engine.setAlarm(wake), isNull, + reason: 'the strap refused it; there is no alarm on the band'); + expect(link.logged('arm REJECTED'), isTrue); + expect(link.logged('invalid_alarm_time'), isTrue, + reason: 'the status name is the whole diagnostic'); + expect(link.engine.pendingCommandCount, 0); + }); + + test('a SUCCESS outer result does not override a rejecting status byte', + () async { + // The reply above already carries outer result 1 — the point of the doc's + // "check both" is that this combination exists on the wire. + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.setAlarmTime + ? _alarmReply(opcode, seq, proto.AlarmStatus.invalidAlarmId, + outer: 1) + : null, + ); + expect(await link.engine.setAlarm(wake), isNull); + expect(link.logged('invalid_alarm_id'), isTrue); + }); + + test('an accepted arm returns the armed time and logs the status', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.setAlarmTime + ? _alarmReply(opcode, seq, proto.AlarmStatus.validInputPattern) + : null, + ); + + expect(await link.engine.setAlarm(wake), wake); + expect(link.logged('arm accepted'), isTrue); + expect(link.logged('valid_input_pattern'), isTrue); + }); + + test('a FAILURE outer result rejects the arm even with no status byte', + () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.setAlarmTime + ? proto.Decoded('cmd_response', { + 'opcode': opcode, + 'req_seq': seq, + 'cmd_status': CommandAwaiter.statusFailure, + }) + : null, + ); + expect(await link.engine.setAlarm(wake), isNull); + expect(link.logged('arm REJECTED'), isTrue); + }); + + test('an unanswered arm still arms, logged as unconfirmed', () { + // Correlation is new and unproven on every strap: a band that does not + // echo the originating sequence must not lose its wake alarm. The arm + // costs at most the awaiter's single 5 s timeout, with no resend. + fakeAsync((async) { + final link = _Link(); // writes succeed, nothing ever answers + DateTime? armed; + var done = false; + link.engine.setAlarm(wake).then((v) { + armed = v; + done = true; + }); + + async.elapse(const Duration(seconds: 4)); + expect(done, isFalse, reason: 'still waiting on the reply'); + async.elapse(const Duration(seconds: 2)); + + expect(done, isTrue); + expect(armed, wake, reason: 'an unanswered read-back is not a refusal'); + expect(link.logged('arm UNCONFIRMED'), isTrue); + expect(link.engine.pendingCommandCount, 0); + }); + }); + + test('a failed write is still the only silent null', () async { + final link = _Link(); + link.engine.debugWriteHook = (_) async => false; + expect(await link.engine.setAlarm(wake), isNull); + expect(link.logged('arm REJECTED'), isFalse, + reason: 'nothing was refused — nothing was ever sent'); + expect(link.engine.pendingCommandCount, 0, + reason: 'a write that never went out leaves no observer behind'); + }); + }); + + // RUN_ALARM(68) is the wake-in-green trigger and has never been verified on + // WHOOP 5 hardware with the rev-2 body. Its `[02, status]` reply is the + // evidence trail a hardware re-test reads back out of the snapshot. + group('engine wiring — runStoredAlarm records what the strap answered', () { + test('the reply status lands in the offload snapshot', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm + ? _alarmReply(opcode, seq, proto.AlarmStatus.playedSuccessfully, + revision: 2) + : null, + ); + + expect(await link.engine.runStoredAlarm(), isTrue, + reason: 'the bool is the WRITE — the wake stays best-effort'); + await pumpEventQueue(); + + final snap = link.engine.offloadSnapshot; + expect(snap['last_run_alarm_status'], proto.AlarmStatus.playedSuccessfully); + expect(snap['last_run_alarm_status_name'], 'played_successfully'); + expect(snap['last_run_alarm_ts'], isNotNull); + expect(link.logged('RUN_ALARM reply'), isTrue); + }); + + test('a haptics failure is recorded too, not swallowed', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm + ? _alarmReply(opcode, seq, proto.AlarmStatus.hapticsFailure, + outer: 0, revision: 2) + : null, + ); + + // Still true: the write went out. The verdict lives in the snapshot. + expect(await link.engine.runStoredAlarm(), isTrue); + await pumpEventQueue(); + expect(link.engine.offloadSnapshot['last_run_alarm_status_name'], + 'haptics_failure'); + }); + + test('the RUN_ALARM frame is correlated on its own sequence', () async { + final link = _Link( + replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm + ? _alarmReply(opcode, seq, proto.AlarmStatus.playedSuccessfully, + revision: 2) + : null, + ); + await link.engine.runStoredAlarm(); + await pumpEventQueue(); + // The frame is built by the protocol helper, so the awaiter's sequence + // has to be threaded THROUGH it — a hard-coded seq inside cmdRunAlarm + // would never match. + final run = + link.written.lastWhere((w) => w.opcode == proto.Cmd.runAlarm); + expect(run.seq, greaterThanOrEqualTo(SeqAllocator.liveFloor)); + expect(link.engine.pendingCommandCount, 0); + }); + }); } diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index 8eac9117..dcf26a14 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -444,4 +444,112 @@ void main() { expect(logs.where((l) => l.contains('never set')), isNotEmpty); }); }); + + _events(); +} + +/// A type-48 EVENT inner: +/// `[0x30][u8 seq][u16 id][u32 unix][u16 subsec][u16 body len][body…]` +/// (doc 04 §"Type 48 — events"). Built directly rather than through +/// `buildFrame` because the engine's receive path consumes inners. +Uint8List _eventInner(int id, List body, {int ts = 1786000000}) { + final inner = Uint8List(12 + body.length); + inner[0] = PacketType.event; + inner[1] = 0x07; + final view = ByteData.sublistView(inner); + view.setUint16(2, id, Endian.little); + view.setUint32(4, ts, Endian.little); + view.setUint16(8, 0, Endian.little); + view.setUint16(10, body.length, Endian.little); + inner.setRange(12, inner.length, body); + return inner; +} + +void _events() { + group('P1 — the band volunteers condition and haptics events (T6)', () { + ({BleEngine engine, List logs}) rig() { + final logs = []; + final engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + ); + return (engine: engine, logs: logs); + } + + test('STRAP_CONDITION_REPORT(29) lands in the offload snapshot and the log', + () { + final r = rig(); + // pages behind 4321, backlog 45.6, SoC 87.2%, flash 3, charging, wrist 2. + r.engine.debugProcessImmediateFrame(Frame( + _eventInner(EventId.strapConditionReport, [ + 0xE1, 0x10, 0x00, 0x00, // u32 page backlog = 4321 + 0xC8, 0x01, // u16 backlog tenths = 456 + 0x68, 0x03, // u16 state-of-charge tenths = 872 + 0x03, // flash + 0x01, // charging + 0x02, // wrist tri-state + ], ts: 1786000123), + true, + true, + )); + + final snap = r.engine.offloadSnapshot; + expect(snap['condition_pages_behind'], 4321, + reason: 'a modular PAGE span (doc 05), not a packet count'); + expect(snap['condition_backlog'], closeTo(45.6, 1e-9)); + expect(snap['condition_soc_pct'], closeTo(87.2, 1e-9)); + expect(snap['condition_charging'], isTrue); + expect(snap['condition_wrist_state'], 2); + expect(snap['condition_ts'], 1786000123); + expect(r.logs.where((l) => l.contains('[SYNC] strap condition')), + isNotEmpty); + }); + + test('a condition report is observability only — it starts no offload', () { + final r = rig(); + r.engine.debugProcessImmediateFrame(Frame( + _eventInner(EventId.strapConditionReport, + [0xFF, 0xFF, 0x00, 0x00, 0, 0, 0, 0, 0, 0, 0]), + true, + true, + )); + // A five-figure backlog is exactly the reading that would tempt a sync + // trigger. The backfill policy stays the only thing that starts one. + expect(r.engine.offloadActive, isFalse); + expect(r.engine.offloadSnapshot['history_requests'], 0); + // The raw tri-state byte must not be laundered into wear state. + expect(r.engine.offloadSnapshot['condition_wrist_state'], 0); + }); + + test('HAPTICS_TERMINATED(100) code 2 records the wearer double-tap', () { + final r = rig(); + r.engine.debugProcessImmediateFrame(Frame( + _eventInner(EventId.hapticsTerminated, + [1, HapticsTermination.userDoubleTap], + ts: 1786000456), + true, + true, + )); + + final snap = r.engine.offloadSnapshot; + expect(snap['last_haptics_termination'], 'user_double_tap'); + expect(snap['last_haptics_termination_ts'], 1786000456); + expect( + r.logs.where( + (l) => l.contains('[ALARM]') && l.contains('user_double_tap')), + isNotEmpty); + }); + + test('an expiry and a dismissal are not the same recorded cause', () { + final r = rig(); + r.engine.debugProcessImmediateFrame(Frame( + _eventInner( + EventId.hapticsTerminated, [1, HapticsTermination.expired]), + true, + true, + )); + expect(r.engine.offloadSnapshot['last_haptics_termination'], 'expired'); + }); + }); } From 7e276f2f39eb92e8428689e1e9e12058be970b14 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Wed, 19 Aug 2026 16:33:32 +0200 Subject: [PATCH 03/11] honest 1 Hz columns and the official gen5 bootstrap tail Persistence stops writing three values the data never supported. The on-wrist and hr-valid columns are left NULL: body-60 bits 0-1 are the primary-flags snapshot, not wear, and body-15 bit7 toggles ~50/50 independent of HR presence across 1.59M retained records (752,820 carry a valid HR with the bit clear), so both were coin flips dressed as answers. Skin temperature goes through the sentinel-aware accessor so the AS6221's -50.00 C unavailable code stores as NULL instead of a temperature. A data-only v35 migration retires what v34-era builds already banked; the columns stay in place, nullable, for an honest source if one ever appears. No metric read any of the three, so day results are unchanged and the algo version stays put. The gen5 bootstrap tail now matches the captured client: 600 ms before notification registration and 500 ms after (the capture shows hello going out 585 ms after the last CCC write); SET_CLOCK only at two or more whole seconds of drift, with no BLE write when the clocks already agree (an uncorrelated or unset RTC still always writes); the advertising-name read as the final pre-READY command, correlated but never a gate; and when hello reports charging, a session-owned follow-up asks for battery-pack info up to five times, five seconds apart, accepting only a reply whose pack address is real. gen4 setup is byte-identical to before throughout. --- lib/ble/ble_engine.dart | 402 +++++++++++++---- lib/ble/ble_state.dart | 54 +++ lib/data/db.dart | 48 +- lib/data/models.dart | 19 +- test/gen5_decoded_onehz_persistence_test.dart | 124 ++++++ test/gen5_sample_fields_test.dart | 154 ++++++- test/gen5_sample_mapping_test.dart | 106 +++++ test/gen5_wiring_test.dart | 418 ++++++++++++++++++ 8 files changed, 1242 insertions(+), 83 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 418b3568..9a123a09 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -118,9 +118,26 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { stepCount: g.stepMotionCounter, stepCadence: g.stepCadence, activityClass: g.activityClassKnown, // null for the unclassified code - skinTempC: g.skinTempC, - onWrist: g.onWristRaw, - hrValid: g.hrRrValidThisSecond, + // -50.00 °C is the AS6221 unavailable/error SENTINEL, not a reading, so the + // honest accessor abstains on it and the column stores NULL. Persisting the + // sentinel verbatim would put a number 70 °C below any wrist into a + // temperature column, where nothing downstream could tell it from data. + skinTempC: g.skinTempCOrNull, + // `onWrist` and `hrValid` are DELIBERATELY LEFT UNSET. v18 carries no + // honest source for either, and both readings we once used are disproven + // (see Gen5HistorySample's deprecation notices in protocol): + // • body 60 bits 0-1 (`onWristRaw`) are the primary-flags bit-8 snapshot, + // not wear. Wear truth comes from the HELLO body, the wrist on/off + // events, and the streams being wear-gated — none of it per-second. + // • body 15 bit7 (`hrRrValidThisSecond`) is not HR/RR validity: across + // 1,587,671 retained records it toggles ~50/50 independently of HR + // presence, and 752,820 records carried a valid HR with the bit CLEAR. + // HR presence is `heartRate` in 25..230 — which the decoder already + // enforces on `hr`, and which every reader derives from `hr` itself; + // per-second signal quality is `signalQualityLogVariance`. + // NULL here means "the band never told us", which is the truth. Setting + // them from those bits is what turned a coin-flip into a confident wear / + // validity answer downstream. hrAlt: g.heartRateAlt, ); } @@ -407,6 +424,10 @@ class _Session { // initial `disconnected` that flutter_blue_plus replays on listen. bool sawConnected = false; bool intentionalClose = false; + /// Whether the doc-01 charging follow-up (GET_BATTERY_PACK_INFO) has already + /// been launched for THIS session. Session-scoped so a second bootstrap on + /// the same link cannot start a second retry loop against the same band. + bool batteryPackFollowUpStarted = false; _Session(this.device); @@ -856,6 +877,20 @@ class BleEngine { @visibleForTesting Future debugReadGen5Hello() => _readGen5Hello(); + /// Drive the real doc-01 bootstrap that follows notification registration: + /// the observed 500 ms delay, HELLO, the clock decision, the final + /// advertising-name read and the charging follow-up. + /// + /// The ORDER of those steps, and which of them make a BLE write at all, is + /// the whole contract of doc 01 §"Phase sequence" — and it lives behind a + /// radio otherwise, because the only caller is the connect path. + @visibleForTesting + Future debugBootstrapAfterRegistration() { + final session = _session; + if (session == null) return Future.value(false); + return _bootstrapAfterRegistration(session); + } + /// Feed one inbound historical frame through the real ingest path (decode → /// plausibility gate → store or archive). /// @@ -1128,9 +1163,34 @@ class BleEngine { int _helloFailures = 0; static const int kHelloFailuresBeforeBondReset = 5; + /// doc 01 §"The two delays": the official client waits **600 ms** after the + /// bond, before notification registration, and **500 ms** after the last + /// registration before running the higher-level state machine — on a captured + /// link GET_HELLO went out 585 ms after the final CCC write. These are + /// OBSERVED client delays; the doc says outright that "the firmware rationale + /// is not documented", so they are applied on gen5 only rather than + /// perturbing the proven gen4 flow for a reason nobody can state. + static const Duration kGen5PreRegistrationDelay = Duration(milliseconds: 600); + static const Duration kGen5PostRegistrationDelay = + Duration(milliseconds: 500); + + /// doc 01 §"Charging follow-up": while the band reports charging, ask it what + /// battery pack it is on — "five attempts, 5,000 ms between attempts", and + /// "every unusable attempt is followed by the 5-second delay, including the + /// fifth". Purely advisory: a missing or invalid result "must not move the + /// band out of READY". + static const int kBatteryPackInfoAttempts = 5; + static const Duration kBatteryPackInfoRetryDelay = Duration(seconds: 5); + /// The identity verdict from the last successful hello (doc 01 "What gates /// READY") — observable, never a disconnect. Null until a hello lands. HelloIdentity? _helloIdentity; + + /// The last USABLE `GET_BATTERY_PACK_INFO(151)` reply (doc 01 §"Charging + /// follow-up") and when it landed. Diagnostics only — surfaced in + /// [offloadSnapshot], never gating READY or anything else. + BatteryPackInfoResponse? _batteryPack; + int? _batteryPackTs; DateTime? _bondTime; // when the handshake completed (bond confirmed) DateTime? _armTime; // when live (R10/R11) streams were last armed // Run-state for a chain of auto-continued offload rounds: how many @@ -1387,6 +1447,16 @@ class BleEngine { 'hello_failures': _helloFailures, 'hello_identity_ok': _helloIdentity?.ok, 'hello_serial_eeprom_failure': _helloIdentity?.eepromFailureSignal, + // doc 01 §"Charging follow-up": what the band answered about the puck it + // was sitting on. Absent until a USABLE reply lands (see + // [BatteryPackInfoGate]); never a readiness input. + 'battery_pack_attached': _batteryPack?.attached, + 'battery_pack_address': _batteryPack?.identifier, + 'battery_pack_name': _batteryPack?.name, + 'battery_pack_type': _batteryPack?.batteryPackType?.name, + 'battery_pack_type_raw': _batteryPack?.batteryPackTypeRaw, + 'battery_pack_status': _batteryPack?.statusRaw, + 'battery_pack_ts': _batteryPackTs, 'pending_commands': _awaiter.pendingKeys, }; @@ -1661,83 +1731,24 @@ class BleEngine { return false; } + // doc 01 §"The two delays" (gen5 only — see [kGen5PreRegistrationDelay]): + // the bond is complete by here, so this is the 600 ms that precedes + // notification registration. + if (band.isGen5 && + !await _bootstrapPause( + session, + kGen5PreRegistrationDelay, + 'the pre-registration delay', + )) { + return false; + } _setPhase(BleConnState.subscribing); await _subscribe(session, cmdFrom, 'cmd_from'); await _subscribe(session, events, 'events'); await _subscribe(session, data, 'data'); - _setPhase(BleConnState.settingUp); - // Set the strap RTC to real wall-clock time. The band ships with an unset - // clock; SET_CLOCK is non-destructive (it's what the official app does each - // connect). Records stamped after this carry real unix time. - _clockCorrectTries = 0; // fresh retry budget for this connection - // Drop the previous session's clock correlation so an alarm armed before - // THIS session's GET_CLOCK reply lands falls back to the raw wall epoch - // (drift 0) instead of the stale strap-RTC frame. The reads below - // repopulate it for this connection. - _clockRef = null; - _gen5Hello = null; - // HELLO FIRST on gen5 — the official bootstrap order (doc 01). Hello - // carries the strap's own timestamp, so it answers the "what time does - // the band think it is" question that the GET_CLOCK below exists to ask, - // and it carries identity/battery/charge/on-body state that everything - // after this wants. The app used to send it late, inside INIT, so none of - // that was available here and gen5 had no serial or battery at connect. - // - // Best effort: a failed or unanswered hello falls through to the ordinary - // clock read, which is what the official client does when hello supplies - // no timestamp. Nothing below is gated on it. - if (session.band.isGen5) { - await _readGen5Hello(); - if (_session != session || !session.connected) { - _log('link dropped during gen5 HELLO — abandoning setup.'); - if (identical(_session, session)) await _failConnect(); - return false; - } - } - // READ BEFORE WRITE. This used to be an unconditional SET_CLOCK, which is - // precisely the write [ClockPolicy.phoneClockSuspect] says we must never - // make: on a phone running >1 day slow it stamps that slow time onto a - // CORRECT strap RTC — and worse, it destroys the evidence, because the - // read-back then "agrees" and every later suspect-clock gate sees a - // healthy pair. Read first; skip the write while the PHONE is the suspect - // one. Unset/behind/garbage-low RTCs are unaffected (not suspect) and are - // still corrected here and by the clock_epoch handler's bounded re-issue. - // _readClock waits on a real reply now — up to _clockReadTimeout, where - // this used to be a 120 ms sleep. That is a much wider window for the - // link to drop underneath us, and setClock() absorbs failed writes, so - // without these checks setup would carry on past a teardown, rebuild the - // drain state and hand back `true` for a dead connection. - // Hello already answered this on gen5, so skip the round trip — the - // official client only falls back to GET_CLOCK when hello carried no - // timestamp. Feed hello's clock through the same handler the GET_CLOCK - // reply uses, so the suspect-phone and unset-RTC verdicts are computed - // from one place regardless of which command supplied the epoch. - final helloClock = _gen5Hello?.tsSeconds; - if (helloClock != null && helloClock > 0) { - _absorbClockEpoch(helloClock); - } else { - await _readClock(); - } - if (_session != session || !session.connected) { - _log('link dropped during the clock read — abandoning setup.'); - // Tear down ONLY if we are still the live session. `_failConnect` - // teardown+band-release act on whatever `_session` currently points - // at, so a newer `_doConnect` that already took over would have its - // link killed and its band claim dropped by this stale invocation. - if (identical(_session, session)) await _failConnect(); - return false; - } - if (!_deferForClock) await setClock(); - if (_session != session || !session.connected) { - _log('link dropped during SET_CLOCK — abandoning setup.'); - // Tear down ONLY if we are still the live session. `_failConnect` - // teardown+band-release act on whatever `_session` currently points - // at, so a newer `_doConnect` that already took over would have its - // link killed and its band claim dropped by this stale invocation. - if (identical(_session, session)) await _failConnect(); - return false; - } + if (!await _bootstrapAfterRegistration(session)) return false; + // Fresh clock verification stamp — see kRtcReverifyIntervalSeconds. _lastClockVerifyAt = DateTime.now(); // Per-connection policy reset. Marginal-radio + post-bond-loop are NOT reset // here — they count consecutive bad cycles across reconnects and self-reset on @@ -1856,6 +1867,247 @@ class BleEngine { } } + // ── bootstrap (doc 01 §"Phase sequence") ──────────────────────────────────── + + /// One of doc 01's two observed bootstrap delays, with the same stale-session + /// check every neighbouring step carries: a link that drops during the sleep + /// aborts setup instead of letting it run on against a dead connection. + /// + /// Returns false when the session is gone (the caller must return false too; + /// teardown has already happened here). + Future _bootstrapPause( + _Session session, + Duration delay, + String what, + ) async { + await Future.delayed(delay); + if (_session != session || !session.connected) { + _log('link dropped during $what — abandoning setup.'); + // Tear down ONLY if we are still the live session — a newer _doConnect + // that already took over must not have its link killed by this one. + if (identical(_session, session)) await _failConnect(); + return false; + } + return true; + } + + /// Everything doc 01's phase sequence puts between the last CCC write and + /// READY: the 500 ms post-registration delay, GET_HELLO, the clock decision, + /// the final advertising-name read and the charging follow-up. + /// + /// Lifted out of [_doConnect] because this ORDER is the contract doc 01 + /// specifies — and as inline statements inside a 400-line connect the only + /// way to check it was against a radio. + /// + /// Returns false when the link died under one of the steps; the session has + /// already been torn down in that case. + Future _bootstrapAfterRegistration(_Session session) async { + // doc 01 §"The two delays": 500 ms after the last registration, before the + // higher-level state machine runs. gen5 only — see the constant. + if (session.band.isGen5 && + !await _bootstrapPause( + session, + kGen5PostRegistrationDelay, + 'the post-registration delay', + )) { + return false; + } + _setPhase(BleConnState.settingUp); + // Set the strap RTC to real wall-clock time. The band ships with an unset + // clock; SET_CLOCK is non-destructive (it's what the official app does each + // connect). Records stamped after this carry real unix time. + _clockCorrectTries = 0; // fresh retry budget for this connection + // Drop the previous session's clock correlation so an alarm armed before + // THIS session's GET_CLOCK reply lands falls back to the raw wall epoch + // (drift 0) instead of the stale strap-RTC frame. The reads below + // repopulate it for this connection. + _clockRef = null; + _gen5Hello = null; + // HELLO FIRST on gen5 — the official bootstrap order (doc 01). Hello + // carries the strap's own timestamp, so it answers the "what time does + // the band think it is" question that the GET_CLOCK below exists to ask, + // and it carries identity/battery/charge/on-body state that everything + // after this wants. The app used to send it late, inside INIT, so none of + // that was available here and gen5 had no serial or battery at connect. + // + // Best effort: a failed or unanswered hello falls through to the ordinary + // clock read, which is what the official client does when hello supplies + // no timestamp. Nothing below is gated on it. + if (session.band.isGen5) { + await _readGen5Hello(); + if (_session != session || !session.connected) { + _log('link dropped during gen5 HELLO — abandoning setup.'); + if (identical(_session, session)) await _failConnect(); + return false; + } + } + // READ BEFORE WRITE. This used to be an unconditional SET_CLOCK, which is + // precisely the write [ClockPolicy.phoneClockSuspect] says we must never + // make: on a phone running >1 day slow it stamps that slow time onto a + // CORRECT strap RTC — and worse, it destroys the evidence, because the + // read-back then "agrees" and every later suspect-clock gate sees a + // healthy pair. Read first; skip the write while the PHONE is the suspect + // one. Unset/behind/garbage-low RTCs are unaffected (not suspect) and are + // still corrected here and by the clock_epoch handler's bounded re-issue. + // _readClock waits on a real reply now — up to _clockReadTimeout, where + // this used to be a 120 ms sleep. That is a much wider window for the + // link to drop underneath us, and setClock() absorbs failed writes, so + // without these checks setup would carry on past a teardown, rebuild the + // drain state and hand back `true` for a dead connection. + // Hello already answered this on gen5, so skip the round trip — the + // official client only falls back to GET_CLOCK when hello carried no + // timestamp. Feed hello's clock through the same handler the GET_CLOCK + // reply uses, so the suspect-phone and unset-RTC verdicts are computed + // from one place regardless of which command supplied the epoch. + final helloClock = _gen5Hello?.tsSeconds; + if (helloClock != null && helloClock > 0) { + _absorbClockEpoch(helloClock); + } else { + await _readClock(); + } + if (_session != session || !session.connected) { + _log('link dropped during the clock read — abandoning setup.'); + // Tear down ONLY if we are still the live session. `_failConnect` + // teardown+band-release act on whatever `_session` currently points + // at, so a newer `_doConnect` that already took over would have its + // link killed and its band claim dropped by this stale invocation. + if (identical(_session, session)) await _failConnect(); + return false; + } + await _bootstrapSetClock(session); + if (_session != session || !session.connected) { + _log('link dropped during SET_CLOCK — abandoning setup.'); + // Tear down ONLY if we are still the live session. `_failConnect` + // teardown+band-release act on whatever `_session` currently points + // at, so a newer `_doConnect` that already took over would have its + // link killed and its band claim dropped by this stale invocation. + if (identical(_session, session)) await _failConnect(); + return false; + } + // doc 01: the advertising-name read is the last command before READY, and + // the charging follow-up is launched after it. Neither can fail setup. + await _readAdvertisingNameGen5(session); + _maybeStartBatteryPackFollowUp(session); + return true; + } + + /// The bootstrap SET_CLOCK decision (doc 01 §"Clock contract"). + /// + /// Three rules, in this order: + /// 1. the phone-clock deferral still wins — while THIS phone is the suspect + /// party, writing its wall clock onto a possibly-correct strap RTC + /// corrupts the RTC and destroys the evidence (unchanged behaviour); + /// 2. on gen5, below [BootstrapClockGate.toleranceSeconds] of absolute drift + /// the official client makes NO BLE write at all. This app used to send + /// SET_CLOCK unconditionally on every single connect; + /// 3. everything else writes once — including a band with no usable clock + /// correlation (unset/implausible RTC), where the drift is null and + /// leaving the RTC uncorrected is the one genuinely bad outcome. + /// + /// gen4 keeps the unconditional write it has today: its flow is proven, and + /// doc 01 describes the WHOOP 5 bootstrap. + Future _bootstrapSetClock(_Session session) async { + if (_deferForClock) return; + if (session.band.isGen5) { + final drift = _clockRef?.driftSec; + if (!BootstrapClockGate.needsCorrection(drift)) { + _log('[CLOCK] in sync (drift ${drift}s, tolerance ' + '${BootstrapClockGate.toleranceSeconds}s) — no correction needed ' + '(doc 01 "Clock contract"); no SET_CLOCK written.'); + return; + } + } + await setClock(); + } + + /// doc 01 §"Final advertising-name read": `GET_ADVERTISING_NAME(141)` with + /// body `01` and a 5 s timeout is part of the exact bootstrap sequence, sent + /// after the clock step and before READY. + /// + /// "The readiness path does not inspect the returned object or result before + /// transitioning to READY, so this command is part of the exact sequence but + /// is **not** a readiness gate" — so the WRITE is ordered here, and the reply + /// is consumed in the background (same shape as the battery poll): a timeout + /// logs and changes nothing. The name itself lands the way it always has, + /// through the `strap_name` branch of the state absorber. + Future _readAdvertisingNameGen5(_Session session) async { + if (!session.band.isGen5) return; + final out = await _sendAwaited( + Cmd.getCustomAdvertisingName, + const [revision1], + ); + if (!out.written) { + _log('[NAME] GET_ADVERTISING_NAME was never written — not a readiness ' + 'gate (doc 01); setup continues.'); + return; + } + // Consumed, never awaited: leaving the pending entry unarmed would hold a + // registry slot for the full timeout with nobody listening. + unawaited(out.response.then((r) { + if (r == null) { + _log('[NAME] GET_ADVERTISING_NAME went unanswered — not a readiness ' + 'gate (doc 01).'); + } + })); + } + + /// doc 01 §"Charging follow-up": when hello says the band is charging, look + /// up the battery pack it is sitting on, asynchronously, after setup. + /// + /// Never runs off-charger, never runs twice for one session, and is not + /// awaited by anything: "a missing or invalid response must be logged and + /// must **not** move the band out of READY". + void _maybeStartBatteryPackFollowUp(_Session session) { + if (!session.band.isGen5) return; + if (_gen5Hello?.charging != true) return; + if (session.batteryPackFollowUpStarted) return; + session.batteryPackFollowUpStarted = true; + unawaited(_runBatteryPackFollowUp(session)); + } + + /// The follow-up task itself: up to [kBatteryPackInfoAttempts] correlated + /// `GET_BATTERY_PACK_INFO(151)` reads, [kBatteryPackInfoRetryDelay] apart. + /// + /// Session-owned like every other background task here — it checks + /// [_sessionIsStale] before each attempt and after each wait, so a link that + /// drops halfway through stops the loop rather than writing into a dead + /// characteristic for another twenty seconds. + Future _runBatteryPackFollowUp(_Session session) async { + for (var attempt = 1; attempt <= kBatteryPackInfoAttempts; attempt++) { + if (_sessionIsStale(session)) return; + final out = await _sendAwaited( + Cmd.getBatteryPackInfo, + const [], + frameBuilder: (seq) => + cmdGetBatteryPackInfo(seq, profile: session.band), + ); + final info = out.written + ? (await out.response)?.fields['battery_pack_info'] + as BatteryPackInfoResponse? + : null; + if (info != null && + BatteryPackInfoGate.usable( + identifier: info.identifier, + name: info.name, + )) { + _batteryPack = info; + _batteryPackTs = _wallSecs().round(); + _log('[PACK] battery pack identified on attempt $attempt/' + '$kBatteryPackInfoAttempts: address=${info.identifier} ' + 'name="${info.name}" attached=${info.attached} ' + 'type=${info.batteryPackType?.name ?? info.batteryPackTypeRaw}.'); + return; + } + // doc 01: "every unusable attempt is followed by the 5-second delay, + // including the fifth". The band answers before it knows what it is + // sitting on, so an early all-zero address is the expected reply. + await Future.delayed(kBatteryPackInfoRetryDelay); + } + _log('[PACK] no usable GET_BATTERY_PACK_INFO reply after ' + '$kBatteryPackInfoAttempts attempts — nothing changes; the band stays ' + 'READY (doc 01 "Charging follow-up").'); + } + // ── keep-alive + periodic backfill ────────────────────────────────────────── void _keepAliveFire(_Session session) { if (_session != session || !session.connected) return; diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index ef171d58..4b586797 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1296,6 +1296,60 @@ class HelloIdentity { '${eepromFailureSignal ? ' serial=all-zero(EEPROM)' : ''}'; } +/// The bootstrap clock gate from doc 01 §"Clock contract". +/// +/// The official client compares the timestamp hello already returned (or, as a +/// fallback, a `GET_CLOCK` reply) against host time and writes NOTHING below +/// two whole seconds of absolute drift: "Below 2 whole seconds, succeed with no +/// BLE write. At 2 or more, send one `SET_CLOCK(10)`". This app used to send +/// SET_CLOCK unconditionally on every connect, i.e. one guaranteed write per +/// connection that the band never needed. +/// +/// Deliberately NOT part of [ClockPolicy] (sync_policy.dart): that class owns +/// the *repair* rules — a drift over a day, an unset RTC, a phone we do not +/// trust — which are a different question with a different threshold. This is +/// only the bootstrap sequence's "is a correction needed at all" step, and it +/// sits with the rest of the doc-01 bootstrap logic ([HelloIdentity]). +class BootstrapClockGate { + /// Absolute whole-second drift at or above which exactly one SET_CLOCK goes + /// out. Below it the bootstrap makes no BLE write at all. + static const int toleranceSeconds = 2; + + /// [driftSec] is `wall - strapRtc` ([ClockRef.driftSec]); the sign does not + /// matter, only the magnitude. + /// + /// A null drift means no correlation exists at this point — hello carried no + /// timestamp AND the GET_CLOCK fallback went unanswered, or the reading was + /// rejected as implausible (an unset band RTC reads decades low and is never + /// correlated). That must WRITE: an unset RTC left uncorrected stamps every + /// record and every alarm against a clock that was never set, which is the + /// one outcome worse than a redundant write. + static bool needsCorrection(int? driftSec) => + driftSec == null || driftSec.abs() >= toleranceSeconds; +} + +/// Whether a `GET_BATTERY_PACK_INFO(151)` reply actually identifies a pack +/// (doc 01 §"Charging follow-up", doc 03 §`GET_BATTERY_PACK_INFO`). +/// +/// "A response is usable only if its pack address/name field is non-empty and +/// is not `00:00:00:00:00:00`" — the band answers the command while it is still +/// working out what it is sitting on, so an early reply carries the all-zero +/// address, which is why the follow-up retries at all. +/// +/// `attached` is deliberately not part of the gate: the doc names only the +/// address/name field, and the flag is recorded alongside the reading rather +/// than deciding whether the reading counts. +class BatteryPackInfoGate { + /// The "no pack yet" address the band answers with before it knows. + static const String unsetAddress = '00:00:00:00:00:00'; + + static bool usable({required String identifier, required String name}) { + final id = identifier.trim().toLowerCase(); + if (id == unsetAddress) return false; + return id.isNotEmpty || name.trim().isNotEmpty; + } +} + /// The last `STRAP_CONDITION_REPORT(29)` event the band volunteered /// (doc 04 §"Type 48 — events"). /// diff --git a/lib/data/db.dart b/lib/data/db.dart index 5e35bd83..1e194425 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -96,7 +96,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 = 34; + static const int schemaVersion = 35; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -483,6 +483,12 @@ class LocalDb { // rebuilds decoded_onehz from an explicit column list. await _ensureDecodedOneHzBandFields(db); } + if (oldV < 35) { + // Clear the two disproven gen5 columns v34 banked, plus the -50 °C + // skin-temp sentinel. Data-only: the DDL is untouched, so this does + // NOT diverge an upgraded install's schema from a fresh one. + await _retireDisprovenOneHzColumns(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -608,6 +614,39 @@ class LocalDb { } } + /// v35: retire what v34 banked into `on_wrist` / `hr_valid`, and any + /// `skin_temp_c` that is really the sensor's unavailable sentinel. + /// + /// v34 filled `on_wrist` from gen5 v18 body 60 bits 0-1 and `hr_valid` from + /// body 15 bit7. Both readings are disproven: bits 0-1 are the primary-flags + /// bit-8 snapshot (not wear), and bit7 toggles ~50/50 independently of HR + /// presence across 1,587,671 retained records (not validity). `skin_temp_c` + /// could likewise hold the AS6221 -50.00 °C unavailable/error code, which is + /// not a temperature. The writer stopped emitting all three + /// (`sampleFromGen5Historical`); this clears what it already stored, so no + /// future reader can pick up a confident answer the data never supported. + /// + /// DDL-NEUTRAL on purpose: the columns stay, nullable, exactly as v34 created + /// them, so a fresh install and an upgraded one still end at the same schema + /// (the fields remain the right shape should an honest source ever appear). + /// Idempotent — a second run matches no rows. Cheap enough for the iOS + /// open-database watchdog: `decoded_onehz` is bounded by `rawRetentionDays`, + /// this is one scan, and it writes only the rows that carry a value. + static Future _retireDisprovenOneHzColumns(Database db) async { + final have = await _columnsOf(db, 'decoded_onehz'); + // Pre-v34 tables never had the columns; nothing to retire. + if (!have.contains('on_wrist')) return; + await db.execute( + 'UPDATE decoded_onehz SET ' + 'on_wrist = NULL, ' + 'hr_valid = NULL, ' + 'skin_temp_c = CASE WHEN skin_temp_c <= -49.995 THEN NULL ' + 'ELSE skin_temp_c END ' + 'WHERE on_wrist IS NOT NULL OR hr_valid IS NOT NULL ' + 'OR skin_temp_c <= -49.995', + ); + } + static Future _ensureDayResultSkippedColumn(Database db) => _addColumnIfMissing( db, @@ -2255,6 +2294,13 @@ class LocalDb { // Every band-computed column above is NULLABLE ON PURPOSE: only a gen5 band // sends them, and a gen4 row must read back as "not reported", not as zero // steps / 0 °C / "off wrist". No DEFAULT, ever. + // + // `on_wrist` and `hr_valid` currently have NO honest writer at all — the + // gen5 v18 bits once mapped onto them are disproven (see + // `sampleFromGen5Historical` and _retireDisprovenOneHzColumns), so every + // row written from v35 on stores NULL. The columns are kept, nullable and + // correctly shaped, for a source that can actually supply them; they are + // NOT a place to park a plausible-looking bit. await _ensureDecodedOneHzBandFields(db); // Forensic-only lookup by the raw counter; not on any read path. await db.execute( diff --git a/lib/data/models.dart b/lib/data/models.dart index b7cb6ff9..affe2839 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -40,15 +40,26 @@ class Sample { /// baseline before it means anything) this is usable on its first second. final double? skinTempC; - /// The band's own on-wrist determination for this second (2-bit code). + /// The band's own on-wrist determination for this second, if a decoder can + /// ever honestly supply one. **Nothing supplies it today** — gen4 has no such + /// field, and the gen5 v18 bits once read as wear (body 60 bits 0-1) are the + /// primary-flags bit-8 snapshot, disproven as a wear signal. Wear truth lives + /// in the HELLO body, the wrist on/off events and the wear-gated streams, not + /// in a per-second column. Do not re-wire those bits here; see + /// `sampleFromGen5Historical`. final int? onWrist; - /// The band's own "HR and RR are valid this second" flag. + /// The band's own "HR and RR are valid this second" flag, if a decoder can + /// ever honestly supply one. **Nothing supplies it today** — gen5 v18's + /// body-15 bit7 was disproven as a validity flag on 1.59M retained records + /// (it toggles ~50/50 independently of HR presence). HR presence is read off + /// [hr] itself (the decoders already gate it to 25..230, and readers use + /// `hr > 0`), never from this column. final bool? hrValid; /// A second heart-rate byte the band reports alongside [hr]. It CORROBORATES - /// [hr] (agreement runs ~58-75%, best when [hrValid]); it is not a substitute - /// heart rate and must never be displayed as one. + /// [hr] (agreement runs ~58-75%); it is not a substitute heart rate and must + /// never be displayed as one. final int? hrAlt; Sample({ diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index f54f448c..b3b02534 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -55,6 +55,36 @@ Uint8List _buildGen5V18LenientInner({ return inner; } +/// A v18 inner the decoder ACCEPTS (unlike [_buildGen5V18LenientInner], whose +/// gravity vector deliberately fails the magnitude gate), so the whole +/// decode → map → persist path runs. [skinTempRaw] is the AS6221 i16 at body +/// 52; the two flag bytes are the readings T10 disproved. +Uint8List _buildGen5V18DecodableInner({ + required int unix, + required int counter, + required int skinTempRaw, + int hrQualityFlags = 0, + int sleepStateByte = 0, +}) { + final inner = Uint8List(112); + final view = inner.buffer.asByteData(); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[2] = 0x80; + view.setUint32(3, counter, Endian.little); + view.setUint32(7, unix, Endian.little); + inner[14] = 61; // heart rate + inner[15] = 0; // no RR slots + inner[28] = hrQualityFlags; + view.setFloat32(33, 0.5, Endian.little); + view.setFloat32(37, 0.0, Endian.little); + view.setFloat32(41, 0.0, Endian.little); + view.setFloat32(45, 1.0, Endian.little); // magSq 1.0 — inside the gate + view.setInt16(65, skinTempRaw, Endian.little); + inner[73] = sleepStateByte; + return inner; +} + void main() { setUpAll(() async { sqfliteFfiInit(); @@ -108,6 +138,14 @@ void main() { expect(rows.length, 1); expect(rows.first['hr'], 102); expect(rows.first['counter'], sample.counter); + // The band's own calibrated °C reading is real and is kept… + expect((rows.first['skin_temp_c'] as num).toDouble(), closeTo(30.57, 1e-9)); + // …but this second stores NO wear state and NO HR-validity claim, even + // though the capture's body-15 bit7 is SET and its body-60 bits 0-1 read + // 0. Both of those readings are disproven (see sampleFromGen5Historical), + // so the columns must be NULL rather than "valid" / "off wrist". + expect(rows.first['on_wrist'], isNull); + expect(rows.first['hr_valid'], isNull); final rr = await db.query( 'decoded_rr', @@ -159,6 +197,92 @@ void main() { expect(rows.first['az'], 0); }); + // The -50.00 °C sentinel is the sensor saying "I have nothing", and it must + // reach the ledger as NULL. Stored verbatim it is a number 70 °C below any + // wrist sitting in a column readers are entitled to treat as a temperature — + // the exact shape of the fabrication AGENTS.md §3.3 forbids. Nulling one + // field never costs the second: HR and the counter still land. + test('a v18 second whose skin temp is the sentinel stores NULL, not -50', + () async { + const unix = 1785900000; + const counter = 77; + final inner = _buildGen5V18DecodableInner( + unix: unix, + counter: counter, + skinTempRaw: -5000, // the AS6221 unavailable/error code + hrQualityFlags: 0xFF, // every disproven bit set… + sleepStateByte: 0x03, // …on both bytes + ); + final sample = sampleFromGen5Historical(parseGen5Historical(inner)); + expect(sample, isNotNull); + + await LocalDb.commitSyncBatch([ + RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: unix * 1000, + recTs: unix, + ), + ], [ + sample, + ]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [unix], + ); + expect(rows, hasLength(1)); + expect(rows.first['skin_temp_c'], isNull); + expect(rows.first['on_wrist'], isNull); + expect(rows.first['hr_valid'], isNull); + expect(rows.first['hr'], 61, reason: 'the rest of the second survives'); + + // …and it reads back absent through the typed seam too, rather than as a + // temperature, an "off wrist" or an "HR invalid". + final s = (await LocalDb.samplesInRange(unix, unix)).single; + expect(s.skinTempC, isNull); + expect(s.onWrist, isNull); + expect(s.hrValid, isNull); + }); + + // A REAL sub-zero reading is a reading. The sentinel check is exact, so an + // honest cold-wrist value must not be swallowed along with it. + test('a genuine sub-zero skin temperature still persists', () async { + const unix = 1785900060; + const counter = 78; + final inner = _buildGen5V18DecodableInner( + unix: unix, + counter: counter, + skinTempRaw: -1234, + ); + final sample = sampleFromGen5Historical(parseGen5Historical(inner)); + await LocalDb.commitSyncBatch([ + RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: unix * 1000, + recTs: unix, + ), + ], [ + sample, + ]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [unix], + ); + expect( + (rows.single['skin_temp_c'] as num).toDouble(), + closeTo(-12.34, 1e-9), + ); + }); + test('R10-lite + complete preferred → no decoded_onehz row', () async { const ts = 1780000100; const counter = 99; diff --git a/test/gen5_sample_fields_test.dart b/test/gen5_sample_fields_test.dart index e0031f11..91b14399 100644 --- a/test/gen5_sample_fields_test.dart +++ b/test/gen5_sample_fields_test.dart @@ -1,8 +1,8 @@ // The per-second fields a gen5 band computes ITSELF — its pedometer's // cumulative step count and cadence, its activity class, a calibrated skin -// temperature in °C, its on-wrist determination, and the HR-validity flag plus -// the corroborating second HR byte — are decoded off every record and now -// PERSISTED (schema v34) instead of being dropped on the floor. +// temperature in °C and the corroborating second HR byte — are decoded off +// every record and PERSISTED (schema v34) instead of being dropped on the +// floor. // // The invariant these tests exist to protect is ABSENCE, not presence: a gen4 // band sends none of this, so a gen4 second must store NULL. Zeroing them would @@ -10,6 +10,15 @@ // ledger — indistinguishable from a real reading downstream, and exactly the // class of fabrication this codebase keeps having to undo. // +// `on_wrist` and `hr_valid` are the same story taken one step further: the v18 +// bits v34 filled them from are DISPROVEN (body 60 bits 0-1 are the +// primary-flags bit-8 snapshot, not wear; body 15 bit7 is not HR validity), so +// from v35 they have no writer at all and every new row stores NULL. The tests +// here still exercise the columns' storage contract — a nullable INTEGER that +// tells 0 from NULL — because the columns are kept for a source that could one +// day supply them honestly; `gen5_sample_mapping_test.dart` is what pins that +// the real decode path never does. +// // Runs the REAL LocalDb over sqflite_ffi, so the DDL, the migration ladder and // the read paths are the shipping ones. @@ -42,6 +51,37 @@ const _v33DecodedDdl = [ ''', ]; +/// The v34 `decoded_onehz` shape — identical DDL to today's, because v35 is a +/// DATA migration, not a schema one. What a v34 install differs in is its +/// CONTENT: it banked `on_wrist` from gen5 v18 body 60 bits 0-1, `hr_valid` +/// from body 15 bit7, and the raw -50.00 °C skin-temp sentinel. +const _v34DecodedDdl = [ + ''' + CREATE TABLE decoded_onehz ( + rec_ts INTEGER PRIMARY KEY, + counter INTEGER NOT NULL, + hr INTEGER NOT NULL, + ax REAL NOT NULL, ay REAL NOT NULL, az REAL NOT NULL, + spo2_red_raw INTEGER NOT NULL, + spo2_ir_raw INTEGER NOT NULL, + skin_temp_raw INTEGER NOT NULL, + step_count INTEGER, + step_cadence INTEGER, + activity_class INTEGER, + skin_temp_c REAL, + on_wrist INTEGER, + hr_valid INTEGER, + hr_alt INTEGER) +''', + 'CREATE INDEX idx_decoded_onehz_counter ON decoded_onehz(counter)', + ''' + CREATE TABLE decoded_rr ( + rec_ts INTEGER NOT NULL, beat_index INTEGER NOT NULL, + rr_ts_ms INTEGER NOT NULL, rr_ms INTEGER NOT NULL, + PRIMARY KEY (rec_ts, beat_index)) +''', +]; + Future _dbPath(String name) async => p.join(await databaseFactory.getDatabasesPath(), name); @@ -314,4 +354,112 @@ void main() { expect((await LocalDb.schemaHealth())['ok'], isTrue); }); + + // v35. The columns were always the right SHAPE (nullable, no DEFAULT); what + // was wrong was what v34 put in two of them. `on_wrist` came from gen5 v18 + // body 60 bits 0-1 — the primary-flags bit-8 snapshot, not wear — and + // `hr_valid` from body 15 bit7, which toggles ~50/50 independently of HR + // presence across 1,587,671 retained records. `skin_temp_c` could also hold + // the AS6221 -50.00 °C unavailable code. The writer stopped emitting all + // three; this migration retires what it already banked, so a later reader + // cannot pick up a confident answer the data never supported. + test('upgrading a v34 database retires the disproven values it banked', + () async { + const name = 'openstrap_v34_retire_fields_test.db'; + created.add(name); + final path = await _dbPath(name); + await LocalDb.close(); + await databaseFactory.deleteDatabase(path); + + const disproven = 1781000000; // a second v34 filled from the bad bits + const sentinel = 1781000060; // …and one whose skin temp was the error code + const honest = 1781000120; // …and one carrying only real values + final old = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: 34, + onCreate: (db, _) async { + for (final s in _v34DecodedDdl) { + await db.execute(s); + } + }, + ), + ); + Future insert(int recTs, Map extra) => old.insert( + 'decoded_onehz', + { + 'rec_ts': recTs, + 'counter': recTs % 1000, + 'hr': 61, + 'ax': 0.0, + 'ay': 0.0, + 'az': 1.0, + 'spo2_red_raw': 0, + 'spo2_ir_raw': 0, + 'skin_temp_raw': 3000, + ...extra, + }, + ); + await insert(disproven, { + 'skin_temp_c': 30.57, + 'on_wrist': 1, + 'hr_valid': 1, + 'step_count': 8080, + 'hr_alt': 62, + }); + await insert(sentinel, {'skin_temp_c': -50.0, 'on_wrist': 0, 'hr_valid': 0}); + await insert(honest, {'skin_temp_c': 22.5, 'step_count': 8081}); + await old.close(); + + LocalDb.dbName = name; + final db = await LocalDb.instance; + expect( + ((await db.rawQuery('PRAGMA user_version')).first.values.first as num) + .toInt(), + LocalDb.schemaVersion, + ); + + // Both disproven columns are cleared on EVERY row — including the row that + // recorded a confident 0 ("off wrist" / "HR invalid"), which is exactly as + // fabricated as a confident 1. + for (final ts in const [disproven, sentinel, honest]) { + final row = await _rowAt(ts); + expect(row['on_wrist'], isNull, reason: 'on_wrist at $ts'); + expect(row['hr_valid'], isNull, reason: 'hr_valid at $ts'); + } + + // The sentinel becomes absence; real temperatures are untouched. + expect((await _rowAt(sentinel))['skin_temp_c'], isNull); + expect( + ((await _rowAt(disproven))['skin_temp_c'] as num).toDouble(), + closeTo(30.57, 1e-9), + ); + expect( + ((await _rowAt(honest))['skin_temp_c'] as num).toDouble(), + closeTo(22.5, 1e-9), + ); + + // Nothing else in the row was collateral damage — the migration only + // touches the three columns it is about. + final row = await _rowAt(disproven); + expect(row['hr'], 61); + expect(row['skin_temp_raw'], 3000); + expect(row['step_count'], 8080, reason: 'the step counter is REAL (T3)'); + expect(row['hr_alt'], 62); + + // The typed read seam agrees: absent, not "off wrist" / "invalid" / -50. + final s = (await LocalDb.samplesInRange(sentinel, sentinel)).single; + expect(s.skinTempC, isNull); + expect(s.onWrist, isNull); + expect(s.hrValid, isNull); + expect(s.hr, 61); + + expect((await LocalDb.schemaHealth())['ok'], isTrue); + + // Idempotent: reopening runs no migration and changes nothing. + await LocalDb.close(); + await LocalDb.instance; + expect((await _rowAt(honest))['step_count'], 8081); + expect((await _rowAt(disproven))['on_wrist'], isNull); + }); } diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart index 37c611a7..3754d01c 100644 --- a/test/gen5_sample_mapping_test.dart +++ b/test/gen5_sample_mapping_test.dart @@ -25,6 +25,34 @@ Uint8List hex(String s) { return out; } +/// A synthetic v18 inner that `Gen5V18Decoder` actually accepts: valid header, +/// an HR inside 25..230, a dynamic-accel inside 0..8 g and a gravity vector +/// inside the 0.5..1.8 g magnitude gate. Only the three bytes these tests care +/// about — the skin-temp i16 and the two disproven flag bytes — are parameters. +Uint8List v18Inner({ + required int skinTempRaw, + int hrQualityFlags = 0, + int sleepStateByte = 0, +}) { + final inner = Uint8List(kGen5V18InnerLen); + final v = inner.buffer.asByteData(); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[2] = 0x80; + v.setUint32(3, 4242, Endian.little); // record index + v.setUint32(7, 1780916150, Endian.little); // unix + inner[14] = 64; // heart rate + inner[15] = 0; // no RR slots declared + inner[28] = hrQualityFlags; // body 15 — bit7 is the disproven "HR valid" + v.setFloat32(33, 0.5, Endian.little); // dynamic acceleration + v.setFloat32(37, 0.0, Endian.little); // gravity x + v.setFloat32(41, 0.0, Endian.little); // gravity y + v.setFloat32(45, 1.0, Endian.little); // gravity z — magSq 1.0 + v.setInt16(65, skinTempRaw, Endian.little); // AS6221 skin temp, °C = raw/100 + inner[73] = sleepStateByte; // body 60 — bits 0-1 are the disproven "on wrist" + return inner; +} + void main() { group('sampleFromGen5Historical — v18 (real fixture)', () { // "worn" capture, unix=1780916150 — CRC16+CRC32 both verified. Same @@ -75,6 +103,84 @@ void main() { expect(sample!.spo2IrRaw, isNull); }, ); + + test('maps the calibrated °C skin temperature through', () { + expect(sample!.skinTempC, closeTo(30.57, 1e-9)); + }); + + test('claims NO wear state and NO HR-validity for this second', () { + // This capture is the counter-example in the flesh. Its body-15 byte is + // 0x8D — bit7 SET — and its body-60 bits 0-1 are 0, so the mapping that + // used to read those bits recorded "HR is valid" AND "on-wrist code 0" + // for a second the band was plainly worn for (HR 102, a gravity vector + // at 1 g). bit7 is not validity (disproven on 1,587,671 records; it + // toggles ~50/50 independently of HR presence) and bits 0-1 are the + // primary-flags bit-8 snapshot, not wear. Absence is the honest answer. + expect(sample!.hr, 102, reason: 'the wearer definitely had a pulse'); + expect( + sample!.onWrist, + isNull, + reason: 'body 60 bits 0-1 are the primary-flags bit-8 snapshot, ' + 'not a wear determination', + ); + expect( + sample!.hrValid, + isNull, + reason: 'body 15 bit7 is not HR/RR validity — HR presence is `hr`', + ); + }); + }); + + group('sampleFromGen5Historical — v18 skin-temp sentinel', () { + test('-50.00 °C is the unavailable code and maps to null, not a reading', + () { + final s = sampleFromGen5Historical( + parseGen5Historical(v18Inner(skinTempRaw: -5000)), + ); + expect(s, isNotNull); + expect( + s!.skinTempC, + isNull, + reason: 'raw -5000 is the AS6221 unavailable/error sentinel', + ); + // Abstaining on one field never costs the rest of the second. + expect(s.hr, 64); + expect(s.tsEpoch, 1780916150); + }); + + test('a real reading just below the sentinel is NOT swallowed', () { + // The gate is the exact sentinel, not "negative means absent" — an i16 + // skin temp is signed and -12.34 °C is a value, not an error code. + final s = sampleFromGen5Historical( + parseGen5Historical(v18Inner(skinTempRaw: -1234)), + ); + expect(s!.skinTempC, closeTo(-12.34, 1e-9)); + }); + }); + + group('sampleFromGen5Historical — the disproven bits are never read', () { + test('flipping both of them changes nothing in the mapped Sample', () { + Sample map(int quality, int sleepState) => sampleFromGen5Historical( + parseGen5Historical( + v18Inner( + skinTempRaw: 3000, + hrQualityFlags: quality, + sleepStateByte: sleepState, + ), + ), + )!; + + // All bits set vs all bits clear: if either byte were still feeding a + // column, these two seconds would disagree about wear and validity. + final allSet = map(0xFF, 0x03); + final allClear = map(0x00, 0x00); + for (final s in [allSet, allClear]) { + expect(s.onWrist, isNull); + expect(s.hrValid, isNull); + } + expect(allSet.skinTempC, closeTo(30.0, 1e-9)); + expect(allClear.skinTempC, closeTo(30.0, 1e-9)); + }); }); group('sampleFromGen5Historical — non-Sample record kinds', () { diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index dcf26a14..427bccbe 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -13,6 +13,7 @@ import 'dart:typed_data'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; import 'package:openstrap_edge/ble/ble_state.dart'; @@ -446,6 +447,7 @@ void main() { }); _events(); + _bootstrap(); } /// A type-48 EVENT inner: @@ -553,3 +555,419 @@ void _events() { }); }); } + +// ── T11: the doc-01 bootstrap sequence ────────────────────────────────────── +// +// doc 01 §"Phase sequence" specifies the exact order — and the exact silences — +// between the bond and READY. Four of its steps were missing here: +// - the two observed client delays (600 ms before notification registration, +// 500 ms after the last CCC write); +// - the ≥2 s clock gate: this app wrote SET_CLOCK on EVERY connect, where the +// official client makes no BLE write at all below two whole seconds of +// drift; +// - GET_ADVERTISING_NAME(141) as the final pre-READY command (sent, never a +// readiness gate); +// - the charging follow-up, GET_BATTERY_PACK_INFO(151) ×5, 5 s apart, which +// must never touch READY and must never run off the charger. +// All four are gen5-only: doc 01 describes the WHOOP 5 bootstrap, and gen4's +// flow is hardware-proven, so these tests also pin gen4's *absence* of them. + +/// A gen4/gen5 link with no radio behind it that records every command written +/// and can answer selected opcodes from inside the write itself. +class _BootstrapLink { + final logs = []; + final commands = <({int seq, int opcode, List body})>[]; + final afterSupersede = []; + final BandProfile band; + + /// Answers to inject as the reply to a written command. Injected from INSIDE + /// the write, i.e. before `_sendAwaited` has even returned — the ordering + /// doc 02 demands. + Decoded? Function(int seq, int opcode)? replyTo; + + late final BleEngine engine; + + _BootstrapLink({this.band = BandProfile.gen5}) { + engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + ); + 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))); + final reply = replyTo?.call(inner[1], inner[2]); + if (reply != null) engine.debugAbsorbDecoded(reply); + return true; + }, + ); + } + + List get opcodes => commands.map((c) => c.opcode).toList(); + int count(int opcode) => opcodes.where((o) => o == opcode).length; + bool logged(String needle) => logs.any((l) => l.contains(needle)); + + /// Replace the live session. A task that captured the old session now sees + /// `_session != session` — exactly what a dropped-and-reconnected link looks + /// like from inside a background loop. + void supersedeSession() { + engine.debugInstallFakeLink( + band: band, + onWrite: (frame) async { + afterSupersede.add(parseFrame(frame, profile: band)!.inner[2]); + return true; + }, + ); + } +} + +/// A revision-1 gen5 hello body (doc 01 §"Revision-1 hello body"), parsed by +/// the real protocol decoder so the timestamp and charge bit under test are the +/// ones a band would actually produce. +Uint8List _gen5HelloBody({required int tsSeconds, bool charging = false}) { + final body = Uint8List(Gen5HelloInfo.semanticBodyLen); + final v = ByteData.sublistView(body); + body[0] = 1; // hello revision + v.setUint32(1, 730, Endian.little); // 73.0% → 73 + body[5] = charging ? 1 : 0; // charge-status bitfield, bit 0 = charging + v.setUint32(6, tsSeconds, Endian.little); + const serial = 'W5AB12CD34'; + for (var i = 0; i < serial.length; i++) { + body[14 + i] = serial.codeUnitAt(i); + } + v.setUint32(87, 82, Endian.little); // optical discriminator ⇒ WHOOP 5 + body[91] = 50; + body[92] = 40; + body[93] = 1; // firmware 50.40.1 + body[102] = 1; // on wrist + return body; +} + +Decoded _helloReply(int seq, {required int tsSeconds, bool charging = false}) => + Decoded('cmd_response', { + 'opcode': Cmd.getHello, + 'req_seq': seq, + 'cmd_status': CommandAwaiter.statusSuccess, + 'gen5_hello': Gen5HelloInfo.parse( + _gen5HelloBody(tsSeconds: tsSeconds, charging: charging), + )!, + }); + +Decoded _packReply(int seq, {required String address, String name = ''}) => + Decoded('cmd_response', { + 'opcode': Cmd.getBatteryPackInfo, + 'req_seq': seq, + 'cmd_status': CommandAwaiter.statusSuccess, + 'battery_pack_info': BatteryPackInfoResponse( + revision: 1, + attached: true, + identifier: address, + name: name, + batteryPackTypeRaw: 12, // puffin + statusRaw: 0, + ), + }); + +/// Run the real post-registration bootstrap to completion under [async]. +/// Returns whether it reported success. +bool _runBootstrap(_BootstrapLink link, FakeAsync async) { + bool? ok; + link.engine.debugBootstrapAfterRegistration().then((v) => ok = v); + // Long enough for the 500 ms delay plus every awaited step's own timeout + // (the 3 s clock read on gen4, the 5 s command timeout on gen5), but short + // of the charging follow-up's first 5 s retry gap. + async.elapse(const Duration(seconds: 4)); + return ok ?? false; +} + +void _bootstrap() { + group('T11 — the two delays (doc 01)', () { + test('gen5 writes nothing for 500 ms after the last CCC write', () { + fakeAsync((async) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow()) + : null; + link.engine.debugBootstrapAfterRegistration(); + + async.elapse(const Duration(milliseconds: 499)); + expect(link.commands, isEmpty, + reason: 'doc 01: 500 ms after registration, before the ' + 'higher-level state machine runs'); + async.elapse(const Duration(milliseconds: 2)); + expect(link.opcodes.first, Cmd.getHello, + reason: 'and GET_HELLO is the first thing out after it'); + }); + }); + + test('the delays are the observed 600/500 ms and gen5-only', () { + expect(BleEngine.kGen5PreRegistrationDelay, + const Duration(milliseconds: 600)); + expect(BleEngine.kGen5PostRegistrationDelay, + const Duration(milliseconds: 500)); + fakeAsync((async) { + final link = _BootstrapLink(band: BandProfile.gen4); + link.engine.debugBootstrapAfterRegistration(); + async.flushMicrotasks(); + expect(link.opcodes, [Cmd.getClock], + reason: 'gen4 keeps its proven flow: no delay, straight to the ' + 'clock read'); + }); + }); + + test('a link that dies during the delay abandons setup', () { + fakeAsync((async) { + final link = _BootstrapLink(); + bool? ok; + link.engine.debugBootstrapAfterRegistration().then((v) => ok = v); + // The link is replaced (reconnected) while the bootstrap sleeps. + link.supersedeSession(); + async.elapse(const Duration(seconds: 1)); + + expect(ok, isFalse); + expect(link.commands, isEmpty, + reason: 'nothing may go out on a session that is gone'); + expect(link.logged('link dropped during the post-registration delay'), + isTrue); + }); + }); + }); + + group('T11 — the ≥2 s SET_CLOCK gate (doc 01 "Clock contract")', () { + test('BootstrapClockGate: below two whole seconds, no correction', () { + expect(BootstrapClockGate.toleranceSeconds, 2); + expect(BootstrapClockGate.needsCorrection(0), isFalse); + expect(BootstrapClockGate.needsCorrection(1), isFalse); + expect(BootstrapClockGate.needsCorrection(-1), isFalse); + expect(BootstrapClockGate.needsCorrection(2), isTrue, + reason: 'the threshold is inclusive: "at 2 or more, send one"'); + expect(BootstrapClockGate.needsCorrection(-2), isTrue, + reason: 'the doc compares the ABSOLUTE delta'); + expect(BootstrapClockGate.needsCorrection(null), isTrue, + reason: 'no correlation at all — an unset band RTC must never be ' + 'left uncorrected'); + }); + + test('a band whose clock agrees is not written to at all', () { + fakeAsync((async) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow()) + : null; + expect(_runBootstrap(link, async), isTrue); + + expect(link.count(Cmd.setClock), 0, + reason: 'doc 01: below 2 s, succeed with NO BLE write'); + expect(link.count(Cmd.getClock), 0, + reason: 'and no read-back either — nothing was written'); + expect(link.logged('no correction needed'), isTrue); + }); + }); + + test('a band 3 s out gets exactly one SET_CLOCK', () { + fakeAsync((async) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow() - 3) + : null; + expect(_runBootstrap(link, async), isTrue); + + expect(link.count(Cmd.setClock), 1, + reason: 'doc 01: at 2 or more, send ONE SET_CLOCK'); + expect(link.opcodes.indexOf(Cmd.setClock), + greaterThan(link.opcodes.indexOf(Cmd.getHello)), + reason: 'the clock decision comes after hello supplies the time'); + }); + }); + + test('the phone-clock deferral still beats the drift gate', () { + fakeAsync((async) { + final link = _BootstrapLink(); + // A plausible strap RTC two days AHEAD of us: the phone is the suspect + // party, and the read is too far out to be correlated — so the drift is + // null and the gate alone would write. The deferral must win. + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow() + 2 * 86400) + : null; + expect(_runBootstrap(link, async), isTrue); + + expect(BootstrapClockGate.needsCorrection(null), isTrue, + reason: 'the gate would have written…'); + expect(link.count(Cmd.setClock), 0, reason: '…and must not have'); + expect(link.engine.historyPausedForClock, isTrue); + }); + }); + + test('gen4 keeps its unconditional SET_CLOCK', () { + fakeAsync((async) { + final link = _BootstrapLink(band: BandProfile.gen4); + expect(_runBootstrap(link, async), isTrue); + + expect(link.opcodes, [Cmd.getClock, Cmd.setClock, Cmd.getClock], + reason: 'read → unconditional write → read-back, unchanged'); + }); + }); + }); + + group('T11 — GET_ADVERTISING_NAME is the final pre-READY step (doc 01)', () { + test('gen5 sends it last, after the clock step', () { + fakeAsync((async) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow() - 3) + : null; + expect(_runBootstrap(link, async), isTrue); + + expect(link.opcodes.last, Cmd.getCustomAdvertisingName); + expect(link.commands.last.body.first, revision1, + reason: 'doc 01: body 01'); + }); + }); + + test('an unanswered name read does not fail setup', () { + fakeAsync((async) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: _wallNow()) + : null; + // Nothing ever answers opcode 141 here. + expect(_runBootstrap(link, async), isTrue, + reason: 'doc 01: the response content and result are NOT a ' + 'readiness gate'); + async.elapse(const Duration(seconds: 6)); + expect(link.logged('GET_ADVERTISING_NAME went unanswered'), isTrue); + expect(link.engine.pendingCommandCount, 0, + reason: 'the unawaited response is still consumed'); + }); + }); + + test('gen4 sends no advertising-name read during setup', () { + fakeAsync((async) { + final link = _BootstrapLink(band: BandProfile.gen4); + expect(_runBootstrap(link, async), isTrue); + expect(link.opcodes, isNot(contains(Cmd.getCustomAdvertisingName))); + expect(link.opcodes, isNot(contains(Cmd.getAdvertisingNameHarvard))); + }); + }); + }); + + group('T11 — the charging follow-up, opcode 151 (doc 01)', () { + /// Bootstrap a gen5 link whose hello reports [charging], answering + /// GET_BATTERY_PACK_INFO with [packAddress] when one is given. + _BootstrapLink chargingRig( + FakeAsync async, { + required bool charging, + String? packAddress, + String packName = '', + }) { + final link = _BootstrapLink(); + link.replyTo = (seq, op) { + if (op == Cmd.getHello) { + return _helloReply(seq, tsSeconds: _wallNow(), charging: charging); + } + if (op == Cmd.getBatteryPackInfo && packAddress != null) { + return _packReply(seq, address: packAddress, name: packName); + } + return null; + }; + expect(_runBootstrap(link, async), isTrue, + reason: 'the follow-up never blocks READY'); + return link; + } + + test('BatteryPackInfoGate: only a real address/name is usable', () { + expect( + BatteryPackInfoGate.usable( + identifier: '00:00:00:00:00:00', name: 'Puffin'), + isFalse, + reason: 'the all-zero address identifies nothing'); + expect(BatteryPackInfoGate.usable(identifier: '', name: ''), isFalse); + expect(BatteryPackInfoGate.usable(identifier: ' ', name: ' '), isFalse); + expect( + BatteryPackInfoGate.usable( + identifier: 'aa:bb:cc:dd:ee:ff', name: ''), + isTrue); + expect(BatteryPackInfoGate.usable(identifier: '', name: 'Puffin'), isTrue); + }); + + test('it never runs when the band is not charging', () { + fakeAsync((async) { + final link = chargingRig(async, charging: false); + async.elapse(const Duration(seconds: 40)); + expect(link.count(Cmd.getBatteryPackInfo), 0, + reason: 'doc 01: this lookup does not run on a non-charging ' + 'READY transition'); + }); + }); + + test('a charging band is asked five times, five seconds apart', () { + fakeAsync((async) { + final link = + chargingRig(async, charging: true, packAddress: '00:00:00:00:00:00'); + expect(link.count(Cmd.getBatteryPackInfo), 1); + expect(link.commands.last.body.first, revision1, + reason: 'doc 01/03: body 01'); + + for (var expected = 2; expected <= 5; expected++) { + async.elapse(const Duration(seconds: 5)); + expect(link.count(Cmd.getBatteryPackInfo), expected); + } + // doc 01: the fifth unusable attempt is followed by the delay too. + async.elapse(const Duration(seconds: 5)); + expect(link.count(Cmd.getBatteryPackInfo), BleEngine.kBatteryPackInfoAttempts, + reason: 'five attempts, and no sixth'); + expect(link.logged('no usable GET_BATTERY_PACK_INFO reply'), isTrue); + expect(link.engine.offloadSnapshot['battery_pack_address'], isNull, + reason: 'an all-zero address is never stored as a reading'); + }); + }); + + test('a usable reply stops the retries and reaches the snapshot', () { + fakeAsync((async) { + final link = chargingRig( + async, + charging: true, + packAddress: 'aa:bb:cc:dd:ee:ff', + packName: 'Puffin', + ); + async.elapse(const Duration(seconds: 40)); + + expect(link.count(Cmd.getBatteryPackInfo), 1, + reason: 'the first usable answer ends the task'); + final snap = link.engine.offloadSnapshot; + expect(snap['battery_pack_address'], 'aa:bb:cc:dd:ee:ff'); + expect(snap['battery_pack_name'], 'Puffin'); + expect(snap['battery_pack_attached'], isTrue); + expect(snap['battery_pack_type'], 'puffin'); + expect(snap['battery_pack_ts'], isNotNull); + }); + }); + + test('it dies with the session', () { + fakeAsync((async) { + final link = + chargingRig(async, charging: true, packAddress: '00:00:00:00:00:00'); + expect(link.count(Cmd.getBatteryPackInfo), 1); + + link.supersedeSession(); + async.elapse(const Duration(seconds: 40)); + + expect(link.count(Cmd.getBatteryPackInfo), 1, + reason: 'the loop checks the session before every attempt'); + expect(link.afterSupersede, isEmpty, + reason: 'and never writes onto the new link either'); + }); + }); + + test('gen4 never starts the follow-up', () { + fakeAsync((async) { + final link = _BootstrapLink(band: BandProfile.gen4); + expect(_runBootstrap(link, async), isTrue); + async.elapse(const Duration(seconds: 40)); + expect(link.opcodes, isNot(contains(Cmd.getBatteryPackInfo))); + }); + }); + }); +} From e35e406dbea2add69809d13422f0544e77ee714d Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Wed, 19 Aug 2026 17:28:24 +0200 Subject: [PATCH 04/11] count burst members in arrival order, and make the 15th failure terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-found on a live strap (fw 50.40.1.0): a burst sat permanently short at expected=16 actual=12 through fifteen retries, then the abort restarted every ~2.5 s. Root cause: GATT delivers notifications in true order across characteristics, but the app reordered them internally — data frames ride the serialized offload queue while event/console frames were counted at notification time, so a burst's members landed in whichever window happened to be open. The re-offers showed it directly: the starved burst's console frames surfaced as a growing surplus on the burst before it. Count-member frames now enter the same serialized queue at their true arrival position; their PROCESSING stays immediate (wrist/battery/alarm handling never waits on an offload commit) — only the burst count rides the queue. The old advisory "completeness would-flag" line claimed missing/corrupted frames for what were mis-binned members; it was the same counter as the gate minus slack, so it now says what is actually true: the burst passed on slack and the band will trim frames we did not count. Type-47 frames without a decoder are members too: the deep buffers (v20/v21/v26/v22) and any future firmware's revisions arrive through the archive path, which counted nothing — on an R22-enabled strap that starves the gate in exactly the same way. Archived frames now feed the same per-revision counter the decoded path uses. Gate-dropped records stay excluded; they are added back separately. The 15th failed validation is terminal for the session now: one abort, re-offered markers are dropped without re-validating, and every same- session drain trigger (periodic, foreground, auto-continue, the backfill continuation loop) is refused through the single refresh choke point. A reconnect clears the latch, so a fresh session drains normally. --- lib/ble/ble_engine.dart | 260 ++++++++++++++++++++++++------ lib/ble/ble_state.dart | 25 +++ lib/state/app_state.dart | 12 ++ test/gen5_wiring_test.dart | 319 +++++++++++++++++++++++++++++++++++++ 4 files changed, 570 insertions(+), 46 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 9a123a09..b7a84026 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -192,6 +192,21 @@ int countBurstTrafficPackets({ unknownCount; } +/// Whether a NON-data frame is a burst count member (doc 05 §"Count +/// membership"): each complete type-48 event, type-50 console log and the +/// three battery-pack ("puffin") wrappers 53/54/55 counts exactly once toward +/// `HISTORY_END.expected_count`. Type 47 is counted on the data path instead +/// (it is what `dataPacketCountsByRevision` tallies); type 49 metadata NEVER +/// counts — it defines the burst boundaries; the 51/52 IMU streams are not +/// members of this count path at all. +@visibleForTesting +bool isBurstCountMemberType(int packetType) => + packetType == PacketType.event || + packetType == PacketType.consoleLogs || + packetType == PacketType.relativePuffinEvents || + packetType == PacketType.puffinEventsFromStrap || + packetType == PacketType.relativeBatteryPackConsoleLogs; + @visibleForTesting bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => offloadActive; @@ -429,6 +444,23 @@ class _Session { /// the same link cannot start a second retry loop against the same band. bool batteryPackFollowUpStarted = false; + /// Terminal `Stuck` latch (doc 05 §"Retry boundary"): set when a burst has + /// failed validation [kBurstValidationAttemptLimit] times and the abort went + /// out. From then on this session's history is OVER — no further drain + /// trigger, and no re-validating a burst the band keeps re-offering. + /// "Failed validation 15 → terminal Stuck, no same-session retry; + /// continuation comes from a later connection or scheduler event." Being + /// session-scoped is the whole mechanism: a reconnect builds a new [_Session] + /// and the next connection drains normally from the band's checkpoint. + bool historyStuck = false; + + /// Markers dropped, and drain triggers refused, by [historyStuck] + /// (diagnostics). Each kind logs its FIRST occurrence and then stays silent: + /// the band re-offers roughly every 2.5 s, and the whole point of the latch + /// is to stop that from generating traffic and log noise. + int stuckMarkersDropped = 0; + int stuckRefreshesRefused = 0; + _Session(this.device); Future teardown() async { @@ -909,6 +941,23 @@ class BleEngine { @visibleForTesting void debugProcessImmediateFrame(Frame frame) => _processImmediateFrame(frame); + /// Feed one inbound frame through the REAL receive path, including + /// [FrameRoutePolicy] and the serialized offload queue — i.e. the thing that + /// decides which burst window a frame's count lands in. + /// + /// [debugProcessImmediateFrame] and [debugIngestHistoricalFrame] both start + /// past that decision, so neither can express the one property that matters + /// here: that a burst's data frames, its event/console members and its + /// HISTORY_END are all handled in the order the band put them on the wire. + /// [role] is the characteristic the frame was reassembled on ('data', + /// 'events', 'cmd_from'), which is exactly what the ordering hazard is about. + @visibleForTesting + void debugReceiveFrame(Frame frame, {String role = 'data'}) { + final session = _session; + if (session == null) return; + _onFrame(role, frame, session); + } + /// Drive the canonical historical-refresh path. Returns whether /// SEND_HISTORICAL_DATA actually went out. @visibleForTesting @@ -1368,6 +1417,13 @@ class BleEngine { bool get offloadActive => _offloadActive; + /// True once this connection's history hit the terminal `Stuck` boundary + /// (doc 05 §"Retry boundary"): a burst failed validation + /// [kBurstValidationAttemptLimit] times and the abort went out. Nothing may + /// start another drain on this link; continuation belongs to a later + /// connection. Callers that loop over sync sessions must stop on it. + bool get historyStuckThisSession => _session?.historyStuck ?? false; + Map get offloadSnapshot => { 'active': _offloadActive, 'queued_frames': _offloadFrames.length, @@ -1391,6 +1447,11 @@ class BleEngine { // a *streak* of mismatches is a real signal worth watching over time. 'burst_mismatch_total': _burstMismatchTotal, 'burst_mismatch_streak': _burstMismatchStreak, + // Terminal `Stuck` for this connection (doc 05 §"Retry boundary") and how + // much re-offer/re-trigger traffic the latch has since absorbed. + 'history_stuck': _session?.historyStuck ?? false, + 'stuck_markers_dropped': _session?.stuckMarkersDropped ?? 0, + 'stuck_refreshes_refused': _session?.stuckRefreshesRefused ?? 0, // Band-reboot signal — see CounterRegressionDetector. Observability only; // recovery already happens automatically at the DB layer. 'counter_regressions_total': _counterRegression.regressions, @@ -2290,7 +2351,25 @@ class BleEngine { bool refreshRange = true, }) async { final d = _drain; - if (_session?.connected != true || d == null) return false; + final session = _session; + if (session?.connected != true || d == null) return false; + // Terminal `Stuck` (doc 05 §"Retry boundary"): "no same-session retry — + // continuation comes from a later connection, scheduler tick or explicit + // trigger." Every in-session trigger routes through here — periodic + // backfill, foreground/manual resync, auto-continue and the backfill + // continuation loop — so refusing here closes all of them at once. The + // FIRST drain of a fresh session is untouched: the latch lives on the + // session object, so a reconnect clears it. + if (session!.historyStuck) { + session.stuckRefreshesRefused++; + if (session.stuckRefreshesRefused == 1) { + _log( + '[SYNC] refresh($reason) refused — history is terminal (Stuck) for ' + 'this connection; the band keeps its checkpoint until the next one.', + ); + } + return false; + } if (_offloadActive && !d._complete) { _log( '[SYNC] refresh($reason) dropped — strap is already transmitting history.', @@ -2777,12 +2856,22 @@ class BleEngine { isMetadata: pt == PacketType.metadata, isHistorical: pt == PacketType.historicalData, isDataRole: role == 'data', + isBurstCountMember: isBurstCountMemberType(pt), + offloadActive: _offloadActive, ); - if (route == FrameRoute.serializedQueue) { - _enqueueOffloadFrame(frame, session); - return; + switch (route) { + case FrameRoute.serializedQueue: + _enqueueOffloadFrame(frame, session); + case FrameRoute.immediateAndCount: + // Process inline first (unchanged behaviour: wrist/battery/alarm and + // console text must not wait behind an offload commit), then enqueue + // the SAME frame so only its burst COUNT is applied in arrival order, + // in the burst window the band sent it in. See [FrameRoute]. + _processImmediateFrame(frame); + _enqueueOffloadFrame(frame, session); + case FrameRoute.immediate: + _processImmediateFrame(frame); } - _processImmediateFrame(frame); } void _processImmediateFrame(Frame frame) { @@ -2833,32 +2922,21 @@ class BleEngine { 'inner=${_innerHex(frame.inner)}', ); } else if (pt == PacketType.event) { - if (_offloadActive) { - _drain?.onBurstEvent(); - } + // NOTE: the burst COUNT for this frame is NOT applied here. Events, + // console logs and puffin wrappers are count members (doc 05 §"Count + // membership") but they arrive on a different characteristic than the + // data frames, so counting them at notification time put them in + // whichever burst window happened to be open rather than the one the + // band sent them in. The count now rides the serialized queue at this + // frame's arrival position — see [FrameRoute.immediateAndCount] and + // [_countQueuedBurstMember]. Event PROCESSING stays right here: nothing + // about wrist/battery/alarm handling may wait on an offload commit. _log('[EVENT] ${_innerHex(frame.inner)}'); final e = parseEvent(frame.inner); if (e != null) { _handleEventInfo(e); onEvent?.call(e.eventId, e.tsEpoch, _innerHex(frame.inner)); } - } else if (pt == PacketType.consoleLogs && _offloadActive) { - _drain?.onBurstConsole(); - } else if (_offloadActive && - (pt == PacketType.relativePuffinEvents || - pt == PacketType.puffinEventsFromStrap || - pt == PacketType.relativeBatteryPackConsoleLogs)) { - // Battery-pack ("puffin") event/log wrappers, types 53/54/55. The strap - // COUNTS these in the burst total it reports at HISTORY_END, and they - // were counted nowhere here — so any burst carrying one looked short by - // exactly that many frames. That is not hypothetical: a retained capture - // has a checkpoint of 24 ordinary packets plus three type-54 wrappers - // reported as `expected = 27`, which fails 27/24 forever until the - // wrappers are counted (reversing-whoop doc 05, "History count - // membership" — each complete 47/48/50/53/54/55 frame counts once, and - // type 49 metadata never does). - _drain?.onBurstEvent(); - _log('[SYNC] puffin wrapper type=$pt counted as a burst member'); } final band = _session?.band ?? BandProfile.gen4; final decoded = _maybeAugmentClockEpoch( @@ -2917,8 +2995,13 @@ class BleEngine { if (_sessionIsStale(session)) return; if (frame.packetType == PacketType.metadata) { await _handleSyncMarker(frame, session); - } else { + } else if (frame.packetType == PacketType.historicalData) { _ingestHistoricalFrame(frame); + } else { + // A count member that was already processed inline + // ([FrameRoute.immediateAndCount]) and is here only to have its + // burst count applied in arrival order. + _countQueuedBurstMember(frame); } } if (_offloadFrames.isNotEmpty) { @@ -2933,6 +3016,40 @@ class BleEngine { } } + /// Apply the burst count for one non-data count member (type 48/50/53/54/55) + /// that has already been processed inline, now that the serialized queue has + /// reached its arrival position. + /// + /// This is the ONLY place these families increment the burst count. The band + /// reports `expected_count = data_pkt_cnt + event_pkt_cnt` for the frames it + /// transmitted between HISTORY_START and HISTORY_END; counting here — behind + /// the same queue that carries the data frames and both markers — is what + /// makes our tally cover the same window. A member counted at notification + /// time instead could land before its burst's HISTORY_START (where `rearm()` + /// wipes it) or after its HISTORY_END had already validated, which is how a + /// burst carrying several of them went permanently short by ~4 frames + /// against `expected=16, actual=12, breakdown={V18=12}` (field capture, + /// 2026-08-19, fw 50.40.1.0). + void _countQueuedBurstMember(Frame frame) { + final d = _drain; + if (d == null) return; + final pt = frame.packetType; + if (pt == PacketType.consoleLogs) { + d.onBurstConsole(); + return; + } + // Type 48 events and the battery-pack ("puffin") wrappers 53/54/55 all + // count once each, on the band's event counter. The wrappers were counted + // nowhere at all before the count gate landed: a retained capture has a + // checkpoint of 24 ordinary packets plus three type-54 wrappers reported as + // `expected = 27`, which fails 27/24 forever until they are counted (doc 05 + // §"Count membership"). + d.onBurstEvent(); + if (pt != PacketType.event) { + _log('[SYNC] puffin wrapper type=$pt counted as a burst member'); + } + } + /// True once [session] is no longer the engine's live session — the guard /// every long-parked offload callback shares. bool _sessionIsStale(_Session session) => @@ -3463,6 +3580,15 @@ class BleEngine { // Terminal. One abort, no 15th failure result, and NO same-session // auto-retry: the strap keeps the uncommitted checkpoint and a later // connection resumes from it. + // + // LATCH IT. Sending the abort is not by itself terminal: the band goes on + // re-offering the same HISTORY_END about every 2.5 s until it gets a + // result, and every re-offer used to re-enter validation — which was + // already past the limit, so it aborted again. A field capture shows that + // loop running 14+ times in 12 s, and the 60 s idle timeout then handing + // the whole 15-failure cycle to the backfill continuation. Terminal has + // to mean terminal for the session (doc 05 §"Retry boundary"). + session.historyStuck = true; _log( '[SYNC] burst still short after ' '${d.consecutiveValidationFailures} attempts — aborting history for ' @@ -3651,6 +3777,23 @@ class BleEngine { if (_sessionIsStale(session)) return; final m = parseMetadata(frame.inner); if (m == null) return; + // Terminal `Stuck` (doc 05 §"Retry boundary"): this session's history ended + // with the abort. The band does not know that yet and re-offers the burst + // every ~2.5 s; each re-offer must be dropped, NOT re-validated and + // re-aborted. The idle watchdog is deliberately not re-armed either — there + // is nothing left to wait for on this link. + if (session.historyStuck) { + session.stuckMarkersDropped++; + if (session.stuckMarkersDropped == 1) { + _log( + '[SYNC] history is terminal (Stuck) for this connection — dropping ' + 'the re-offered marker without validating or aborting again. ' + 'Further re-offers are silent; the band keeps its checkpoint and a ' + 'later connection resumes from it.', + ); + } + return; + } _armIdleWatchdog(); _log( '[SYNC] META sub=${m.sub} inner=' @@ -3706,21 +3849,26 @@ class BleEngine { final expected = m.expectedPacketCount; // Records the plausibility gate silently rejected THIS burst (stale/ // wandering-clock block — by design, "neither stored nor counted", - // see RecordGate.admit) never reach onHistoricalRecord/ - // onUndecodableRecord, so they never entered currentBurstPacketCount. + // see RecordGate.admit) DO reach onUndecodableRecord as + // kGateDroppedReason archives, but that path deliberately skips the + // burst count for them, so they never entered currentBurstPacketCount. final droppedThisBurst = _recordGate.dropped - _burstDroppedAtStart; + // Read before validateBurst, which zeroes the counter on a pass — this is + // the attempt number, and the slack, the gate actually judged this burst + // under. + final failuresBefore = d.consecutiveValidationFailures; final validated = expected == null || d.validateBurst( expectedPacketCount: expected, droppedThisBurst: droppedThisBurst, ); - // Honest, LOG-ONLY completeness signal (never gates the ACK). Compares - // num_packets against the ALL-TYPES received total (currentBurstTrafficCount), - // not the banked R24 subset — see burstPacketShortfall. Only a POSITIVE - // shortfall means frames the band counted that we did not count as valid - // received traffic (missing OR CRC-corrupted — potential loss); this is - // the signal we want visible in telemetry BEFORE ever wiring a FAIL gate - // (which needs its own design + field validation to avoid re-flood). + // How far short of the band's count this burst is, on the SAME all-types + // tally the gate above just used (`currentBurstTrafficCount` and + // `currentBurstPacketCount` are one number, not two counters). The gate + // is one-sided WITH slack; this is the raw gap without it, so the only + // case where the two differ is a burst that passed on slack — which is + // exactly what the log below reports. Positive means member frames the + // band counted and we did not. final shortfall = expected == null ? 0 : burstPacketShortfall( @@ -3787,19 +3935,27 @@ class BleEngine { } else { _burstMismatchStreak = 0; } - // Would-flag: the correct-signal completeness diagnostic. LOG-ONLY — the - // commit + verbatim-token ACK below are unchanged. A positive shortfall - // is the honest missing/corrupted-traffic telemetry we want to watch - // before a later, field-validated FAIL gate ever acts on it. + // Reaching here means the gate PASSED. A positive shortfall therefore + // means it passed on the doc-05 slack (2 from the 4th attempt) rather + // than on a complete burst — worth one line, because the ACK below trims + // flash for frames we never tallied. + // + // This used to be logged as a separate "burst completeness would-flag" + // with its own missing/CRC-loss story, which read like a SECOND + // completeness counter disagreeing with the gate. It never was one: both + // lines have always come from the same all-types tally, and the only + // difference is the slack. In the field capture that produced this + // change, its "potential loss" reading was wrong too — the missing frames + // were the burst's own event/console members, counted into a different + // burst window by the ordering bug this commit fixes, not lost on air. if (shortfall > 0) { _log( - '[SYNC] burst completeness would-flag (LOG-ONLY, commit+ACK ' - 'unchanged): expected=$expected ' - 'received=${d.currentBurstTrafficCount} ' - 'dropped_this_burst=$droppedThisBurst shortfall=$shortfall ' - '(all-types received total — frames the band counted that we did ' - 'not; missing or CRC-corrupted, potential loss; groundwork for a ' - 'future FAIL gate, NOT gating today)', + '[SYNC] burst passed the count gate ON SLACK: expected=$expected ' + 'counted=${d.currentBurstTrafficCount} ' + 'dropped_this_burst=$droppedThisBurst short_by=$shortfall ' + '(attempt ${failuresBefore + 1}, slack ' + '${burstCountSlack(failuresBefore)}) — committing ' + 'and ACKing; the band will trim frames we did not count.', ); } final r = d.bufferedRecTsRange; @@ -5419,6 +5575,18 @@ class DrainController { if (a.reason != kGateDroppedReason) { records++; recordsThisOffload++; + // The band's expected count tallies every type-47 frame it TRANSMITTED, + // decodable or not (doc 05: "unknown revisions still count"). The gen5 + // deep buffers (v20/v21/v26/v22) and any future firmware's revisions all + // arrive through this path, so leaving them uncounted makes every burst + // that carries one permanently short at the count gate. Same counter the + // decoded path uses, so the breakdown line stays truthful (V22=…, + // unknown=…). Gate-dropped archives stay excluded: validateBurst adds + // them back via droppedThisBurst, and counting them here too would + // double-count. + if (a.packetType == PacketType.historicalData) { + burstStats.onHistoricalData(a.packetType, a.counter, null, a.hex); + } } _lastProgressAt = DateTime.now(); if (_buffering) { diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 4b586797..629e4484 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -495,6 +495,23 @@ enum FrameRoute { /// Handled inline (command responses, events, live high-rate frames). immediate, + + /// Handled inline AND enqueued on the serialized queue at its true arrival + /// position, where the burst COUNT for it is applied. + /// + /// Burst count members that are not type-47 data (events 48, console 50, + /// puffin wrappers 53/54/55 — doc 05 §"Count membership") arrive on a + /// different characteristic than the data frames but over the SAME ACL link, + /// so the band's transmit order is the arrival order. Counting them inline + /// while the data frames and their HISTORY_END queue up REORDERS the count: + /// a member could be tallied into the burst before its HISTORY_START opened + /// the window (where the next rearm wipes it) or after its HISTORY_END had + /// already validated — which is exactly how a burst goes permanently short + /// by its event/console members. Enqueueing the count at the arrival + /// position restores the band's ordering; the frame is still PROCESSED + /// inline, so wrist/battery/alarm handling is never delayed behind an + /// offload commit. + immediateAndCount, } /// Pure routing decision for [FrameRoute]. @@ -509,13 +526,21 @@ enum FrameRoute { class FrameRoutePolicy { const FrameRoutePolicy._(); + /// [isBurstCountMember] is doc 05 §"Count membership" for the non-data + /// families (48/50/53/54/55); [offloadActive] is whether a history session is + /// running at all, since outside one there is no burst to count into. static FrameRoute route({ required bool isMetadata, required bool isHistorical, required bool isDataRole, + bool isBurstCountMember = false, + bool offloadActive = false, }) { if (isMetadata) return FrameRoute.serializedQueue; if (isHistorical && isDataRole) return FrameRoute.serializedQueue; + if (isBurstCountMember && offloadActive) { + return FrameRoute.immediateAndCount; + } return FrameRoute.immediate; } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4e254f6f..018cc814 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2818,6 +2818,18 @@ class AppState extends ChangeNotifier { }) async { var last = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { + // Terminal `Stuck` (doc 05 §"Retry boundary"): a burst failed validation + // 15 times and the abort went out, so this connection's history is over. + // The engine refuses every further drain trigger, but stopping here too + // keeps the loop from spending its remaining sessions waiting out an idle + // timeout apiece against a link that will never answer. + if (engine.historyStuckThisSession) { + _log( + 'Backfill stop — history is terminal (Stuck) for this connection; ' + 'the band keeps its checkpoint until the next one.', + ); + break; + } // rec_ts_hw, not lastDecodedRecTs() — see the boot-time seed above for // why: an R10-lite-heavy backlog can genuinely advance without ever // touching decoded_onehz, and this "did we make progress" check must diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index 427bccbe..9914a948 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -448,6 +448,7 @@ void main() { _events(); _bootstrap(); + _burstOrdering(); } /// A type-48 EVENT inner: @@ -971,3 +972,321 @@ void _bootstrap() { }); }); } + +// ── T14: burst count membership must follow the band's arrival order ───────── +// +// doc 05 §"Count membership": every complete type-47/48/50/53/54/55 frame the +// band sends between HISTORY_START and HISTORY_END counts exactly once toward +// `HISTORY_END.expected_count` (= its own data_pkt_cnt + event_pkt_cnt). +// +// Data frames arrive on the data characteristic and are handled by the ONE +// serialized offload queue, together with both markers. Events and console +// logs arrive on the events characteristic — over the SAME ACL link, so their +// true position in the stream is their arrival order — and used to have their +// burst count applied at notification time instead. That reorders the count +// relative to the markers: a member could be tallied while the queue still had +// the PREVIOUS burst open (where the next HISTORY_START's rearm wipes it), so +// its own burst came up short by exactly those frames, every single retry. +// +// Field capture 2026-08-19 (WHOOP 5, fw 50.40.1.0): earlier bursts carried a +// growing console surplus (console=5, 7, 9 …) while the burst behind them went +// `expected=52, actual=48, breakdown={V18=42, events=1, console=5}` and then, +// after the strap's adaptive burst-size drop, `expected=16, actual=12, +// breakdown={V18=12}` on every one of 15 attempts. + +/// A gen5 v18 inner `Gen5V18Decoder` accepts: HR inside 25..230, dynamic +/// acceleration inside 0..8 g and a 1 g gravity vector. +Uint8List _gen5V18Inner({required int ts, required int counter}) { + final inner = Uint8List(kGen5V18InnerLen); + final v = ByteData.sublistView(inner); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[2] = 0x80; + v.setUint32(3, counter, Endian.little); + v.setUint32(7, ts, Endian.little); + inner[14] = 64; // heart rate + v.setFloat32(33, 0.5, Endian.little); // dynamic acceleration + v.setFloat32(45, 1.0, Endian.little); // gravity z → |g| = 1.0 + return inner; +} + +/// A type-50 CONSOLE_LOGS inner (protocol's `parseConsoleLog` envelope). +/// A type-47 inner that will NOT decode to a 1 Hz sample: a valid shared +/// header (counter + unix) under a revision edge has no Sample mapping for. +Uint8List _rawHistInner({required int rev, required int counter}) { + final inner = Uint8List(24); + inner[0] = PacketType.historicalData; + inner[1] = rev; + final v = ByteData.sublistView(inner); + v.setUint32(3, counter, Endian.little); + v.setUint32(7, 1786000000, Endian.little); + return inner; +} + +Uint8List _consoleInner(int index, {int ts = 1786000000}) { + const text = 'BLE_CMD: Command Link Valid'; + final inner = Uint8List(12 + text.length); + inner[0] = PacketType.consoleLogs; + inner[1] = index; + final v = ByteData.sublistView(inner); + v.setUint16(2, 2, Endian.little); // console logs ride event id 2 + v.setUint32(4, ts, Endian.little); + v.setUint16(10, text.length, Endian.little); + inner.setRange(12, inner.length, text.codeUnits); + return inner; +} + +/// A type-49 METADATA HISTORY_START inner. +Uint8List _historyStart() => + Uint8List.fromList([PacketType.metadata, 0x01, SyncMeta.historyStart]); + +/// A type-49 METADATA HISTORY_END inner: `expected_count` u32 @9 and the +/// 8-byte trim token @13:21 the result echoes verbatim. +Uint8List _historyEnd({required int expected, required int token}) { + final inner = Uint8List(24); + inner[0] = PacketType.metadata; + inner[1] = 0x02; + inner[2] = SyncMeta.historyEnd; + final v = ByteData.sublistView(inner); + v.setUint32(3, 1786000000, Endian.little); // strap clock + v.setUint32(9, expected, Endian.little); + v.setUint32(13, token, Endian.little); // marker A + v.setUint32(17, 0x18, Endian.little); // marker B / batch id + return inner; +} + +/// A gen5 link that feeds inbound frames through the REAL receive path — +/// [FrameRoutePolicy] and the serialized offload queue included — and captures +/// every outgoing command. +class _Burst { + final logs = []; + final frames = []; + late final BleEngine engine; + + _Burst() { + engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + ); + connect(); + } + + /// Stand up a fresh session on the same engine (a reconnect, as far as + /// everything session-scoped is concerned). + void connect() => engine.debugInstallFakeLink( + onWrite: (f) async { + frames.add(f); + return true; + }, + band: BandProfile.gen5, + ); + + void rx(Uint8List inner, {String role = 'data'}) => + engine.debugReceiveFrame(Frame(inner, true, true), role: role); + + /// Opcodes of every command written to the link so far. + List get opcodes => frames + .map((f) => parseFrame(f, profile: BandProfile.gen5)) + .where((p) => p != null && p.valid) + .map((p) => p!.inner[2]) + .toList(); + + /// The `actual=` field of each HistoryEnd line, in order — i.e. what the + /// count gate tallied for each burst that passed it. + List get acceptedCounts => logs + .where((l) => l.contains('[SYNC] HistoryEnd batch=')) + .map((l) => int.parse( + RegExp(r'actual=(\d+)').firstMatch(l)!.group(1)!)) + .toList(); + + List get shortLines => + logs.where((l) => l.contains('Burst packet-count SHORT')).toList(); +} + +void _burstOrdering() { + group('T14 — burst count members are counted in ARRIVAL order', () { + final ts = _wallNow() - 3600; + + test( + 'event and console members delivered between the last data frame and ' + 'HISTORY_END are counted — the gate passes', + () async { + final b = _Burst(); + b.rx(_historyStart()); + for (var i = 0; i < 12; i++) { + b.rx(_gen5V18Inner(ts: ts + i, counter: 1000 + i)); + } + // The two members the band counted in the same burst, on the OTHER + // characteristic, after the last data frame and before the terminal. + b.rx(_eventInner(29, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + role: 'events'); + b.rx(_consoleInner(1), role: 'events'); + b.rx(_historyEnd(expected: 14, token: 0x8601)); + await pumpEventQueue(); + + expect(b.shortLines, isEmpty, + reason: '12 data + 1 event + 1 console IS the band\'s 14'); + expect(b.acceptedCounts, [14]); + }, + ); + + test( + 'THE FIELD SCENARIO: members that arrive while the queue still has the ' + 'previous burst open count into THEIR burst, not the open one', + () async { + // One synchronous GATT flurry — the queue has processed nothing past + // burst A's HISTORY_START when burst B's members land. Counting them at + // notification time (the old path) credited them to A, and B then went + // permanently short by exactly those four frames: the 16/12 signature + // from the field log. + final b = _Burst(); + b.rx(_historyStart()); + for (var i = 0; i < 2; i++) { + b.rx(_gen5V18Inner(ts: ts + i, counter: 2000 + i)); + } + b.rx(_historyEnd(expected: 2, token: 0x8601)); + b.rx(_historyStart()); + for (var i = 0; i < 12; i++) { + b.rx(_gen5V18Inner(ts: ts + 100 + i, counter: 2100 + i)); + } + b.rx(_eventInner(29, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + role: 'events'); + b.rx(_eventInner(123, [1, 6, 0]), role: 'events'); + b.rx(_consoleInner(1), role: 'events'); + b.rx(_consoleInner(2), role: 'events'); + b.rx(_historyEnd(expected: 16, token: 0x8602)); + await pumpEventQueue(); + + expect(b.shortLines, isEmpty, + reason: 'burst B counted 12 data + 2 events + 2 console = 16/16; ' + 'the old immediate path counted 12 and failed forever'); + expect(b.acceptedCounts, [2, 16], + reason: 'burst A must NOT be inflated by B\'s members either — ' + 'that surplus is what the field log showed growing (console=' + '5, 7, 9 …) while the burst behind it starved'); + }, + ); + + test('a straggler arriving after the result does not contaminate the NEXT ' + 'burst', () async { + final b = _Burst(); + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 3000)); + b.rx(_historyEnd(expected: 1, token: 0x8601)); + // Late: the band put this on the wire after the burst's terminal. + b.rx(_consoleInner(9), role: 'events'); + await pumpEventQueue(); + + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts + 1, counter: 3001)); + b.rx(_historyEnd(expected: 1, token: 0x8602)); + await pumpEventQueue(); + + expect(b.shortLines, isEmpty); + expect(b.acceptedCounts, [1, 1], + reason: 'the straggler belongs to the burst that was open when it ' + 'arrived; HISTORY_START rearms the stats, so it can never be ' + 'spent on the next burst\'s gate'); + }); + + test( + 'a type-47 frame we cannot decode still counts — deep buffers and ' + 'unknown revisions are burst members (doc 05)', () async { + final b = _Burst(); + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 4000)); + // A gen5 deep buffer (v22 research telemetry: identified, archived, not + // a 1 Hz sample) and a future firmware's unknown revision. The band + // counted both when it wrote expected=3 — "unknown revisions still + // count" is doc 05's rule 4, and an R22-enabled strap puts one of these + // in most bursts, so leaving them uncounted starves the gate exactly + // like the mis-binned event frames did. + b.rx(_rawHistInner(rev: 22, counter: 4001)); + b.rx(_rawHistInner(rev: 99, counter: 4002)); + b.rx(_historyEnd(expected: 3, token: 0x8601)); + await pumpEventQueue(); + + expect(b.shortLines, isEmpty, + reason: '1 decoded + 2 archived type-47 frames ARE the band\'s 3'); + expect(b.acceptedCounts, [3]); + }); + }); + + group('T14 — the 15th failed validation is terminal for the session', () { + final ts = _wallNow() - 3600; + + /// Deliver one burst the band says is longer than it is. + Future shortBurst(_Burst b, int token) async { + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 4000 + token)); + b.rx(_historyEnd(expected: 5, token: token)); + await pumpEventQueue(); + } + + test('15 failures abort ONCE, then the re-offered burst is dropped without ' + 'validating or aborting again', () async { + final b = _Burst(); + for (var i = 1; i <= kBurstValidationAttemptLimit; i++) { + await shortBurst(b, 0x8600 + i); + } + + expect(b.shortLines, hasLength(kBurstValidationAttemptLimit)); + expect( + b.opcodes.where((o) => o == Cmd.abortHistoricalTransmits).length, + 1, + reason: 'ONE abort at the boundary — doc 05 §"Retry boundary"', + ); + // Attempts 1..14 send a failure result; the 15th deliberately does not. + expect( + b.opcodes.where((o) => o == Cmd.historicalDataResult).length, + kBurstValidationAttemptLimit - 1, + ); + expect(b.engine.historyStuckThisSession, isTrue); + + // The band does not know the session is over and re-offers the burst + // roughly every 2.5 s. Each re-offer used to re-enter validation — which + // was already past the limit — and abort again: 14+ aborts in 12 s in the + // field capture. + final before = b.opcodes.length; + for (var i = 0; i < 4; i++) { + await shortBurst(b, 0x8700 + i); + } + expect(b.shortLines, hasLength(kBurstValidationAttemptLimit), + reason: 'no further validation at all'); + expect(b.opcodes.length, before, reason: 'and no further link traffic'); + expect( + b.logs.where((l) => l.contains('terminal (Stuck)')).length, + 1, + reason: 'logged once, quietly — the re-offers are silent after that', + ); + expect(b.engine.offloadSnapshot['stuck_markers_dropped'], greaterThan(0)); + }); + + test('a same-session drain trigger is refused; a new session drains', + () async { + final b = _Burst(); + for (var i = 1; i <= kBurstValidationAttemptLimit; i++) { + await shortBurst(b, 0x8600 + i); + } + + expect(await b.engine.debugStartHistoricalRefresh(), isFalse, + reason: 'continuation belongs to a later connection, not to a ' + 'retry on this one (doc 05 §"Retry boundary")'); + expect(b.engine.offloadSnapshot['stuck_refreshes_refused'], 1); + + // A reconnect is the remedy: the latch is session-scoped, so the next + // connection drains normally from the band\'s own checkpoint. + b.connect(); + expect(b.engine.historyStuckThisSession, isFalse); + final shortBefore = b.shortLines.length; + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 5000)); + b.rx(_historyEnd(expected: 1, token: 0x8800)); + await pumpEventQueue(); + expect(b.shortLines, hasLength(shortBefore), + reason: 'the fresh session validated its burst normally'); + expect(b.acceptedCounts.last, 1); + }); + }); +} From 8ad7a7a892c3cac2c5faaffbf03e3bc964394409 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Wed, 19 Aug 2026 18:08:54 +0200 Subject: [PATCH 05/11] one SET_CLOCK per bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs say the bootstrap sends one SET_CLOCK; a factory-fresh or far-off RTC was getting two — the clock-absorb handler's own bounded re-correction fired on the hello reply, and the bootstrap clock step then wrote again because no correlation existed. A duplicate persistent-state write is exactly the hazard the no-auto-resend rule exists for. The absorb handler now stands down inside the bootstrap's clock window and the bootstrap step is the single writer; outside the window (RTC-lost events, the periodic re-verify) it corrects itself exactly as before. Pinned by a test that fails with two writes. Also writes down, at the battery poll, that the keep-alive polls are a deliberate deviation from the official no-idle-polling model — retained as liveness probes, not data sources, with the removal tracked as its own conformance task rather than done as a drive-by. --- lib/ble/ble_engine.dart | 66 +++++++++++++++++++++++++++++--------- test/gen5_wiring_test.dart | 20 ++++++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index b7a84026..b9f38999 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -1159,6 +1159,18 @@ class BleEngine { /// back, and the GET_CLOCK handler re-issues on drift — so cap the retries or /// a firmware that never latches either payload form would loop forever. int _clockCorrectTries = 0; + + /// True while the bootstrap's clock step owns the SET_CLOCK decision. + /// + /// doc 01 sends **one** `SET_CLOCK(10)` per bootstrap. Without this window, + /// an unset/far-off RTC got TWO: [_absorbClockEpoch]'s own bounded + /// re-correction fired on the hello/GET_CLOCK reply, and + /// [_bootstrapSetClock] then wrote again because no correlation existed. + /// doc 02 calls a duplicate persistent-state write "a real hazard". While + /// this is set, the absorb handler leaves the write to the bootstrap step; + /// outside it (RTC-lost events, the periodic re-verify) it corrects itself + /// exactly as before. + bool _bootstrapClockWrite = false; // Proactive RTC recheck timestamp for long-lived connections — see // kRtcReverifyIntervalSeconds. Every other clock recheck is symptom-driven. DateTime? _lastClockVerifyAt; @@ -2020,22 +2032,30 @@ class BleEngine { // timestamp. Feed hello's clock through the same handler the GET_CLOCK // reply uses, so the suspect-phone and unset-RTC verdicts are computed // from one place regardless of which command supplied the epoch. - final helloClock = _gen5Hello?.tsSeconds; - if (helloClock != null && helloClock > 0) { - _absorbClockEpoch(helloClock); - } else { - await _readClock(); - } - if (_session != session || !session.connected) { - _log('link dropped during the clock read — abandoning setup.'); - // Tear down ONLY if we are still the live session. `_failConnect` - // teardown+band-release act on whatever `_session` currently points - // at, so a newer `_doConnect` that already took over would have its - // link killed and its band claim dropped by this stale invocation. - if (identical(_session, session)) await _failConnect(); - return false; + // One SET_CLOCK per bootstrap (doc 01): the reads below run inside the + // window so the absorb handler's own re-correction stands down and + // _bootstrapSetClock is the single writer. + _bootstrapClockWrite = true; + try { + final helloClock = _gen5Hello?.tsSeconds; + if (helloClock != null && helloClock > 0) { + _absorbClockEpoch(helloClock); + } else { + await _readClock(); + } + if (_session != session || !session.connected) { + _log('link dropped during the clock read — abandoning setup.'); + // Tear down ONLY if we are still the live session. `_failConnect` + // teardown+band-release act on whatever `_session` currently points + // at, so a newer `_doConnect` that already took over would have its + // link killed and its band claim dropped by this stale invocation. + if (identical(_session, session)) await _failConnect(); + return false; + } + await _bootstrapSetClock(session); + } finally { + _bootstrapClockWrite = false; } - await _bootstrapSetClock(session); if (_session != session || !session.connected) { _log('link dropped during SET_CLOCK — abandoning setup.'); // Tear down ONLY if we are still the live session. `_failConnect` @@ -2255,6 +2275,13 @@ class BleEngine { kBatteryPollIntervalSeconds) { return; } + // KNOWN DEVIATION from doc 06 ("no idle polling loop" — battery updates + // come from band events): this poll and the 6 h clock re-verify are kept + // deliberately, as LIVENESS probes on stacks that silently drop + // notifications, not as data sources — hello + BATTERY_LEVEL events are + // the data path. Revisiting both is tracked as an open conformance task; + // removing them changes dead-link detection, so it is not done as a + // drive-by here. // Correlated (doc 02) but deliberately NOT awaited by this caller: the // battery level is a display value, and both call sites — the keep-alive // tick and `getBattery()` on the session-open path — only ever needed the @@ -4633,6 +4660,15 @@ class BleEngine { 'Clock drift over policy but the PHONE clock is the suspect one ' '(strap=$dev wall=$wall) — NOT writing SET_CLOCK yet.', ); + } else if (_bootstrapClockWrite) { + // The bootstrap's own clock step is the single writer for this + // connect (doc 01: "send one SET_CLOCK"). Writing here too sent a + // factory-fresh band TWO corrections back to back — doc 02's + // duplicate-persistent-write hazard. The retry budget is untouched: + // the read-back after the bootstrap write lands once this window is + // closed, and a still-wrong RTC re-corrects here as before. + _log('Clock drift over policy — leaving the write to the bootstrap ' + 'clock step (one SET_CLOCK per connect, doc 01).'); } else if (_clockCorrectTries < 3) { // BOUND the retries: setClock() reads the clock back and this handler // re-issues on drift, so an unbounded loop would spin diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index 9914a948..e207a471 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -783,6 +783,26 @@ void _bootstrap() { }); }); + test('an UNSET RTC gets exactly one SET_CLOCK, not two', () { + fakeAsync((async) { + // Factory-epoch hello timestamp: below the plausible floor, so it is + // never correlated (drift == null) and needsCorrection(null) is true. + // Before the bootstrap-window fix, BOTH writers fired — the absorb + // handler's own re-correction on the hello reply AND the bootstrap + // clock step — sending a fresh band two SET_CLOCKs back to back, + // against doc 01's "send one SET_CLOCK(10)". + final link = _BootstrapLink(); + link.replyTo = (seq, op) => op == Cmd.getHello + ? _helloReply(seq, tsSeconds: 1000) + : null; + expect(_runBootstrap(link, async), isTrue); + + expect(link.count(Cmd.setClock), 1, + reason: 'doc 01: ONE SET_CLOCK per bootstrap — the absorb ' + 'handler must stand down inside the bootstrap window'); + }); + }); + test('the phone-clock deferral still beats the drift gate', () { fakeAsync((async) { final link = _BootstrapLink(); From 6ccaae293189f970d29ef8bb114e27d0d92cb270 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 13:03:59 +0200 Subject: [PATCH 06/11] review pass: per-burst attempts, a frozen tally, and a gen5-scoped gate - beginBurst() starts a fresh validation cycle: a new HISTORY_START resets the attempt count (no inherited slack), while marker-only re-offers of the same burst still accumulate toward the 15-attempt boundary. - the burst tally freezes at HISTORY_END, so event/console chatter in the re-offer window cannot push a short burst over the line into an ACK. - the count gate is enforced on gen5 only; gen4 keeps its advisory-only behaviour until a gen4 capture pins its count semantics. - Stuck lets HISTORY_COMPLETE through (it ACKs nothing), so awaitComplete waiters stop running out their timeout; the idle watchdog re-arms only on real drain progress, never on chatter. - one log line when a GET_CLOCK reply matches via the seq-zero fallback. - the smart-alarm machinery (ConditionalWakePolicy, runStoredAlarm, the condition-report fields) moves out to its own future PR. - BatteryPackInfoGate rejects the sentinel in the name field too; BurstShortfallGate (dead) removed; the v46 data rule now also applies at the backup-import seam, with a pre-v46 import regression test; the alarm error message stays neutral across both null causes; pubspec.lock restored to main's pinned form. - comments and log lines state the facts without their sources. --- lib/ble/ble_engine.dart | 433 +++++++++++----------- lib/ble/ble_state.dart | 250 ++----------- lib/data/db.dart | 13 + lib/state/app_state.dart | 9 +- pubspec.lock | 16 +- test/absence_and_offload_guards_test.dart | 26 -- test/alarm_test.dart | 147 +------- test/ble_clock_gate_test.dart | 2 +- test/ble_engine_test.dart | 4 +- test/command_correlation_test.dart | 31 +- test/db_paged_import_export_test.dart | 55 +++ test/gen5_wiring_test.dart | 273 ++++++++++---- test/v25_refusal_test.dart | 16 +- 13 files changed, 555 insertions(+), 720 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 62a351fb..ab583338 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -223,8 +223,7 @@ int countBurstTrafficPackets({ unknownCount; } -/// Whether a NON-data frame is a burst count member (doc 05 §"Count -/// membership"): each complete type-48 event, type-50 console log and the +/// Whether a NON-data frame is a burst count member: each complete type-48 event, type-50 console log and the /// three battery-pack ("puffin") wrappers 53/54/55 counts exactly once toward /// `HISTORY_END.expected_count`. Type 47 is counted on the data path instead /// (it is what `dataPacketCountsByRevision` tallies); type 49 metadata NEVER @@ -263,8 +262,8 @@ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => /// gate-rejected record can never validate — which discards its OTHER, /// perfectly good buffered records and re-requests the same stuck block /// forever (zero sync progress). -/// The official rule is ONE-SIDED with a failure-dependent slack, not equality -/// (reversing-whoop doc 05, "Collector and count gate"): +/// The pinned rule is ONE-SIDED with a failure-dependent slack, not equality +///: /// /// ```text /// slack = consecutiveFailedValidations >= 3 ? 2 : 0 @@ -278,10 +277,9 @@ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => /// * The first three attempts demand every frame; from the fourth, up to two /// missing are tolerated so a burst with a persistently unreadable frame /// can still make progress instead of looping to the 15-attempt abort. -/// The official Sensor-HPS boundary: attempts 1..14 send a failure result and +/// The pinned Sensor-HPS boundary: attempts 1..14 send a failure result and /// wait for the strap to re-offer; the 15th is terminal and aborts instead of -/// sending a fifteenth failure (reversing-whoop doc 05, "Exact Sensor-HPS retry -/// boundary"). Bounding it is what stops a permanently-short burst becoming an +/// sending a fifteenth failure. Bounding it is what stops a permanently-short burst becoming an /// infinite re-request loop. const int kBurstValidationAttemptLimit = 15; @@ -487,7 +485,7 @@ class _Session { /// the same link cannot start a second retry loop against the same band. bool batteryPackFollowUpStarted = false; - /// Terminal `Stuck` latch (doc 05 §"Retry boundary"): set when a burst has + /// Terminal `Stuck` latch: set when a burst has /// failed validation [kBurstValidationAttemptLimit] times and the abort went /// out. From then on this session's history is OVER — no further drain /// trigger, and no re-validating a burst the band keeps re-offering. @@ -955,16 +953,16 @@ class BleEngine { @visibleForTesting Future debugWriteRaw(Uint8List raw) => _write(raw); - /// Commands currently waiting for a correlated response (doc 02). Zero at + /// Commands currently waiting for a correlated response. Zero at /// rest; a wrong-opcode reply must leave the count unchanged. @visibleForTesting int get pendingCommandCount => _awaiter.pendingCount; - /// Hello failures counted across reconnect attempts (doc 01). + /// Hello failures counted across reconnect attempts. @visibleForTesting int get helloFailureCount => _helloFailures; - /// The identity verdict from the last successful hello (doc 01). + /// The identity verdict from the last successful hello. @visibleForTesting HelloIdentity? get helloIdentity => _helloIdentity; @@ -980,7 +978,7 @@ class BleEngine { /// advertising-name read and the charging follow-up. /// /// The ORDER of those steps, and which of them make a BLE write at all, is - /// the whole contract of doc 01 §"Phase sequence" — and it lives behind a + /// the whole contract of — and it lives behind a /// radio otherwise, because the only caller is the connect path. @visibleForTesting Future debugBootstrapAfterRegistration() { @@ -1144,29 +1142,13 @@ class BleEngine { int? _strapAlarmEpoch; bool? _strapAlarmActive; - /// Last STRAP_CONDITION_REPORT(29) event — the band's own view of its - /// backlog, charge and wear. Observability only; see [StrapConditionReport]. - StrapConditionReport? _strapCondition; - /// Why the last running haptics pattern stopped (HAPTICS_TERMINATED(100), - /// doc 07): `expired`, `error` or `user_double_tap`. The double tap is the + /// `expired`, `error` or `user_double_tap`. The double tap is the /// only way to learn the WEARER dismissed an alarm rather than letting it /// time out. Recorded and logged; the alarm flow is unchanged. String? _lastHapticsTermination; int? _lastHapticsTerminationTs; - /// What the strap answered the last [runStoredAlarm] with: the alarm/haptics - /// status byte from the correlated `RUN_ALARM(68)` reply (doc 07 - /// §"Alarm/haptics status codes"), plus the wall second it landed. - /// - /// This is the wake-in-green trigger's only evidence trail. RUN_ALARM has - /// never been verified on WHOOP 5 hardware with the rev-2 body (see - /// [runStoredAlarm]), so "did the strap say it played?" is exactly the - /// question a hardware re-test needs answered from the field. - int? _lastRunAlarmStatus; - String? _lastRunAlarmStatusName; - int? _lastRunAlarmTs; - // ── reconnect/offload policy ──────────────────────────────────────────────── // Marginal-radio + post-bond-loop persist ACROSS reconnects (they count // consecutive bad cycles), so they live for the engine's lifetime and self-reset @@ -1234,11 +1216,11 @@ class BleEngine { /// True while the bootstrap's clock step owns the SET_CLOCK decision. /// - /// doc 01 sends **one** `SET_CLOCK(10)` per bootstrap. Without this window, + /// The bootstrap sends **one** `SET_CLOCK(10)`. Without this window, /// an unset/far-off RTC got TWO: [_absorbClockEpoch]'s own bounded /// re-correction fired on the hello/GET_CLOCK reply, and /// [_bootstrapSetClock] then wrote again because no correlation existed. - /// doc 02 calls a duplicate persistent-state write "a real hazard". While + /// a duplicate persistent-state write is a real hazard. While /// this is set, the absorb handler leaves the write to the bootstrap step; /// outside it (RTC-lost events, the periodic re-verify) it corrects itself /// exactly as before. @@ -1286,19 +1268,18 @@ class BleEngine { _phoneClockSuspectSince, _monotonicSecs()); int _clockPausedOffloads = 0; // diagnostics: offloads deferred for this reason /// Request/response correlation for every command this engine awaits - /// (doc 02). Replaces the two ad-hoc one-shot completers this file used to + ///. Replaces the two ad-hoc one-shot completers this file used to /// carry for HELLO and GET_CLOCK, which keyed off "a reply of roughly the /// right shape arrived" and could therefore be satisfied by an unrelated /// command's answer. Emptied on teardown so a dropped link never leaves a /// caller waiting out a full timeout on a connection that is gone. final CommandAwaiter _awaiter = CommandAwaiter(); - /// The most recent gen5 HELLO. Its timestamp is the official input to the - /// clock decision (doc 01: the normal gen5 path compares hello's time to the - /// phone and never sends GET_CLOCK unless hello supplied none). + /// The most recent gen5 HELLO. Its timestamp is the primary input to the + /// clock decision. Gen5HelloInfo? _gen5Hello; - /// doc 01 §"Hello failure handling": failures are counted ACROSS reconnect + /// failures are counted ACROSS reconnect /// attempts (like `_marginalRadio`/`_postBondLoop`, and deliberately NOT /// reset in the per-connection block in `_doConnect`); at /// [kHelloFailuresBeforeBondReset] the counter resets and the platform bond @@ -1306,7 +1287,7 @@ class BleEngine { int _helloFailures = 0; static const int kHelloFailuresBeforeBondReset = 5; - /// doc 01 §"The two delays": the official client waits **600 ms** after the + /// the pinned bootstrap waits **600 ms** after the /// bond, before notification registration, and **500 ms** after the last /// registration before running the higher-level state machine — on a captured /// link GET_HELLO went out 585 ms after the final CCC write. These are @@ -1317,7 +1298,7 @@ class BleEngine { static const Duration kGen5PostRegistrationDelay = Duration(milliseconds: 500); - /// doc 01 §"Charging follow-up": while the band reports charging, ask it what + /// while the band reports charging, ask it what /// battery pack it is on — "five attempts, 5,000 ms between attempts", and /// "every unusable attempt is followed by the 5-second delay, including the /// fifth". Purely advisory: a missing or invalid result "must not move the @@ -1325,12 +1306,10 @@ class BleEngine { static const int kBatteryPackInfoAttempts = 5; static const Duration kBatteryPackInfoRetryDelay = Duration(seconds: 5); - /// The identity verdict from the last successful hello (doc 01 "What gates - /// READY") — observable, never a disconnect. Null until a hello lands. + /// The identity verdict from the last successful hello — observable, never a disconnect. Null until a hello lands. HelloIdentity? _helloIdentity; - /// The last USABLE `GET_BATTERY_PACK_INFO(151)` reply (doc 01 §"Charging - /// follow-up") and when it landed. Diagnostics only — surfaced in + /// The last USABLE `GET_BATTERY_PACK_INFO(151)` reply and when it landed. Diagnostics only — surfaced in /// [offloadSnapshot], never gating READY or anything else. BatteryPackInfoResponse? _batteryPack; int? _batteryPackTs; @@ -1496,7 +1475,7 @@ class BleEngine { bool get offloadActive => _offloadActive; /// True once this connection's history hit the terminal `Stuck` boundary - /// (doc 05 §"Retry boundary"): a burst failed validation + ///: a burst failed validation /// [kBurstValidationAttemptLimit] times and the abort went out. Nothing may /// start another drain on this link; continuation belongs to a later /// connection. Callers that loop over sync sessions must stop on it. @@ -1526,7 +1505,7 @@ class BleEngine { // a *streak* of mismatches is a real signal worth watching over time. 'burst_mismatch_total': _burstMismatchTotal, 'burst_mismatch_streak': _burstMismatchStreak, - // Terminal `Stuck` for this connection (doc 05 §"Retry boundary") and how + // Terminal `Stuck` for this connection and how // much re-offer/re-trigger traffic the latch has since absorbed. 'history_stuck': _session?.historyStuck ?? false, 'stuck_markers_dropped': _session?.stuckMarkersDropped ?? 0, @@ -1567,27 +1546,17 @@ class BleEngine { // What the STRAP reports it holds (GET_ALARM_TIME), not what we set. 'strap_alarm_epoch': _strapAlarmEpoch, 'strap_alarm_active': _strapAlarmActive, - // Unsolicited strap telemetry (doc 04 event 29 / doc 07 event 100). - // Observability only — neither drives a sync nor the alarm flow. - 'condition_pages_behind': _strapCondition?.pagesBehind, - 'condition_backlog': _strapCondition?.backlog, - 'condition_soc_pct': _strapCondition?.socPct, - 'condition_charging': _strapCondition?.charging, - 'condition_wrist_state': _strapCondition?.wristState, - 'condition_ts': _strapCondition?.tsEpoch, + // Unsolicited strap telemetry (haptics termination). Observability only — + // it drives neither a sync nor the alarm flow. 'last_haptics_termination': _lastHapticsTermination, 'last_haptics_termination_ts': _lastHapticsTerminationTs, - // doc 07: what the strap answered the last RUN_ALARM with. - 'last_run_alarm_status': _lastRunAlarmStatus, - 'last_run_alarm_status_name': _lastRunAlarmStatusName, - 'last_run_alarm_ts': _lastRunAlarmTs, - // doc 01/02: hello health and the identity gate, both observable rather + // hello health and the identity gate, both observable rather // than enforced. `hello_failures` counts ACROSS reconnects and resets // itself at the bond-reset threshold. 'hello_failures': _helloFailures, 'hello_identity_ok': _helloIdentity?.ok, 'hello_serial_eeprom_failure': _helloIdentity?.eepromFailureSignal, - // doc 01 §"Charging follow-up": what the band answered about the puck it + // what the band answered about the puck it // was sitting on. Absent until a USABLE reply lands (see // [BatteryPackInfoGate]); never a readiness input. 'battery_pack_attached': _batteryPack?.attached, @@ -1953,7 +1922,7 @@ class BleEngine { return false; } - // doc 01 §"The two delays" (gen5 only — see [kGen5PreRegistrationDelay]): + // (gen5 only — see [kGen5PreRegistrationDelay]): // the bond is complete by here, so this is the 600 ms that precedes // notification registration. if (band.isGen5 && @@ -2099,9 +2068,9 @@ class BleEngine { } } - // ── bootstrap (doc 01 §"Phase sequence") ──────────────────────────────────── + // ── bootstrap ──────────────────────────────────── - /// One of doc 01's two observed bootstrap delays, with the same stale-session + /// One of the two observed bootstrap delays, with the same stale-session /// check every neighbouring step carries: a link that drops during the sleep /// aborts setup instead of letting it run on against a dead connection. /// @@ -2123,18 +2092,18 @@ class BleEngine { return true; } - /// Everything doc 01's phase sequence puts between the last CCC write and + /// Everything the phase sequence puts between the last CCC write and /// READY: the 500 ms post-registration delay, GET_HELLO, the clock decision, /// the final advertising-name read and the charging follow-up. /// - /// Lifted out of [_doConnect] because this ORDER is the contract doc 01 + /// Lifted out of [_doConnect] because this ORDER is the contract the /// specifies — and as inline statements inside a 400-line connect the only /// way to check it was against a radio. /// /// Returns false when the link died under one of the steps; the session has /// already been torn down in that case. Future _bootstrapAfterRegistration(_Session session) async { - // doc 01 §"The two delays": 500 ms after the last registration, before the + // 500 ms after the last registration, before the // higher-level state machine runs. gen5 only — see the constant. if (session.band.isGen5 && !await _bootstrapPause( @@ -2146,7 +2115,7 @@ class BleEngine { } _setPhase(BleConnState.settingUp); // Set the strap RTC to real wall-clock time. The band ships with an unset - // clock; SET_CLOCK is non-destructive (it's what the official app does each + // clock; SET_CLOCK is non-destructive (it is sent routinely on connect // connect). Records stamped after this carry real unix time. _clockCorrectTries = 0; // fresh retry budget for this connection // Drop the previous session's clock correlation so an alarm armed before @@ -2155,7 +2124,7 @@ class BleEngine { // repopulate it for this connection. _clockRef = null; _gen5Hello = null; - // HELLO FIRST on gen5 — the official bootstrap order (doc 01). Hello + // HELLO FIRST on gen5 — the pinned bootstrap order. Hello // carries the strap's own timestamp, so it answers the "what time does // the band think it is" question that the GET_CLOCK below exists to ask, // and it carries identity/battery/charge/on-body state that everything @@ -2163,7 +2132,7 @@ class BleEngine { // that was available here and gen5 had no serial or battery at connect. // // Best effort: a failed or unanswered hello falls through to the ordinary - // clock read, which is what the official client does when hello supplies + // clock read, which is the pinned fallback when hello supplies // no timestamp. Nothing below is gated on it. if (session.band.isGen5) { await _readGen5Hello(); @@ -2187,11 +2156,11 @@ class BleEngine { // without these checks setup would carry on past a teardown, rebuild the // drain state and hand back `true` for a dead connection. // Hello already answered this on gen5, so skip the round trip — the - // official client only falls back to GET_CLOCK when hello carried no + // pinned flow only falls back to GET_CLOCK when hello carried no // timestamp. Feed hello's clock through the same handler the GET_CLOCK // reply uses, so the suspect-phone and unset-RTC verdicts are computed // from one place regardless of which command supplied the epoch. - // One SET_CLOCK per bootstrap (doc 01): the reads below run inside the + // One SET_CLOCK per bootstrap: the reads below run inside the // window so the absorb handler's own re-correction stands down and // _bootstrapSetClock is the single writer. _bootstrapClockWrite = true; @@ -2224,43 +2193,43 @@ class BleEngine { if (identical(_session, session)) await _failConnect(); return false; } - // doc 01: the advertising-name read is the last command before READY, and + // the advertising-name read is the last command before READY, and // the charging follow-up is launched after it. Neither can fail setup. await _readAdvertisingNameGen5(session); _maybeStartBatteryPackFollowUp(session); return true; } - /// The bootstrap SET_CLOCK decision (doc 01 §"Clock contract"). + /// The bootstrap SET_CLOCK decision. /// /// Three rules, in this order: /// 1. the phone-clock deferral still wins — while THIS phone is the suspect /// party, writing its wall clock onto a possibly-correct strap RTC /// corrupts the RTC and destroys the evidence (unchanged behaviour); /// 2. on gen5, below [BootstrapClockGate.toleranceSeconds] of absolute drift - /// the official client makes NO BLE write at all. This app used to send + /// the pinned bootstrap makes NO BLE write at all. This app used to send /// SET_CLOCK unconditionally on every single connect; /// 3. everything else writes once — including a band with no usable clock /// correlation (unset/implausible RTC), where the drift is null and /// leaving the RTC uncorrected is the one genuinely bad outcome. /// /// gen4 keeps the unconditional write it has today: its flow is proven, and - /// doc 01 describes the WHOOP 5 bootstrap. + /// the WHOOP 5 bootstrap is where the evidence lives. Future _bootstrapSetClock(_Session session) async { if (_deferForClock) return; if (session.band.isGen5) { final drift = _clockRef?.driftSec; if (!BootstrapClockGate.needsCorrection(drift)) { _log('[CLOCK] in sync (drift ${drift}s, tolerance ' - '${BootstrapClockGate.toleranceSeconds}s) — no correction needed ' - '(doc 01 "Clock contract"); no SET_CLOCK written.'); + '${BootstrapClockGate.toleranceSeconds}s) — no correction ' + 'needed; no SET_CLOCK written.'); return; } } await setClock(); } - /// doc 01 §"Final advertising-name read": `GET_ADVERTISING_NAME(141)` with + /// `GET_ADVERTISING_NAME(141)` with /// body `01` and a 5 s timeout is part of the exact bootstrap sequence, sent /// after the clock step and before READY. /// @@ -2278,7 +2247,7 @@ class BleEngine { ); if (!out.written) { _log('[NAME] GET_ADVERTISING_NAME was never written — not a readiness ' - 'gate (doc 01); setup continues.'); + 'gate; setup continues.'); return; } // Consumed, never awaited: leaving the pending entry unarmed would hold a @@ -2286,12 +2255,12 @@ class BleEngine { unawaited(out.response.then((r) { if (r == null) { _log('[NAME] GET_ADVERTISING_NAME went unanswered — not a readiness ' - 'gate (doc 01).'); + 'gate.'); } })); } - /// doc 01 §"Charging follow-up": when hello says the band is charging, look + /// when hello says the band is charging, look /// up the battery pack it is sitting on, asynchronously, after setup. /// /// Never runs off-charger, never runs twice for one session, and is not @@ -2338,14 +2307,14 @@ class BleEngine { 'type=${info.batteryPackType?.name ?? info.batteryPackTypeRaw}.'); return; } - // doc 01: "every unusable attempt is followed by the 5-second delay, + // "every unusable attempt is followed by the 5-second delay, // including the fifth". The band answers before it knows what it is // sitting on, so an early all-zero address is the expected reply. await Future.delayed(kBatteryPackInfoRetryDelay); } _log('[PACK] no usable GET_BATTERY_PACK_INFO reply after ' '$kBatteryPackInfoAttempts attempts — nothing changes; the band stays ' - 'READY (doc 01 "Charging follow-up").'); + 'READY.'); } // ── keep-alive + periodic backfill ────────────────────────────────────────── @@ -2434,14 +2403,15 @@ class BleEngine { kBatteryPollIntervalSeconds) { return; } - // KNOWN DEVIATION from doc 06 ("no idle polling loop" — battery updates + // KNOWN DEVIATION from the pinned idle contract (no idle polling loop — + // battery updates // come from band events): this poll and the 6 h clock re-verify are kept // deliberately, as LIVENESS probes on stacks that silently drop // notifications, not as data sources — hello + BATTERY_LEVEL events are // the data path. Revisiting both is tracked as an open conformance task; // removing them changes dead-link detection, so it is not done as a // drive-by here. - // Correlated (doc 02) but deliberately NOT awaited by this caller: the + // Correlated but deliberately NOT awaited by this caller: the // battery level is a display value, and both call sites — the keep-alive // tick and `getBattery()` on the session-open path — only ever needed the // write to have gone out. Blocking either for up to five seconds on a @@ -2539,9 +2509,9 @@ class BleEngine { final d = _drain; final session = _session; if (session?.connected != true || d == null) return false; - // Terminal `Stuck` (doc 05 §"Retry boundary"): "no same-session retry — + // Terminal `Stuck`: no same-session retry — // continuation comes from a later connection, scheduler tick or explicit - // trigger." Every in-session trigger routes through here — periodic + // trigger. Every in-session trigger routes through here — periodic // backfill, foreground/manual resync, auto-continue and the backfill // continuation loop — so refusing here closes all of them at once. The // FIRST drain of a fresh session is untouched: the latch lives on the @@ -2960,7 +2930,7 @@ class BleEngine { return ok; } - /// Send a command and wait for ITS reply (doc 02). + /// Send a command and wait for ITS reply. /// /// The observer is installed BEFORE the write ("Ordering"), so a response /// that beats the write's own completion still finds a waiter. Correlation is @@ -3026,15 +2996,14 @@ class BleEngine { /// Ask the strap to prompt more frequent history syncs around a wake time. /// - /// Defaults are the OFFICIAL Smart Alarm values recovered from WHOOP's own - /// client: interval **180 s**, duration **7200 s** (2 h), i.e. the wire body - /// `02 b4 00 20 1c` (reversing-whoop doc 14 "High-frequency command", doc 05 - /// "High-frequency mode is a scheduler mode"). The window officially opens at - /// `latest wake time - 2 hours`, which is why the duration matches it. + /// Defaults are the pinned Smart Alarm values: interval **180 s**, duration + /// **7200 s** (2 h), i.e. the wire body `02 b4 00 20 1c`. The wake window + /// opens at `latest wake time - 2 hours`, which is why the duration + /// matches it. /// /// The previous default was 61 s / 90 min — chosen only because gen5 refuses /// an interval of 60 or less, not because anything established it. A shorter - /// interval means more wake/connect cycles for the same result; the official + /// interval means more wake/connect cycles for the same result; the pinned /// cadence is the one with evidence behind it. Future applyHighFreqWakeWindow({ required bool enabled, @@ -3214,8 +3183,7 @@ class BleEngine { ); } else if (pt == PacketType.event) { // NOTE: the burst COUNT for this frame is NOT applied here. Events, - // console logs and puffin wrappers are count members (doc 05 §"Count - // membership") but they arrive on a different characteristic than the + // console logs and puffin wrappers are count members but they arrive on a different characteristic than the // data frames, so counting them at notification time put them in // whichever burst window happened to be open rather than the one the // band sent them in. The count now rides the serialized queue at this @@ -3223,7 +3191,12 @@ class BleEngine { // [_countQueuedBurstMember]. Event PROCESSING stays right here: nothing // about wrist/battery/alarm handling may wait on an offload commit. _log('[EVENT] ${_innerHex(frame.inner)}'); - final e = parseEvent(frame.inner); + // The profile matters: protocol keeps the gen5-scoped event bodies + // (29/100/109/123) numeric and un-decoded on a gen4 link. + final e = parseEvent( + frame.inner, + profile: _session?.band ?? BandProfile.gen4, + ); if (e != null) { _handleEventInfo(e); onEvent?.call(e.eventId, e.tsEpoch, _innerHex(frame.inner)); @@ -3281,7 +3254,13 @@ class BleEngine { // Records are flowing → the strap is still draining. Armed per drained // batch (bounded rate) instead of per record — same watchdog semantics, // no Timer churn at flood rates. Markers re-arm it in _handleSyncMarker. - _armIdleWatchdog(); + // + // Only REAL drain progress counts: event/console count members ride + // this queue too, and gen5's console chatter alone could otherwise + // keep a genuinely stalled offload alive past the timeout forever. + if (batch.any((f) => f.packetType == PacketType.historicalData)) { + _armIdleWatchdog(); + } for (final frame in batch) { if (_sessionIsStale(session)) return; if (frame.packetType == PacketType.metadata) { @@ -3319,8 +3298,7 @@ class BleEngine { /// time instead could land before its burst's HISTORY_START (where `rearm()` /// wipes it) or after its HISTORY_END had already validated, which is how a /// burst carrying several of them went permanently short by ~4 frames - /// against `expected=16, actual=12, breakdown={V18=12}` (field capture, - /// 2026-08-19, fw 50.40.1.0). + /// against `expected=16, actual=12, breakdown={V18=12}` on a real strap. void _countQueuedBurstMember(Frame frame) { final d = _drain; if (d == null) return; @@ -3333,8 +3311,7 @@ class BleEngine { // count once each, on the band's event counter. The wrappers were counted // nowhere at all before the count gate landed: a retained capture has a // checkpoint of 24 ordinary packets plus three type-54 wrappers reported as - // `expected = 27`, which fails 27/24 forever until they are counted (doc 05 - // §"Count membership"). + // `expected = 27`, which fails 27/24 forever until they are counted. d.onBurstEvent(); if (pt != PacketType.event) { _log('[SYNC] puffin wrapper type=$pt counted as a burst member'); @@ -3623,7 +3600,7 @@ class BleEngine { // // It was parked because the response layout was unconfirmed and the decode // returned a plausible-but-wrong epoch (21:49 for an alarm set to 11:14). - // The revision-4 response is now pinned from the official client: + // The revision-4 response is now pinned: // body[0] revision 04 · body[1] active flag (exactly 1) · // body[2:6] epoch u32 LE · body[6:8] subsec u16 // and protocol reads the epoch at that offset, so the old wrong-offset @@ -3768,9 +3745,9 @@ class BleEngine { onState(state); } // gen5's GET_HELLO (opcode 145) has its own layout, now decoded in full - // against the official revision-1 body map — battery, charge state, the + // against the revision-1 body map — battery, charge state, the // strap's own timestamp, serial, firmware and on-body state all come from - // here (doc 01 "Revision-1 hello body"). It used to be diagnostics-only + // here. It used to be diagnostics-only // because those offsets were unconfirmed, which left gen5 with no serial, // no battery-at-connect and no wrist state. if (d.kind == 'cmd_response' && f['gen5_hello'] is Gen5HelloInfo) { @@ -3813,7 +3790,7 @@ class BleEngine { // A near-miss — right opcode but a sequence we never sent, or the right // sequence carrying a different opcode — is the one symptom worth // shouting about. It is what a strap that does not echo the originating - // sequence the way doc 02 describes would look like, and it is doc 02's + // sequence would look like, and it is the correlation contract's // own "a sequence match with the wrong opcode is not a success" case. // Either way the await it belongs to just expires, silently, without it. final nearMiss = (opcode != null && _awaiter.hasPendingOpcode(opcode)) || @@ -3849,23 +3826,19 @@ class BleEngine { final f = event.decoded; switch (event.eventId) { case EventId.strapConditionReport: - // doc 04 §"Type 48 — events": free sync-progress telemetry, sent - // unasked. Recorded and logged ONLY — deliberately no offload trigger - // here, so the backfill policy stays the single place that decides - // when to sync. - _strapCondition = StrapConditionReport( - tsEpoch: event.tsEpoch, - pagesBehind: (f['condition_pages_behind'] as num?)?.toInt(), - backlog: (f['condition_backlog'] as num?)?.toDouble(), - socPct: (f['condition_soc_pct'] as num?)?.toDouble(), - flash: (f['condition_flash'] as num?)?.toInt(), - charging: f['condition_charging'] as bool?, - wristState: (f['condition_wrist_state'] as num?)?.toInt(), + // Free sync-progress telemetry, sent unasked. Logged ONLY — + // deliberately no offload trigger and no stored state, so the + // backfill policy stays the single place that decides when to sync. + _log( + '[SYNC] strap condition report: ' + 'pages_behind=${f['condition_pages_behind']} ' + 'backlog=${f['condition_backlog']} soc=${f['condition_soc_pct']} ' + 'charging=${f['condition_charging']} ' + 'wrist=${f['condition_wrist_state']} ts=${event.tsEpoch}', ); - _log('[SYNC] strap condition: $_strapCondition'); return; case EventId.hapticsTerminated: - // doc 07 §"Termination event". `user_double_tap` is the wearer + // . `user_double_tap` is the wearer // dismissing a running alarm — a different fact from an alarm that ran // its course. Observed, not acted on: the alarm flow is unchanged. _lastHapticsTermination = @@ -4032,7 +4005,7 @@ class BleEngine { /// re-offer rather than sit waiting for a result that never comes. /// 3. A bounded end: the 15th consecutive failure sends ONE abort and /// terminates the session — and deliberately does NOT send a 15th failure - /// result, matching the official client. + /// result, matching the pinned retry boundary. Future _refuseHistoryEndOnShortCount({ required DrainController d, required _Session session, @@ -4065,10 +4038,10 @@ class BleEngine { // LATCH IT. Sending the abort is not by itself terminal: the band goes on // re-offering the same HISTORY_END about every 2.5 s until it gets a // result, and every re-offer used to re-enter validation — which was - // already past the limit, so it aborted again. A field capture shows that + // already past the limit, so it aborted again. A real strap showed that // loop running 14+ times in 12 s, and the 60 s idle timeout then handing // the whole 15-failure cycle to the backfill continuation. Terminal has - // to mean terminal for the session (doc 05 §"Retry boundary"). + // to mean terminal for the session. session.historyStuck = true; _log( '[SYNC] burst still short after ' @@ -4164,7 +4137,7 @@ class BleEngine { // re-deliver the chunk, which is the only way the frames we lost can // still be recovered — after the trim they are gone from flash. The // re-delivery is dedup-safe (decoded rows REPLACE by rec_ts), and - // BurstShortfallGate has already spent this token's one refusal, so + // this token's one refusal has already been spent, so // the redelivery is ACKed whatever it contains. No link bounce: the // link is fine, we just want the chunk again. _log( @@ -4281,12 +4254,17 @@ class BleEngine { if (_sessionIsStale(session)) return; final m = parseMetadata(frame.inner); if (m == null) return; - // Terminal `Stuck` (doc 05 §"Retry boundary"): this session's history ended + // Terminal `Stuck`: this session's history ended // with the abort. The band does not know that yet and re-offers the burst // every ~2.5 s; each re-offer must be dropped, NOT re-validated and // re-aborted. The idle watchdog is deliberately not re-armed either — there // is nothing left to wait for on this link. - if (session.historyStuck) { + // + // HISTORY_COMPLETE is the one marker that must still get through: it ACKs + // nothing, and swallowing it left `onComplete()` unreachable once the + // latch was set, so every `awaitComplete()` waiter ran out its full + // timeout against a drain that had already ended. + if (session.historyStuck && m.sub != SyncMeta.historyComplete) { session.stuckMarkersDropped++; if (session.stuckMarkersDropped == 1) { _log( @@ -4298,7 +4276,9 @@ class BleEngine { } return; } - _armIdleWatchdog(); + // Stuck: the COMPLETE passing through above must not re-arm the watchdog + // it deliberately left dead. + if (!session.historyStuck) _armIdleWatchdog(); _log( '[SYNC] META sub=${m.sub} inner=' '${frame.inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', @@ -4364,11 +4344,23 @@ class BleEngine { // the attempt number, and the slack, the gate actually judged this burst // under. final failuresBefore = d.consecutiveValidationFailures; + // The count-gate membership rules and the failure-result retry cycle + // are pinned on gen5 hardware only. On gen4 the gap between expected + // and actual varies run to run with no fixed offset, and a hard gate + // turns that into a permanent stall (15 failures → abort → Stuck) on a + // band whose count semantics nothing has pinned — so gen4 keeps the + // advisory-only behaviour until a gen4 capture settles it. + final gateEnforced = session.band.isGen5; final validated = expected == null || + !gateEnforced || d.validateBurst( expectedPacketCount: expected, droppedThisBurst: droppedThisBurst, ); + // The band computed `expected` for the window that just closed; count + // members arriving after this marker (re-offer-cycle chatter) must not + // inflate the tally a re-validation of this same burst judges. + d.closeBurstTally(); // How far short of the band's count this burst is, on the SAME all-types // tally the gate above just used (`currentBurstTrafficCount` and // `currentBurstPacketCount` are one number, not two counters). The gate @@ -4437,6 +4429,29 @@ class BleEngine { droppedThisBurst: droppedThisBurst, ); return; + } else if (gateEnforced) { + _burstMismatchStreak = 0; + } else if (shortfall != 0) { + // gen4: advisory only — record the mismatch for observability, keep + // ACKing exactly as the proven flow always has. + _burstMismatchTotal++; + _burstMismatchStreak++; + _log( + '[SYNC] burst packet-count mismatch (ADVISORY, gen4): ' + 'expected=$expected counted=${d.currentBurstTrafficCount} ' + 'dropped_this_burst=$droppedThisBurst short_by=$shortfall — ' + 'ACKing as always; the gen4 count semantics are unpinned.', + ); + await _bestEffortLedgerWrite(() => LocalDb.upsertSyncLedgerEntry( + status: 'validated_with_mismatch', + lastError: 'burst_packet_mismatch_advisory', + metaPatch: { + 'expected_burst_packets': expected, + 'dropped_this_burst': droppedThisBurst, + 'traffic_burst_packets': d.currentBurstTrafficCount, + 'burst_shortfall': shortfall, + }, + )); } else { _burstMismatchStreak = 0; } @@ -4449,11 +4464,11 @@ class BleEngine { // with its own missing/CRC-loss story, which read like a SECOND // completeness counter disagreeing with the gate. It never was one: both // lines have always come from the same all-types tally, and the only - // difference is the slack. In the field capture that produced this + // difference is the slack. In the on-air behaviour that produced this // change, its "potential loss" reading was wrong too — the missing frames // were the burst's own event/console members, counted into a different // burst window by the ordering bug this commit fixes, not lost on air. - if (shortfall > 0) { + if (gateEnforced && shortfall > 0) { _log( '[SYNC] burst passed the count gate ON SLACK: expected=$expected ' 'counted=${d.currentBurstTrafficCount} ' @@ -4537,7 +4552,7 @@ class BleEngine { commitDurable: durable, hadDurableRows: hadDurableRows, droppedThisBurst: droppedThisBurst, - // The doc-05 count gate already refused a short burst (failure result + // The count gate already refused a short burst (failure result // + band re-offer) before this point, so the one-shot shortfall // refusal is never spent here. shortfallRetry: false, @@ -4679,7 +4694,9 @@ class BleEngine { } else if (m.sub == SyncMeta.historyComplete) { final d = _drain; if (d == null) return; - if (!_offloadActive) { + // After a Stuck abort the offload flag is already down by design — that + // is not an out-of-band COMPLETE, so don't record it as one. + if (!_offloadActive && !session.historyStuck) { _setHpsTerminal( _HpsTerminalKind.metadataWhileNotSyncing, reason: 'history_complete_while_not_syncing', @@ -4891,8 +4908,8 @@ class BleEngine { // drain must not start while the phone clock is suspect, or the records // it pulls get stamped against a clock we do not trust. // No CLIENT_HELLO here any more: the connect path sends and AWAITS it - // during setup, before the clock decision, which is the official order - // (doc 01). Re-sending it at INIT would be a second identity exchange + // during setup, before the clock decision, which is the pinned order + //. Re-sending it at INIT would be a second identity exchange // after the point every consumer of it has already run. _log('Sending gen5 offload…'); var ok = false; @@ -5062,13 +5079,12 @@ class BleEngine { final ms = now.millisecondsSinceEpoch; final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units - // SET_CLOCK(10) with the 8-byte body is the OFFICIAL + // SET_CLOCK(10) with the 8-byte body is the real // command on BOTH generations. It used to send opcode 146 ("Maverick - // clock") on gen5 — a number that appears nowhere in the official 75-opcode - // enum recovered from WHOOP's own client, and which nothing has ever - // watched latch an RTC. The real gen5 contract is opcode 10, physically - // confirmed: a probe read the clock with GET_CLOCK(11), measured ~2410 ms - // of drift and received SUCCESS for this exact 8-byte form from a WHOOP 5. + // clock") on gen5 — not an established WHOOP opcode, and one nothing has + // ever watched latch an RTC. The real gen5 contract is opcode 10, + // hardware-confirmed: a WHOOP 5 answers GET_CLOCK(11) with a usable time + // and returns SUCCESS for this exact 8-byte SET form. // // This matters beyond tidiness: a rejected clock write is SILENT. The RTC // simply never latches, and every absolute timestamp afterwards — alarms @@ -5100,7 +5116,7 @@ class BleEngine { Future getClock() => _send(Cmd.getClock, const []); /// Apply a strap clock reading: phone-suspect verdict, correlation, and the /// bounded SET_CLOCK correction. Extracted so the gen5 HELLO timestamp and a - /// GET_CLOCK reply reach IDENTICAL logic — the official gen5 path takes its + /// GET_CLOCK reply reach IDENTICAL logic — the pinned gen5 path takes its /// clock from hello and never sends GET_CLOCK, so without this the two /// sources would drift apart in behaviour. void _absorbClockEpoch(int dev) { @@ -5196,13 +5212,13 @@ class BleEngine { ); } else if (_bootstrapClockWrite) { // The bootstrap's own clock step is the single writer for this - // connect (doc 01: "send one SET_CLOCK"). Writing here too sent a - // factory-fresh band TWO corrections back to back — doc 02's + // connect. Writing here too sent a + // factory-fresh band TWO corrections back to back — the // duplicate-persistent-write hazard. The retry budget is untouched: // the read-back after the bootstrap write lands once this window is // closed, and a still-wrong RTC re-corrects here as before. _log('Clock drift over policy — leaving the write to the bootstrap ' - 'clock step (one SET_CLOCK per connect, doc 01).'); + 'clock step (one SET_CLOCK per connect).'); } else if (_clockCorrectTries < 3) { // BOUND the retries: setClock() reads the clock back and this handler // re-issues on drift, so an unbounded loop would spin @@ -5246,20 +5262,20 @@ class BleEngine { /// signal that the read never landed. /// Send the gen5 `GET_HELLO(0x91)` and wait for its reply. /// - /// This runs BEFORE any clock work, which is the official order (doc 01): + /// This runs BEFORE any clock work, which is the pinned order: /// hello carries the strap's own timestamp, identity, battery, charge and - /// on-body state, and the official client feeds that timestamp straight into + /// on-body state, and the pinned flow feeds that timestamp straight into /// the clock decision rather than spending a GET_CLOCK round trip. Sending it /// late — as this app used to, inside INIT — meant the clock had already been /// read and written by then, so hello's timestamp could never be used and its /// identity fields arrived after everything that wanted them. /// /// Returns whether a reply landed. A timeout is NOT fatal: the caller falls - /// back to the GET_CLOCK path, which is exactly what the official client does + /// back to the GET_CLOCK path, which is exactly what the pinned flow does /// when hello supplies no timestamp. /// Correlated through the [CommandAwaiter]: the reply must echo THIS hello's /// sequence and opcode 145. GET_HELLO is also one of the two commands whose - /// `PENDING` is not terminal (doc 02), so a deferred reply keeps the await + /// `PENDING` is not terminal, so a deferred reply keeps the await /// open for the real result instead of reporting the strap as answered. Future _readGen5Hello() async { final out = await _sendAwaited( @@ -5282,7 +5298,7 @@ class BleEngine { } // A non-success result leaves the body unpopulated, and an unparseable // body leaves `_gen5Hello` null — either way there is no identity, no - // timestamp and nothing for the clock decision, which is doc 01's + // timestamp and nothing for the clock decision, which is the // "missing or failed hello". final hello = _gen5Hello; if (!resp.success || hello == null) { @@ -5296,10 +5312,10 @@ class BleEngine { return true; } - /// Matches the official 5-second command timeout (doc 02). + /// Matches the standard 5-second command timeout. static const Duration _helloTimeout = Duration(seconds: 5); - /// doc 01 §"What gates READY" (identity half) — recorded and logged, never a + /// (identity half) — recorded and logged, never a /// disconnect. See [HelloIdentity] for why this stays observable. void _noteHelloSuccess(Gen5HelloInfo h) { _helloFailures = 0; @@ -5310,16 +5326,16 @@ class BleEngine { ); _helloIdentity = id; if (!id.ok) { - _log('[HELLO gen5] identity gate FAILED ($id) — the official client ' + _log('[HELLO gen5] identity gate FAILED ($id) — a strict readiness gate ' 'requires serial and CPU to be alphanumeric; logged, not enforced.'); } if (id.eepromFailureSignal) { _log('[HELLO gen5] serial is all zeros — the strap is reporting an ' - 'EEPROM failure. Not a reject (doc 01); the band stays usable.'); + 'EEPROM failure. Not a reject; the band stays usable.'); } } - /// doc 01 §"Hello failure handling": record the failure, and at the fifth + /// record the failure, and at the fifth /// one reset the counter and remove the platform bond before starting over. Future _noteHelloFailure(String why) async { _helloFailures++; @@ -5362,7 +5378,7 @@ class BleEngine { // before correlation any of them could release this gate — including one // belonging to the PREVIOUS request. // - // The 3 s ceiling is kept rather than doc 02's generic 5 s: this read sits + // The 3 s ceiling is kept rather than the generic 5 s: this read sits // in the connect path and in the drain gate, and its timeout is a // proceed-on-the-last-verdict fallback, not a failure. final out = await _sendAwaited( @@ -5370,7 +5386,20 @@ class BleEngine { const [], timeout: _clockReadTimeout, ); - if (await out.response != null) return true; + final resp = await out.response; + if (resp != null) { + // Whether gen4 firmware echoes the originating sequence is unproven; a + // reply landing via the seq-zero fallback is the tell that it does not, + // and one line per connect is the cheapest way to find out from the + // field before anything is gated harder on the echo. + if (resp.viaSeqZeroFallback) { + _log( + '[SYNC] GET_CLOCK reply matched via the seq-zero fallback — this ' + 'band does not echo the originating sequence.', + ); + } + return true; + } _log( out.written ? '[SYNC] GET_CLOCK went unanswered for ' @@ -5410,8 +5439,7 @@ class BleEngine { /// /// * the write never left the phone, or /// * the strap answered and REFUSED it — a FAILURE/UNSUPPORTED outer result, - /// or an alarm-status byte from the input-rejection family (doc 07 - /// §"Alarm/haptics status codes": invalid waveform/loop/duration/time/ID). + /// or an alarm-status byte from the input-rejection family. /// That byte is "in addition to" the outer result and the doc says to /// check both — a strap can answer SUCCESS and still report `invalid /// alarm time`. The `arm info is invalid, error 0xb` seen when arming slot @@ -5530,60 +5558,6 @@ class BleEngine { await _send(Cmd.runAlarm, AlarmPayloads.runNow); } - /// Fire the STORED alarm now — the real wake, not a test pulse. - /// - /// This is a different thing from [runAlarm]. That one plays a short buzz so - /// the user can feel that the strap works; this one tells the firmware to - /// enter its ALARM state for the alarm already programmed in [slot], which is - /// the full stored waveform with its loop count, 30 s cap and 50%→100% - /// strength progression, terminated by timeout, error or a user double-tap. - /// A short test pulse does not wake a sleeping person; this does. - /// - /// It is also the ONLY band command needed to wake someone early: the band - /// holds an absolute deadline and can be told to run it ahead of time - /// (reversing-whoop doc 14 "Run alarm now — opcode 68" / "Wake in green"). - /// - /// Body per that doc: revision 2 plus the alarm ID, i.e. `02 01` for the ID 1 - /// the official client uses. NOTE the existing gen5 note on [runAlarm] — that - /// "RUN_ALARM does not buzz" on gen5 — was very likely observed with the - /// gen4 revision-1 body `[0x01]`, which protocol documents as doing nothing - /// on gen5. The rev-2 form has not been re-tested on hardware yet, so callers - /// must treat a wake driven by this as unconfirmed until it has (tracked in - /// reversing-whoop doc 15 G6). - /// - /// Returns whether the WRITE went out — callers treat the wake as - /// best-effort and must not block on the band. The reply (`[02, status]`, - /// doc 07) is correlated in the background and recorded in - /// [offloadSnapshot] as `last_run_alarm_status*`: on hardware that never - /// answered this command, whether the strap reports `played_successfully` - /// is the evidence the re-test needs, and it can only be collected from a - /// real band. - Future runStoredAlarm({int? slot}) async { - final band = _session?.band ?? BandProfile.gen4; - final id = slot ?? (band.isGen5 ? AlarmPayloads.gen5Slot : null); - final out = await _sendAwaited( - Cmd.runAlarm, - const [], - frameBuilder: (seq) => cmdRunAlarm(seq, mode: id, profile: band), - ); - if (!out.written) return false; - unawaited(out.response.then((resp) { - if (resp == null) { - _log('[ALARM] RUN_ALARM went unanswered — the early wake is ' - 'UNCONFIRMED (write ok, no correlated reply).'); - return; - } - final code = (resp.fields['alarm_status'] as num?)?.toInt(); - final name = resp.fields['alarm_status_name'] as String?; - _lastRunAlarmStatus = code; - _lastRunAlarmStatusName = name; - _lastRunAlarmTs = _wallSecs().round(); - _log('[ALARM] RUN_ALARM reply — result=${resp.status} ' - 'alarm_status=${code ?? 'absent'} (${name ?? 'no status byte'}).'); - })); - return true; - } - /// Cancel the on-device alarm (DISABLE_ALARM = 0x45). gen4 body `[0x01]` /// (the earlier `[0x00]` body was ACKed but did not clear the alarm); gen5 /// needs revision 2 plus the alarm id, defaulting to "all slots" — see @@ -6106,7 +6080,11 @@ class DrainController { records++; recordsThisOffload++; _lastProgressAt = DateTime.now(); - burstStats.onHistoricalData(raw.packetType, raw.counter, sample, raw.hex); + // The tally covers the marker-to-marker window only ([closeBurstTally]) — + // the record itself is still banked either way. + if (!_burstTallyClosed) { + burstStats.onHistoricalData(raw.packetType, raw.counter, sample, raw.hex); + } if (_buffering) { _raws.add(raw); _samples.add(sample); @@ -6145,7 +6123,7 @@ class DrainController { records++; recordsThisOffload++; // The band's expected count tallies every type-47 frame it TRANSMITTED, - // decodable or not (doc 05: "unknown revisions still count"). The gen5 + // decodable or not. The gen5 // deep buffers (v20/v21/v26/v22) and any future firmware's revisions all // arrive through this path, so leaving them uncounted makes every burst // that carries one permanently short at the count gate. Same counter the @@ -6153,7 +6131,7 @@ class DrainController { // unknown=…). Gate-dropped archives stay excluded: validateBurst adds // them back via droppedThisBurst, and counting them here too would // double-count. - if (a.packetType == PacketType.historicalData) { + if (a.packetType == PacketType.historicalData && !_burstTallyClosed) { burstStats.onHistoricalData(a.packetType, a.counter, null, a.hex); } } @@ -6170,9 +6148,13 @@ class DrainController { void noteBatchAcked() => batches++; - void onBurstEvent() => burstStats.onEvent(); + void onBurstEvent() { + if (!_burstTallyClosed) burstStats.onEvent(); + } - void onBurstConsole() => burstStats.onConsole(); + void onBurstConsole() { + if (!_burstTallyClosed) burstStats.onConsole(); + } /// [droppedThisBurst] = records the plausibility gate rejected during this /// same burst (stale/wandering-clock block) — never tallied into @@ -6216,6 +6198,7 @@ class DrainController { _linkDown = false; _lastProgressAt = DateTime.now(); burstStats.reset(); + _burstTallyClosed = false; } /// The band declared a new burst (HISTORY_START) — clear the poison latch. @@ -6227,7 +6210,27 @@ class DrainController { /// and its token was echoed, trimming exactly the records we dropped. Frames /// arrive in order, so a HISTORY_START proves the previous burst's terminal /// has already been handled (or is never coming) and the latch may clear. - void beginBurst() => _trimGuard.beginBurst(); + /// + /// A NEW burst also starts a FRESH validation cycle, and reopens the tally. + /// Without the failure reset, burst B's first attempt inherited burst A's + /// failure streak — and with it the two-frame slack — so a burst short by + /// two could be ACKed, trimming frames never tallied; and 15 different + /// bursts each failing once latched `historyStuck` under a log claiming one + /// burst failed 15 times. Marker-only re-offers of the SAME burst arrive + /// without a HISTORY_START, so their attempts still accumulate. + void beginBurst() { + _trimGuard.beginBurst(); + consecutiveValidationFailures = 0; + _burstTallyClosed = false; + } + + /// A HISTORY_END closes the burst's wire window: the band computed its + /// `expected` for the frames BETWEEN the markers, so members arriving after + /// the terminal — console/event chatter during the ~2.5 s re-offer cycle + /// above all — belong to no burst and must not push a short tally over the + /// line into an ACK. A new HISTORY_START ([beginBurst]) reopens counting. + void closeBurstTally() => _burstTallyClosed = true; + bool _burstTallyClosed = false; void onLinkDown() => _linkDown = true; diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index b00a8c3c..2caba10f 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -617,69 +617,11 @@ enum TrimAckVerdict { /// received traffic — some arrived corrupted or never arrived at all. The /// rows we DID get are already committed; refusing the token asks the band /// to re-send the chunk so the missing seconds get another chance instead of - /// being trimmed out of flash forever. Strictly bounded by - /// [BurstShortfallGate] — see the history in that class. + /// being trimmed out of flash forever. Strictly bounded by the caller — + /// an unbounded refusal wedged sync forever once. blockedBurstShortfall, } -/// The bound on "refuse the trim token because the burst was short". -/// -/// An UNCONDITIONAL refusal on shortfall is not an option: it was the old -/// behaviour, and it wedged sync forever — nothing about a retry changes the -/// count relationship when the shortfall is systematic (`expectedPacketCount`'s -/// exact semantics are not fully reverse-engineered), so the band re-delivered -/// the same block indefinitely and the cursor never moved. Accepting every -/// short burst is the opposite failure: the gap is counted, logged, and then -/// authorised for deletion. -/// -/// So: refuse ONCE, then take whatever arrives. A transient radio glitch — -/// the common case — is recovered on the re-delivery; a systematic shortfall -/// costs exactly one extra round trip and then proceeds. -/// -/// Bounded three ways, because only the first assumes the token is stable: -/// * per TOKEN — a chunk is refused at most once, so a stable token cannot -/// ping-pong; -/// * per SESSION — [maxPerSession], in case the band re-issues a fresh token -/// for the same data (which would defeat the per-token bound); -/// * per ENGINE RUN — [maxTotal], the backstop for a link that is short on -/// every burst. Past it, shortfalls are telemetry again. -/// -/// Pure: no clock, no I/O. -class BurstShortfallGate { - BurstShortfallGate({this.maxPerSession = 1, this.maxTotal = 3}); - - final int maxPerSession; - final int maxTotal; - - /// Bounded so a long-lived engine cannot grow this without limit; a token - /// evicted here can be refused once more, which the two counters still cap. - static const int _maxTracked = 64; - final Set _refusedTokens = {}; - int _thisSession = 0; - int _total = 0; - - int get refusalsThisSession => _thisSession; - int get refusalsTotal => _total; - - /// Whether this HISTORY_END token should be refused over a positive - /// shortfall. Records the refusal when it returns true — call it once per - /// decision, at the point of decision. - bool refuse(String tokenHex) { - if (_thisSession >= maxPerSession || _total >= maxTotal) return false; - if (!_refusedTokens.add(tokenHex)) return false; - if (_refusedTokens.length > _maxTracked) { - _refusedTokens.remove(_refusedTokens.first); - } - _thisSession++; - _total++; - return true; - } - - /// New link — the per-session budget refills. [maxTotal] deliberately does - /// not, so a band that is short on every burst of every session stops - /// costing round trips. - void onSessionStart() => _thisSession = 0; -} /// THE gate on the one irreversible act in the whole offload protocol: echoing /// a HISTORY_END continuation token, which is what tells the band it may trim @@ -719,7 +661,7 @@ class TrimAckPolicy { /// [droppedThisBurst] — RecordGate rejects during this burst. Combined with /// `!hadDurableRows`, refuses trim so gate-only bursts /// cannot delete flash we never stored. - /// [shortfallRetry] — [BurstShortfallGate] has budget to spend one refusal + /// [shortfallRetry] — the caller has budget to spend one refusal /// on this token's positive shortfall. Pass `false` on /// the PRE-commit call: this refusal must happen only /// AFTER the rows we did receive are durable, or the @@ -858,7 +800,7 @@ enum FrameRoute { /// position, where the burst COUNT for it is applied. /// /// Burst count members that are not type-47 data (events 48, console 50, - /// puffin wrappers 53/54/55 — doc 05 §"Count membership") arrive on a + /// puffin wrappers 53/54/55 — ) arrive on a /// different characteristic than the data frames but over the SAME ACL link, /// so the band's transmit order is the arrival order. Counting them inline /// while the data frames and their HISTORY_END queue up REORDERS the count: @@ -884,7 +826,7 @@ enum FrameRoute { class FrameRoutePolicy { const FrameRoutePolicy._(); - /// [isBurstCountMember] is doc 05 §"Count membership" for the non-data + /// [isBurstCountMember] is for the non-data /// families (48/50/53/54/55); [offloadActive] is whether a history session is /// running at all, since outside one there is no burst to count into. static FrameRoute route({ @@ -1319,116 +1261,11 @@ class AlarmConfirmation { } } -/// What a [ConditionalWakePolicy] tick wants the caller to do. -enum ConditionalWakeAction { - /// Nothing to do — outside the window, or already handled. - none, - - /// Open the wake window: ask the strap for more frequent sync prompts. - openWindow, - - /// Close it again (window passed, alarm cleared, or feature turned off). - closeWindow, - - /// The condition is met — fire the STORED alarm NOW, once. - fireNow, -} - -/// The "wake me when I'm recovered, but no later than X" decision, as a pure -/// function of time and inputs. The engine/app owns the I/O; this owns the -/// rules. -/// -/// The band does NOT decide this. It holds one absolute deadline and can be -/// told to run that alarm early — that is the whole mechanism (reversing-whoop -/// doc 14 "The implementation boundary" / "Wake in green"). WHOOP's own client -/// asks its server whether the condition is met; an on-device app that already -/// computes recovery locally can answer the same question itself, with no -/// network at all — and unlike the official flow, it still works offline. -/// -/// Two properties matter more than cleverness here, because the failure mode is -/// waking a person at the wrong time: -/// -/// * **The deadline is the safety net.** The stored alarm is programmed first -/// and left armed. Everything below only ever moves the wake EARLIER, inside -/// the window. If this policy never fires — app killed, band out of range, -/// condition never met — the strap still wakes them from its own RTC. -/// * **Fire exactly once.** [fired] latches, so a second qualifying tick (or a -/// replayed/duplicated input) cannot wake someone twice. The caller must -/// persist the latch before doing anything retryable, per the same doc. -class ConditionalWakePolicy { - /// How long before the deadline the window opens. The official client uses - /// two hours, and the high-frequency sync duration (7200 s) matches it. - static const Duration window = Duration(hours: 2); - - /// Latched once the early wake has been sent, so it can never repeat. - bool fired = false; - - /// True while the strap has been asked for frequent prompts. - bool windowOpen = false; - - /// The deadline this policy is currently tracking, so a rescheduled alarm - /// resets the latch instead of inheriting the previous night's. - int? trackedDeadlineEpoch; - - /// Decide what to do at [nowEpoch]. - /// - /// [deadlineEpoch] is the armed stored alarm (null = no alarm). [conditionMet] - /// is the caller's own answer to "is the user recovered?" — deliberately a - /// plain bool, because this class must not know or care how that was computed. - /// [enabled] is the user's opt-in. - ConditionalWakeAction tick({ - required int nowEpoch, - required int? deadlineEpoch, - required bool conditionMet, - required bool enabled, - }) { - // A new/changed/cleared deadline is a new night: forget the old latch. - if (deadlineEpoch != trackedDeadlineEpoch) { - trackedDeadlineEpoch = deadlineEpoch; - fired = false; - } - if (!enabled || deadlineEpoch == null) { - return _close(); - } - // Past the deadline the strap's own RTC owns the wake; nothing to add. - if (nowEpoch >= deadlineEpoch) return _close(); - - final opensAt = deadlineEpoch - window.inSeconds; - if (nowEpoch < opensAt) return _close(); - - // Inside the window. - if (conditionMet && !fired) { - fired = true; - // Leave the window open: the caller still wants the strap reachable, and - // closing it is a separate decision once the wake is acknowledged. - return ConditionalWakeAction.fireNow; - } - if (!windowOpen) { - windowOpen = true; - return ConditionalWakeAction.openWindow; - } - return ConditionalWakeAction.none; - } - - ConditionalWakeAction _close() { - if (!windowOpen) return ConditionalWakeAction.none; - windowOpen = false; - return ConditionalWakeAction.closeWindow; - } - - /// Restore the fire-once latch from storage (call before the first [tick] of - /// a process, so a restart cannot re-wake the user). - void restore({required int? deadlineEpoch, required bool alreadyFired}) { - trackedDeadlineEpoch = deadlineEpoch; - fired = alreadyFired; - } -} - -// ── command/response correlation (doc 02) ──────────────────────────────────── +// ── command/response correlation ──────────────────────────────────── /// A command response that was matched to a request we actually made. /// -/// Wire layout (doc 02 "Command response"): +/// Wire layout: /// `[36][response seq][echoed opcode][originating seq][result][body…]`. class CorrelatedResponse { /// The echoed opcode — equal to the opcode of the request by construction. @@ -1467,8 +1304,7 @@ enum CommandDelivery { /// It satisfied a pending request, which is now complete. completed, - /// It matched a pending request whose PENDING is non-terminal (doc 02's - /// per-command table) — the await stays open for the terminal result. + /// It matched a pending request whose PENDING is non-terminal — the await stays open for the terminal result. pendingHeld, /// Nothing was waiting for it, or it failed the match rules (wrong opcode @@ -1477,7 +1313,7 @@ enum CommandDelivery { } /// One outstanding command transaction. Created by [CommandAwaiter.register] -/// BEFORE the write goes out (doc 02 "Ordering"). +/// BEFORE the write goes out. class PendingCommand { final int seq; final int opcode; @@ -1491,7 +1327,7 @@ class PendingCommand { /// The correlated reply, or null once [timeout] expires. /// /// The timeout is applied EXACTLY ONCE and there is no automatic resend - /// (doc 02 "Timeouts and retries") — retry, disconnect and abort belong to + /// — retry, disconnect and abort belong to /// the calling state machine. Lazily built, so registering a command that is /// never awaited never arms a timer. late final Future response = _completer.future.timeout( @@ -1518,7 +1354,7 @@ class PendingCommand { } /// The registry that turns fire-and-forget writes into real request/response -/// transactions (doc 02 "Sequence allocation and response correlation"). +/// transactions. /// /// Match rule — a response is accepted only when **both** fields agree: /// ```text @@ -1535,7 +1371,7 @@ class PendingCommand { /// sequence, frames and writes; this only says which reply belongs to which /// request. class CommandAwaiter { - /// doc 02 "Timeouts and retries" — the generic command await. + /// The generic five-second command await. static const Duration defaultTimeout = Duration(milliseconds: 5000); static const int statusFailure = 0; @@ -1543,13 +1379,12 @@ class CommandAwaiter { static const int statusPending = 2; static const int statusUnsupported = 3; - /// The only commands whose `PENDING` is NON-terminal (doc 02 "`PENDING` is - /// per-command"): GET_HELLO(145) and GET_DATA_RANGE(34) keep waiting for a + /// The only commands whose `PENDING` is NON-terminal: GET_HELLO(145) and GET_DATA_RANGE(34) keep waiting for a /// terminal failure/success/unsupported. Every other command completes on /// the first matching response, PENDING included. static const Set pendingIsNonTerminal = {0x91, 0x22}; - /// Whether to honour doc 02's optional "Sequence-zero compatibility path": + /// Whether to honour the optional sequence-zero compatibility path: /// a response whose originating sequence is 0 may match a nonzero request by /// opcode. The doc's own caveat is that two outstanding requests with the /// same opcode then become ambiguous — so a fallback match is only taken @@ -1572,7 +1407,7 @@ class CommandAwaiter { bool hasPendingSeq(int seq) => _pending.any((p) => p.seq == seq); /// Install an observer for a command about to be written. Call this BEFORE - /// the write (doc 02 "Ordering") so a fast response cannot arrive before its + /// the write so a fast response cannot arrive before its /// observer exists. PendingCommand register( int seq, @@ -1636,9 +1471,9 @@ class CommandAwaiter { void _forget(PendingCommand p) => _pending.remove(p); } -/// The identity half of doc 01 §"What gates READY", as an OBSERVATION. +/// The identity half of as an OBSERVATION. /// -/// The official client requires the serial and CPU strings to match +/// A strict readiness gate requires the serial and CPU strings to match /// `[a-zA-Z0-9]+` before it calls a connection ready. This app records the /// verdict and logs it rather than dropping the link: a hard disconnect on an /// identity read we have far less hardware evidence for would turn a cosmetic @@ -1679,9 +1514,9 @@ class HelloIdentity { '${eepromFailureSignal ? ' serial=all-zero(EEPROM)' : ''}'; } -/// The bootstrap clock gate from doc 01 §"Clock contract". +/// The bootstrap clock gate from . /// -/// The official client compares the timestamp hello already returned (or, as a +/// The pinned flow compares the timestamp hello already returned (or, as a /// fallback, a `GET_CLOCK` reply) against host time and writes NOTHING below /// two whole seconds of absolute drift: "Below 2 whole seconds, succeed with no /// BLE write. At 2 or more, send one `SET_CLOCK(10)`". This app used to send @@ -1712,7 +1547,7 @@ class BootstrapClockGate { } /// Whether a `GET_BATTERY_PACK_INFO(151)` reply actually identifies a pack -/// (doc 01 §"Charging follow-up", doc 03 §`GET_BATTERY_PACK_INFO`). +///. /// /// "A response is usable only if its pack address/name field is non-empty and /// is not `00:00:00:00:00:00`" — the band answers the command while it is still @@ -1728,46 +1563,13 @@ class BatteryPackInfoGate { static bool usable({required String identifier, required String name}) { final id = identifier.trim().toLowerCase(); + final nm = name.trim().toLowerCase(); if (id == unsetAddress) return false; - return id.isNotEmpty || name.trim().isNotEmpty; + if (id.isNotEmpty) return true; + // No identifier: a name alone carries the reply only when it is a real + // name — the sentinel address leaking through the name field is still + // "no pack yet". + return nm.isNotEmpty && nm != unsetAddress; } } -/// The last `STRAP_CONDITION_REPORT(29)` event the band volunteered -/// (doc 04 §"Type 48 — events"). -/// -/// OBSERVABILITY ONLY. This is the band telling us, unasked, how far behind its -/// flash we are — the cheapest sync-progress signal there is — but nothing here -/// starts an offload; the backfill triggers are unchanged. -/// -/// [pagesBehind] is the SAME modular PAGE span `GET_DATA_RANGE` reports, not a -/// packet or record count (doc 05, ~15 records/page nominal). [wristState] is -/// the raw tri-state byte: the doc names no mapping for its three values, so -/// wear truth still comes from WRIST_ON/WRIST_OFF and hello, never from here. -/// Every field is nullable because a short body decodes to its prefix only. -class StrapConditionReport { - /// The event's own strap timestamp — the band re-serves buffered events on - /// connect, so a report is only evidence about the moment it names. - final int tsEpoch; - final int? pagesBehind; - final double? backlog; - final double? socPct; - final int? flash; - final bool? charging; - final int? wristState; - - const StrapConditionReport({ - required this.tsEpoch, - this.pagesBehind, - this.backlog, - this.socPct, - this.flash, - this.charging, - this.wristState, - }); - - @override - String toString() => 'pages_behind=$pagesBehind backlog=$backlog ' - 'soc=$socPct% flash=$flash charging=$charging wrist=$wristState ' - 'ts=$tsEpoch'; -} diff --git a/lib/data/db.dart b/lib/data/db.dart index 24419e25..64478e19 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -6379,6 +6379,19 @@ class LocalDb { if (cols.contains(e.key)) e.key: e.value, }; if (row.isEmpty) continue; + if (t == 'decoded_onehz') { + // A pre-v46 export still carries the retired columns as + // VALUES (the disproven on_wrist/hr_valid reads and the + // -50.00 °C skin-temp error sentinel). Importing them + // verbatim would reinstate exactly the rows the v46 + // data-retirement cleaned, so the same rule applies at this + // boundary — the migration only runs on version bumps and + // never sees imported rows. + if (cols.contains('on_wrist')) row['on_wrist'] = null; + if (cols.contains('hr_valid')) row['hr_valid'] = null; + final st = row['skin_temp_c']; + if (st is num && st <= -49.995) row['skin_temp_c'] = null; + } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index f206f3d3..6481c185 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3331,7 +3331,7 @@ class AppState extends ChangeNotifier { }) async { var last = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { - // Terminal `Stuck` (doc 05 §"Retry boundary"): a burst failed validation + // Terminal `Stuck`: a burst failed validation // 15 times and the abort went out, so this connection's history is over. // The engine refuses every further drain trigger, but stopping here too // keeps the loop from spending its remaining sessions waiting out an idle @@ -3598,11 +3598,12 @@ class AppState extends ChangeNotifier { // Do NOT persist or start the confirmation machine, or we'd strand a // phantom alarm "waiting for the strap to confirm" that can never fire. // Null now covers two cases: the write never left the phone, and the - // strap answered and REFUSED the alarm (doc 07's alarm-status byte — - // see BleEngine.setAlarm). Both mean the band holds no alarm, so both + // strap answered and REFUSED the alarm. Both mean the band holds no alarm, so both // must stay out of persistence; the engine log says which one it was. _log('[alarm] the band did not take the alarm — not persisting.'); - throw Exception('Alarm not set — the strap did not accept it'); + // Neutral on purpose: null covers both a write that never left the + // phone and an explicit refusal — the engine log says which. + throw Exception('Alarm not set'); } final epoch = armed.millisecondsSinceEpoch ~/ 1000; _savedAlarm = epoch; diff --git a/pubspec.lock b/pubspec.lock index 59539cfa..33204675 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -652,10 +652,10 @@ packages: dependency: "direct main" description: name: health - sha256: "148ce984c2119f50224b4d187552d751b91aa47f4de8968daf05e6e596ddee50" + sha256: "0432c4e5c5348164adff57e78ca3191c88f0cdf7c2b0d72b6785a6af965177ac" url: "https://pub.dev" source: hosted - version: "11.1.1" + version: "12.2.1" home_widget: dependency: "direct main" description: @@ -924,17 +924,19 @@ packages: dependency: "direct main" description: path: "." - ref: bfea5e56e74f336c3e3d83743123e58da225617d - resolved-ref: bfea5e56e74f336c3e3d83743123e58da225617d + ref: d9362a66fbeac326d5d7d7b1fe27b28e41169a79 + resolved-ref: d9362a66fbeac326d5d7d7b1fe27b28e41169a79 url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "../protocol" - relative: true - source: path + path: "." + ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 + resolved-ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 + url: "https://github.com/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" diff --git a/test/absence_and_offload_guards_test.dart b/test/absence_and_offload_guards_test.dart index 8bd9a189..a5d24d6d 100644 --- a/test/absence_and_offload_guards_test.dart +++ b/test/absence_and_offload_guards_test.dart @@ -92,32 +92,6 @@ void main() { }); }); - group('BurstShortfallGate — bounded, because always-FAIL wedged sync', () { - test('the first short burst is refused, the redelivery is not', () { - final g = BurstShortfallGate(); - expect(g.refuse('aa'), isTrue); - expect(g.refuse('aa'), isFalse, - reason: 'a stable token must never ping-pong'); - }); - - test('a fresh token in the same session is still capped', () { - final g = BurstShortfallGate(); - expect(g.refuse('aa'), isTrue); - // A band re-issuing a NEW token for the same data would defeat the - // per-token bound; the per-session budget catches it. - expect(g.refuse('bb'), isFalse); - }); - - test('a new session refills the session budget but not the run total', () { - final g = BurstShortfallGate(maxPerSession: 1, maxTotal: 2); - expect(g.refuse('a'), isTrue); - g.onSessionStart(); - expect(g.refuse('b'), isTrue); - g.onSessionStart(); - expect(g.refuse('c'), isFalse, reason: 'run total is the backstop'); - expect(g.refusalsTotal, 2); - }); - }); group('TrimAckPolicy — the shortfall refusal is last, and only post-commit', () { diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 8e572e8b..2d2544ae 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -3,7 +3,7 @@ // 7-byte time-only form) and the RUN/DISABLE bodies (AlarmPayloads), // - the strap-event confirmation state machine (AlarmConfirmation), and // - the arm/run decision made on the correlated reply's alarm-status byte -// (doc 07), driven over the engine's fake-link seam. +//, driven over the engine's fake-link seam. // No radio and no DB — everything here is deterministic. import 'dart:typed_data'; @@ -18,7 +18,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; /// A gen5 link with no radio behind it, plus the seq of every command written. /// Same seam as `command_correlation_test.dart`: the reply is injected from /// INSIDE the write, i.e. before the write call returns, which is the ordering -/// doc 02 demands and the one a fast strap actually produces. +/// the correlation contract demands and the one a fast strap produces. class _Link { final logs = []; final written = <({int seq, int opcode})>[]; @@ -288,93 +288,8 @@ void main() { }); }); - group('ConditionalWakePolicy — wake early, never later', () { - const deadline = 1750000000; // the armed stored alarm - const opensAt = deadline - 2 * 3600; // official 2-hour window - - ConditionalWakeAction run( - ConditionalWakePolicy p, { - required int now, - bool met = false, - bool enabled = true, - int? dl = deadline, - }) => - p.tick( - nowEpoch: now, - deadlineEpoch: dl, - conditionMet: met, - enabled: enabled, - ); - - test('does nothing before the window, opens it on entry', () { - final p = ConditionalWakePolicy(); - expect(run(p, now: opensAt - 60, met: true), ConditionalWakeAction.none, - reason: 'a met condition before the window must NOT wake anyone'); - expect(run(p, now: opensAt + 1), ConditionalWakeAction.openWindow); - expect(run(p, now: opensAt + 2), ConditionalWakeAction.none, - reason: 'window already open — no repeat command'); - }); - - test('fires once, and only once, when the condition is met inside it', () { - final p = ConditionalWakePolicy(); - run(p, now: opensAt + 1); // opens - expect(run(p, now: opensAt + 600, met: true), - ConditionalWakeAction.fireNow); - // Every later tick — including more met ticks — must stay silent. - expect( - run(p, now: opensAt + 601, met: true), ConditionalWakeAction.none); - expect( - run(p, now: opensAt + 900, met: true), ConditionalWakeAction.none); - expect(p.fired, isTrue); - }); - - test('past the deadline the strap RTC owns the wake — never a late fire', - () { - final p = ConditionalWakePolicy(); - run(p, now: opensAt + 1); - // The single most important negative: this policy may only move a wake - // EARLIER. After the deadline it must go quiet and let the band fire. - expect(run(p, now: deadline, met: true), ConditionalWakeAction.closeWindow); - expect(run(p, now: deadline + 60, met: true), ConditionalWakeAction.none); - expect(p.fired, isFalse); - }); - - test('opt-out and a cleared alarm both close the window', () { - final p = ConditionalWakePolicy(); - run(p, now: opensAt + 1); - expect(run(p, now: opensAt + 2, enabled: false), - ConditionalWakeAction.closeWindow); - - final q = ConditionalWakePolicy(); - run(q, now: opensAt + 1); - expect(run(q, now: opensAt + 2, dl: null), - ConditionalWakeAction.closeWindow); - }); - test('a rescheduled alarm is a new night: the latch resets', () { - final p = ConditionalWakePolicy(); - run(p, now: opensAt + 1); - expect(run(p, now: opensAt + 60, met: true), ConditionalWakeAction.fireNow); - - const tomorrow = deadline + 86400; - // Same policy object, new deadline — the previous night's latch must not - // suppress tomorrow's early wake. - expect( - run(p, now: tomorrow - 3600, met: true, dl: tomorrow), - ConditionalWakeAction.fireNow, - ); - }); - - test('a restored latch survives a restart and cannot re-wake', () { - final p = ConditionalWakePolicy() - ..restore(deadlineEpoch: deadline, alreadyFired: true); - expect(run(p, now: opensAt + 600, met: true), ConditionalWakeAction.openWindow, - reason: 'window may reopen, but the wake must not repeat'); - expect(run(p, now: opensAt + 700, met: true), ConditionalWakeAction.none); - }); - }); - - // doc 07 §"Command bodies": the SET_ALARM_TIME reply carries a haptics/alarm + // the SET_ALARM_TIME reply carries a haptics/alarm // status byte "in addition to the ordinary outer command result — check // both". Before this, the engine treated a successful WRITE as an armed // alarm, so a strap that answered `invalid alarm time` left the app showing @@ -473,60 +388,4 @@ void main() { }); }); - // RUN_ALARM(68) is the wake-in-green trigger and has never been verified on - // WHOOP 5 hardware with the rev-2 body. Its `[02, status]` reply is the - // evidence trail a hardware re-test reads back out of the snapshot. - group('engine wiring — runStoredAlarm records what the strap answered', () { - test('the reply status lands in the offload snapshot', () async { - final link = _Link( - replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm - ? _alarmReply(opcode, seq, proto.AlarmStatus.playedSuccessfully, - revision: 2) - : null, - ); - - expect(await link.engine.runStoredAlarm(), isTrue, - reason: 'the bool is the WRITE — the wake stays best-effort'); - await pumpEventQueue(); - - final snap = link.engine.offloadSnapshot; - expect(snap['last_run_alarm_status'], proto.AlarmStatus.playedSuccessfully); - expect(snap['last_run_alarm_status_name'], 'played_successfully'); - expect(snap['last_run_alarm_ts'], isNotNull); - expect(link.logged('RUN_ALARM reply'), isTrue); - }); - - test('a haptics failure is recorded too, not swallowed', () async { - final link = _Link( - replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm - ? _alarmReply(opcode, seq, proto.AlarmStatus.hapticsFailure, - outer: 0, revision: 2) - : null, - ); - - // Still true: the write went out. The verdict lives in the snapshot. - expect(await link.engine.runStoredAlarm(), isTrue); - await pumpEventQueue(); - expect(link.engine.offloadSnapshot['last_run_alarm_status_name'], - 'haptics_failure'); - }); - - test('the RUN_ALARM frame is correlated on its own sequence', () async { - final link = _Link( - replyTo: (seq, opcode) => opcode == proto.Cmd.runAlarm - ? _alarmReply(opcode, seq, proto.AlarmStatus.playedSuccessfully, - revision: 2) - : null, - ); - await link.engine.runStoredAlarm(); - await pumpEventQueue(); - // The frame is built by the protocol helper, so the awaiter's sequence - // has to be threaded THROUGH it — a hard-coded seq inside cmdRunAlarm - // would never match. - final run = - link.written.lastWhere((w) => w.opcode == proto.Cmd.runAlarm); - expect(run.seq, greaterThanOrEqualTo(SeqAllocator.liveFloor)); - expect(link.engine.pendingCommandCount, 0); - }); - }); } diff --git a/test/ble_clock_gate_test.dart b/test/ble_clock_gate_test.dart index 25f5a458..57daca66 100644 --- a/test/ble_clock_gate_test.dart +++ b/test/ble_clock_gate_test.dart @@ -136,7 +136,7 @@ void _transportTests() { int seqOf(Uint8List frame) => frame[5]; /// A GET_CLOCK reply CORRELATED to the request that asked for it: the read - /// only accepts a reply echoing both its sequence and its opcode (doc 02), so + /// only accepts a reply echoing both its sequence and its opcode, so /// a test reply without the sequence proves nothing about the gate. Decoded clockReply(int strapEpoch, int reqSeq) => Decoded('cmd_response', { 'opcode': Cmd.getClock, diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 6b2bf795..9c57a15e 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -122,8 +122,8 @@ void main() { ); }); - // The official rule is one-sided with a failure-dependent slack, NOT - // equality (doc 05 "Collector and count gate"). + // The pinned rule is one-sided with a failure-dependent slack, NOT + // equality. test('SURPLUS passes — there is no upper bound', () { // The strap re-offers an unacknowledged burst and may re-deliver frames, // so tallying more than expected is normal. Equality failed this. diff --git a/test/command_correlation_test.dart b/test/command_correlation_test.dart index 7050845c..2cf5f188 100644 --- a/test/command_correlation_test.dart +++ b/test/command_correlation_test.dart @@ -1,4 +1,4 @@ -// Command/response correlation — doc 02 "Sequence allocation and response +// Command/response correlation — sequence allocation and response // correlation", "Sequence-zero compatibility path", "Ordering", "`PENDING` is // per-command" and "Timeouts and retries". // @@ -23,7 +23,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart'; const _fast = Duration(milliseconds: 40); -/// A synthetic revision-1 gen5 hello body (doc 01 "Revision-1 hello body"), +/// A synthetic revision-1 gen5 hello body, /// parsed by the real protocol decoder so the identity fields under test are /// the ones a strap would actually produce. Uint8List _helloBody({String serial = 'W5AB12CD34', int tsSeconds = 0}) { @@ -82,7 +82,8 @@ class _Link { written.add((seq: seq, opcode: opcode)); if (!writesSucceed) return false; // The reply is injected from INSIDE the write, i.e. before the write - // call has even returned to `_sendAwaited`. That is the ordering doc 02 + // call has even returned to `_sendAwaited`. That is the ordering the + // contract // demands: install the observer, then write. A registry built the other // way round loses every fast response. final reply = replyTo?.call(seq, opcode); @@ -101,7 +102,7 @@ void main() { setUp(BleEngine.resetBandClaimForTest); tearDown(BleEngine.resetBandClaimForTest); - group('CommandAwaiter — both fields must match (doc 02)', () { + group('CommandAwaiter — both fields must match', () { test('a reply echoing the sequence AND the opcode satisfies the await', () async { final a = CommandAwaiter(); @@ -133,7 +134,7 @@ void main() { expect( a.deliver(opcode: Cmd.getClock, reqSeq: 0xA0, status: 1), CommandDelivery.unmatched, - reason: 'doc 02: a sequence match by itself is insufficient', + reason: 'a sequence match by itself is insufficient', ); expect(a.pendingCount, 1, reason: 'the await must stay open'); expect(await p.response, isNull, reason: 'and then expire'); @@ -171,7 +172,7 @@ void main() { }); }); - group('CommandAwaiter — sequence-zero compatibility path (doc 02)', () { + group('CommandAwaiter — sequence-zero compatibility path', () { test('an originating seq of 0 matches a nonzero request by opcode', () async { final a = CommandAwaiter(); @@ -195,7 +196,7 @@ void main() { test('two outstanding requests for one opcode make it AMBIGUOUS — refuse', () async { - // doc 02's own caveat: "if you implement this fallback, serialize command + // The fallback's own caveat: "if you implement this fallback, serialize command // transactions, otherwise two outstanding requests with the same opcode // become ambiguous". Guessing which one a seq-0 reply belongs to is how // an old request's answer becomes the new request's result. @@ -220,7 +221,7 @@ void main() { }); }); - group('CommandAwaiter — PENDING is per-command (doc 02)', () { + group('CommandAwaiter — PENDING is per-command', () { test('GET_HELLO(145) waits past PENDING for a terminal result', () async { final a = CommandAwaiter(); final p = a.register(7, Cmd.getHello, timeout: _fast); @@ -308,7 +309,7 @@ void main() { }); }); - group('CommandAwaiter — timeouts and lifetime (doc 02)', () { + group('CommandAwaiter — timeouts and lifetime', () { test('the timeout is 5,000 ms, applied once, with no resend', () async { expect(CommandAwaiter.defaultTimeout, const Duration(milliseconds: 5000)); final a = CommandAwaiter(); @@ -354,7 +355,7 @@ void main() { }); }); - group('HelloIdentity — doc 01 "What gates READY", observed not enforced', () { + group('HelloIdentity — the READY identity gate, observed not enforced', () { test('alphanumeric serial and CPU pass', () { final id = HelloIdentity.evaluate(serial: 'W5AB12CD34', cpuHex: 'abc123'); expect(id.ok, isTrue); @@ -382,7 +383,7 @@ void main() { cpuHex: 'ab', eepromFailureSignal: true, ); - expect(id.ok, isTrue, reason: 'doc 01: not a reject on its own'); + expect(id.ok, isTrue, reason: 'not a reject on its own'); expect(id.eepromFailureSignal, isTrue); }); }); @@ -442,7 +443,7 @@ void main() { final hello = link.engine.debugReadGen5Hello(); await pumpEventQueue(); expect(link.engine.pendingCommandCount, 1, - reason: 'GET_HELLO(145) waits past PENDING (doc 02)'); + reason: 'GET_HELLO(145) waits past PENDING'); link.engine.debugAbsorbDecoded(_helloReply(link.seqOf(Cmd.getHello))); expect(await hello, isTrue); @@ -460,7 +461,7 @@ void main() { }); }); - group('engine wiring — hello failures and the bond reset (doc 01)', () { + group('engine wiring — hello failures and the bond reset', () { test('failures accumulate and the fifth resets the counter + the bond', () async { final link = _Link(writesSucceed: false); @@ -472,7 +473,7 @@ void main() { } expect(await link.engine.debugReadGen5Hello(), isFalse); expect(link.engine.helloFailureCount, 0, - reason: 'doc 01: at five, reset the counter'); + reason: 'at five, reset the counter'); expect(link.logs.any((l) => l.contains('bond')), isTrue, reason: 'and remove the platform bond before starting over'); expect(BleEngine.kHelloFailuresBeforeBondReset, 5); @@ -504,7 +505,7 @@ void main() { }); }); - group('engine wiring — identity is logged, never enforced (doc 01)', () { + group('engine wiring — identity is logged, never enforced', () { test('a non-alphanumeric serial is flagged but the hello still succeeds', () async { final link = _Link( diff --git a/test/db_paged_import_export_test.dart b/test/db_paged_import_export_test.dart index ae968ca0..02c1344e 100644 --- a/test/db_paged_import_export_test.dart +++ b/test/db_paged_import_export_test.dart @@ -277,4 +277,59 @@ void main() { } }); }); + + group('importFromDbFile applies the v46 data rule at the seam', () { + test('a pre-v46 backup cannot reinstate the retired columns', () async { + await clearLocal(); + // A pre-v46 export: rows still carry the disproven on_wrist/hr_valid + // values and the -50.00 °C skin-temp error sentinel. The v46 migration + // only runs on version bumps, so the import seam is the ONLY line of + // defence for these rows. + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute(''' + CREATE TABLE decoded_onehz ( + rec_ts INTEGER PRIMARY KEY, counter INTEGER NOT NULL, + hr INTEGER, skin_temp_c REAL, on_wrist INTEGER, hr_valid INTEGER) + '''); + await src.insert('decoded_onehz', { + 'rec_ts': 1786200000, + 'counter': 1, + 'hr': 62, + 'skin_temp_c': -50.0, // the unavailable/error sentinel + 'on_wrist': 1, + 'hr_valid': 1, + }); + await src.insert('decoded_onehz', { + 'rec_ts': 1786200001, + 'counter': 2, + 'hr': 63, + 'skin_temp_c': 30.57, // a real reading must survive untouched + 'on_wrist': 1, + 'hr_valid': 0, + }); + await src.close(); + + await LocalDb.importFromDbFile(srcPath); + + final db = await LocalDb.instance; + final rows = await db.rawQuery( + 'SELECT rec_ts, hr, skin_temp_c, on_wrist, hr_valid ' + 'FROM decoded_onehz WHERE rec_ts IN (1786200000, 1786200001) ' + 'ORDER BY rec_ts', + ); + expect(rows, hasLength(2)); + expect(rows[0]['hr'], 62, reason: 'the honest fields import normally'); + expect(rows[0]['skin_temp_c'], isNull, + reason: 'the -50.00 °C sentinel is an absence, not a temperature'); + expect(rows[1]['skin_temp_c'], closeTo(30.57, 1e-9), + reason: 'a real reading is not collateral damage'); + for (final r in rows) { + expect(r['on_wrist'], isNull, + reason: 'no honest writer exists for on_wrist'); + expect(r['hr_valid'], isNull, + reason: 'no honest writer exists for hr_valid'); + } + }); + }); } diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index d8ae8a61..c1887943 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -348,7 +348,7 @@ void main() { test('INIT no longer re-sends the hello — it belongs to connect setup', () async { - // The official order is hello FIRST, during setup, so its timestamp can + // The pinned order is hello FIRST, during setup, so its timestamp can // drive the clock decision and its identity fields are available to // everything after. Sending it again at INIT would be a second identity // exchange after every consumer has already run. @@ -363,9 +363,9 @@ void main() { expect(opcodes, contains(Cmd.sendHistoricalData)); }); - test('the wake window uses the official 180 s / 7200 s Smart Alarm values', + test('the wake window uses the pinned 180 s / 7200 s Smart Alarm values', () async { - // doc 14: ENTER_HIGH_FREQ_SYNC(96) body `02 b4 00 20 1c` — rev 2, then + // ENTER_HIGH_FREQ_SYNC(96) body `02 b4 00 20 1c` — rev 2, then // interval 180 s and duration 7200 s as u16 LE. The old 61 s/90 min // defaults were picked only to clear gen5's "> 60" floor. final w = _Wire(band: BandProfile.gen5); @@ -381,30 +381,18 @@ void main() { ]); }); - test('runStoredAlarm sends the official rev-2 body with the alarm id', + test('gen5 reads the clock with the established GET_CLOCK(11), empty body', () async { - // doc 14 "Run alarm now — opcode 68": body `02 01`. This is the early-wake - // mechanism; the rev-1 gen4 body does nothing on gen5. - final w = _Wire(band: BandProfile.gen5); - await w.engine.runStoredAlarm(); - expect(w.lastCommandOf(3), - [Cmd.runAlarm, 0x02, AlarmPayloads.gen5Slot]); - expect(AlarmPayloads.gen5Slot, 1); - }); - - test('gen5 reads the clock with the OFFICIAL GET_CLOCK(11), empty body', - () async { - // Opcode 147 ("GET_CLOCK_GEN5") appears nowhere in the official 75-opcode - // enum. The confirmed gen5 contract is the shared opcode 11 with an EMPTY - // body — physically exercised on a real WHOOP 5 (the probe read the clock - // this way and measured ~2410 ms drift before setting it). + // Opcode 147 ("GET_CLOCK_GEN5") is not an established WHOOP opcode. + // The confirmed gen5 contract is the shared opcode 11 with an EMPTY + // body — hardware-confirmed on a real WHOOP 5. final w = _Wire(band: BandProfile.gen5); await w.engine.getClock(); expect(w.lastCommandOf(1), [Cmd.getClock]); expect(Cmd.getClock, 11); }); - test('gen5 sets the clock with the OFFICIAL SET_CLOCK(10), 8-byte body', + test('gen5 sets the clock with the established SET_CLOCK(10), 8-byte body', () async { // , no revision byte — the form that // returned SUCCESS from a real WHOOP 5. A wrong clock write is silent: @@ -510,7 +498,7 @@ void main() { /// A type-48 EVENT inner: /// `[0x30][u8 seq][u16 id][u32 unix][u16 subsec][u16 body len][body…]` -/// (doc 04 §"Type 48 — events"). Built directly rather than through +///. Built directly rather than through /// `buildFrame` because the engine's receive path consumes inners. Uint8List _eventInner(int id, List body, {int ts = 1786000000}) { final inner = Uint8List(12 + body.length); @@ -534,11 +522,16 @@ void _events() { onState: (_) {}, log: logs.add, ); + // These event bodies are gen5-scoped in the protocol decoder; a gen4 + // link keeps them numeric and un-decoded. + engine.debugInstallFakeLink( + onWrite: (_) async => true, + band: BandProfile.gen5, + ); return (engine: engine, logs: logs); } - test('STRAP_CONDITION_REPORT(29) lands in the offload snapshot and the log', - () { + test('STRAP_CONDITION_REPORT(29) is logged, and only logged', () { final r = rig(); // pages behind 4321, backlog 45.6, SoC 87.2%, flash 3, charging, wrist 2. r.engine.debugProcessImmediateFrame(Frame( @@ -554,16 +547,12 @@ void _events() { true, )); - final snap = r.engine.offloadSnapshot; - expect(snap['condition_pages_behind'], 4321, - reason: 'a modular PAGE span (doc 05), not a packet count'); - expect(snap['condition_backlog'], closeTo(45.6, 1e-9)); - expect(snap['condition_soc_pct'], closeTo(87.2, 1e-9)); - expect(snap['condition_charging'], isTrue); - expect(snap['condition_wrist_state'], 2); - expect(snap['condition_ts'], 1786000123); - expect(r.logs.where((l) => l.contains('[SYNC] strap condition')), - isNotEmpty); + final line = r.logs + .where((l) => l.contains('[SYNC] strap condition report')) + .single; + expect(line, contains('pages_behind=4321')); + expect(line, contains('soc=87.2')); + expect(line, contains('charging=true')); }); test('a condition report is observability only — it starts no offload', () { @@ -578,8 +567,6 @@ void _events() { // trigger. The backfill policy stays the only thing that starts one. expect(r.engine.offloadActive, isFalse); expect(r.engine.offloadSnapshot['history_requests'], 0); - // The raw tri-state byte must not be laundered into wear state. - expect(r.engine.offloadSnapshot['condition_wrist_state'], 0); }); test('HAPTICS_TERMINATED(100) code 2 records the wearer double-tap', () { @@ -616,18 +603,18 @@ void _events() { // ── T11: the doc-01 bootstrap sequence ────────────────────────────────────── // -// doc 01 §"Phase sequence" specifies the exact order — and the exact silences — +// specifies the exact order — and the exact silences — // between the bond and READY. Four of its steps were missing here: // - the two observed client delays (600 ms before notification registration, // 500 ms after the last CCC write); // - the ≥2 s clock gate: this app wrote SET_CLOCK on EVERY connect, where the -// official client makes no BLE write at all below two whole seconds of +// pinned bootstrap makes no BLE write at all below two whole seconds of // drift; // - GET_ADVERTISING_NAME(141) as the final pre-READY command (sent, never a // readiness gate); // - the charging follow-up, GET_BATTERY_PACK_INFO(151) ×5, 5 s apart, which // must never touch READY and must never run off the charger. -// All four are gen5-only: doc 01 describes the WHOOP 5 bootstrap, and gen4's +// All four are gen5-only: the pinned bootstrap is WHOOP 5's, and gen4's // flow is hardware-proven, so these tests also pin gen4's *absence* of them. /// A gen4/gen5 link with no radio behind it that records every command written @@ -640,7 +627,7 @@ class _BootstrapLink { /// Answers to inject as the reply to a written command. Injected from INSIDE /// the write, i.e. before `_sendAwaited` has even returned — the ordering - /// doc 02 demands. + /// the correlation contract demands. Decoded? Function(int seq, int opcode)? replyTo; late final BleEngine engine; @@ -681,7 +668,7 @@ class _BootstrapLink { } } -/// A revision-1 gen5 hello body (doc 01 §"Revision-1 hello body"), parsed by +/// A revision-1 gen5 hello body, parsed by /// the real protocol decoder so the timestamp and charge bit under test are the /// ones a band would actually produce. Uint8List _gen5HelloBody({required int tsSeconds, bool charging = false}) { @@ -741,7 +728,7 @@ bool _runBootstrap(_BootstrapLink link, FakeAsync async) { } void _bootstrap() { - group('T11 — the two delays (doc 01)', () { + group('T11 — the two delays', () { test('gen5 writes nothing for 500 ms after the last CCC write', () { fakeAsync((async) { final link = _BootstrapLink(); @@ -752,7 +739,7 @@ void _bootstrap() { async.elapse(const Duration(milliseconds: 499)); expect(link.commands, isEmpty, - reason: 'doc 01: 500 ms after registration, before the ' + reason: '500 ms after registration, before the ' 'higher-level state machine runs'); async.elapse(const Duration(milliseconds: 2)); expect(link.opcodes.first, Cmd.getHello, @@ -793,7 +780,7 @@ void _bootstrap() { }); }); - group('T11 — the ≥2 s SET_CLOCK gate (doc 01 "Clock contract")', () { + group('T11 — the ≥2 s SET_CLOCK gate', () { test('BootstrapClockGate: below two whole seconds, no correction', () { expect(BootstrapClockGate.toleranceSeconds, 2); expect(BootstrapClockGate.needsCorrection(0), isFalse); @@ -817,7 +804,7 @@ void _bootstrap() { expect(_runBootstrap(link, async), isTrue); expect(link.count(Cmd.setClock), 0, - reason: 'doc 01: below 2 s, succeed with NO BLE write'); + reason: 'below 2 s, succeed with NO BLE write'); expect(link.count(Cmd.getClock), 0, reason: 'and no read-back either — nothing was written'); expect(link.logged('no correction needed'), isTrue); @@ -833,7 +820,7 @@ void _bootstrap() { expect(_runBootstrap(link, async), isTrue); expect(link.count(Cmd.setClock), 1, - reason: 'doc 01: at 2 or more, send ONE SET_CLOCK'); + reason: 'at 2 or more, send ONE SET_CLOCK'); expect(link.opcodes.indexOf(Cmd.setClock), greaterThan(link.opcodes.indexOf(Cmd.getHello)), reason: 'the clock decision comes after hello supplies the time'); @@ -847,7 +834,7 @@ void _bootstrap() { // Before the bootstrap-window fix, BOTH writers fired — the absorb // handler's own re-correction on the hello reply AND the bootstrap // clock step — sending a fresh band two SET_CLOCKs back to back, - // against doc 01's "send one SET_CLOCK(10)". + // against the one-SET_CLOCK-per-bootstrap rule. final link = _BootstrapLink(); link.replyTo = (seq, op) => op == Cmd.getHello ? _helloReply(seq, tsSeconds: 1000) @@ -855,7 +842,7 @@ void _bootstrap() { expect(_runBootstrap(link, async), isTrue); expect(link.count(Cmd.setClock), 1, - reason: 'doc 01: ONE SET_CLOCK per bootstrap — the absorb ' + reason: 'ONE SET_CLOCK per bootstrap — the absorb ' 'handler must stand down inside the bootstrap window'); }); }); @@ -889,7 +876,7 @@ void _bootstrap() { }); }); - group('T11 — GET_ADVERTISING_NAME is the final pre-READY step (doc 01)', () { + group('T11 — GET_ADVERTISING_NAME is the final pre-READY step', () { test('gen5 sends it last, after the clock step', () { fakeAsync((async) { final link = _BootstrapLink(); @@ -900,7 +887,7 @@ void _bootstrap() { expect(link.opcodes.last, Cmd.getCustomAdvertisingName); expect(link.commands.last.body.first, revision1, - reason: 'doc 01: body 01'); + reason: 'body 01'); }); }); @@ -912,7 +899,7 @@ void _bootstrap() { : null; // Nothing ever answers opcode 141 here. expect(_runBootstrap(link, async), isTrue, - reason: 'doc 01: the response content and result are NOT a ' + reason: 'the response content and result are NOT a ' 'readiness gate'); async.elapse(const Duration(seconds: 6)); expect(link.logged('GET_ADVERTISING_NAME went unanswered'), isTrue); @@ -931,7 +918,7 @@ void _bootstrap() { }); }); - group('T11 — the charging follow-up, opcode 151 (doc 01)', () { + group('T11 — the charging follow-up, opcode 151', () { /// Bootstrap a gen5 link whose hello reports [charging], answering /// GET_BATTERY_PACK_INFO with [packAddress] when one is given. _BootstrapLink chargingRig( @@ -968,6 +955,12 @@ void _bootstrap() { identifier: 'aa:bb:cc:dd:ee:ff', name: ''), isTrue); expect(BatteryPackInfoGate.usable(identifier: '', name: 'Puffin'), isTrue); + expect( + BatteryPackInfoGate.usable( + identifier: '', name: '00:00:00:00:00:00'), + isFalse, + reason: 'the sentinel leaking through the NAME field is still ' + '"no pack yet"'); }); test('it never runs when the band is not charging', () { @@ -975,7 +968,7 @@ void _bootstrap() { final link = chargingRig(async, charging: false); async.elapse(const Duration(seconds: 40)); expect(link.count(Cmd.getBatteryPackInfo), 0, - reason: 'doc 01: this lookup does not run on a non-charging ' + reason: 'this lookup does not run on a non-charging ' 'READY transition'); }); }); @@ -986,13 +979,13 @@ void _bootstrap() { chargingRig(async, charging: true, packAddress: '00:00:00:00:00:00'); expect(link.count(Cmd.getBatteryPackInfo), 1); expect(link.commands.last.body.first, revision1, - reason: 'doc 01/03: body 01'); + reason: 'body 01'); for (var expected = 2; expected <= 5; expected++) { async.elapse(const Duration(seconds: 5)); expect(link.count(Cmd.getBatteryPackInfo), expected); } - // doc 01: the fifth unusable attempt is followed by the delay too. + // the fifth unusable attempt is followed by the delay too. async.elapse(const Duration(seconds: 5)); expect(link.count(Cmd.getBatteryPackInfo), BleEngine.kBatteryPackInfoAttempts, reason: 'five attempts, and no sixth'); @@ -1052,7 +1045,7 @@ void _bootstrap() { // ── T14: burst count membership must follow the band's arrival order ───────── // -// doc 05 §"Count membership": every complete type-47/48/50/53/54/55 frame the +// every complete type-47/48/50/53/54/55 frame the // band sends between HISTORY_START and HISTORY_END counts exactly once toward // `HISTORY_END.expected_count` (= its own data_pkt_cnt + event_pkt_cnt). // @@ -1065,7 +1058,7 @@ void _bootstrap() { // the PREVIOUS burst open (where the next HISTORY_START's rearm wipes it), so // its own burst came up short by exactly those frames, every single retry. // -// Field capture 2026-08-19 (WHOOP 5, fw 50.40.1.0): earlier bursts carried a +// On a real WHOOP 5, earlier bursts carried a // growing console surplus (console=5, 7, 9 …) while the burst behind them went // `expected=52, actual=48, breakdown={V18=42, events=1, console=5}` and then, // after the strap's adaptive burst-size drop, `expected=16, actual=12, @@ -1117,6 +1110,9 @@ Uint8List _consoleInner(int index, {int ts = 1786000000}) { Uint8List _historyStart() => Uint8List.fromList([PacketType.metadata, 0x01, SyncMeta.historyStart]); +Uint8List _historyComplete() => Uint8List.fromList( + [PacketType.metadata, 0x03, SyncMeta.historyComplete]); + /// A type-49 METADATA HISTORY_END inner: `expected_count` u32 @9 and the /// 8-byte trim token @13:21 the result echoes verbatim. Uint8List _historyEnd({required int expected, required int token}) { @@ -1269,14 +1265,14 @@ void _burstOrdering() { test( 'a type-47 frame we cannot decode still counts — deep buffers and ' - 'unknown revisions are burst members (doc 05)', () async { + 'unknown revisions are burst members', () async { final b = _Burst(); b.rx(_historyStart()); b.rx(_gen5V18Inner(ts: ts, counter: 4000)); // A gen5 deep buffer (v22 research telemetry: identified, archived, not // a 1 Hz sample) and a future firmware's unknown revision. The band // counted both when it wrote expected=3 — "unknown revisions still - // count" is doc 05's rule 4, and an R22-enabled strap puts one of these + // count" is the membership rule, and an R22-enabled strap puts one of these // in most bursts, so leaving them uncounted starves the gate exactly // like the mis-binned event frames did. b.rx(_rawHistInner(rev: 22, counter: 4001)); @@ -1293,26 +1289,28 @@ void _burstOrdering() { group('T14 — the 15th failed validation is terminal for the session', () { final ts = _wallNow() - 3600; - /// Deliver one burst the band says is longer than it is. - Future shortBurst(_Burst b, int token) async { + /// One short burst, then marker-only re-offers of its END — the band + /// re-offers the terminal roughly every 2.5 s WITHOUT resending frames, + /// so the attempt count accumulates on one HISTORY_START. + Future stuckAfterFifteen(_Burst b) async { b.rx(_historyStart()); - b.rx(_gen5V18Inner(ts: ts, counter: 4000 + token)); - b.rx(_historyEnd(expected: 5, token: token)); - await pumpEventQueue(); + b.rx(_gen5V18Inner(ts: ts, counter: 4001)); + for (var i = 1; i <= kBurstValidationAttemptLimit; i++) { + b.rx(_historyEnd(expected: 5, token: 0x8600)); + await pumpEventQueue(); + } } test('15 failures abort ONCE, then the re-offered burst is dropped without ' 'validating or aborting again', () async { final b = _Burst(); - for (var i = 1; i <= kBurstValidationAttemptLimit; i++) { - await shortBurst(b, 0x8600 + i); - } + await stuckAfterFifteen(b); expect(b.shortLines, hasLength(kBurstValidationAttemptLimit)); expect( b.opcodes.where((o) => o == Cmd.abortHistoricalTransmits).length, 1, - reason: 'ONE abort at the boundary — doc 05 §"Retry boundary"', + reason: 'ONE abort at the boundary — ', ); // Attempts 1..14 send a failure result; the 15th deliberately does not. expect( @@ -1323,11 +1321,12 @@ void _burstOrdering() { // The band does not know the session is over and re-offers the burst // roughly every 2.5 s. Each re-offer used to re-enter validation — which - // was already past the limit — and abort again: 14+ aborts in 12 s in the - // field capture. + // was already past the limit — and abort again: 14+ aborts in 12 s on a + // real strap. final before = b.opcodes.length; for (var i = 0; i < 4; i++) { - await shortBurst(b, 0x8700 + i); + b.rx(_historyEnd(expected: 5, token: 0x8600)); + await pumpEventQueue(); } expect(b.shortLines, hasLength(kBurstValidationAttemptLimit), reason: 'no further validation at all'); @@ -1343,13 +1342,11 @@ void _burstOrdering() { test('a same-session drain trigger is refused; a new session drains', () async { final b = _Burst(); - for (var i = 1; i <= kBurstValidationAttemptLimit; i++) { - await shortBurst(b, 0x8600 + i); - } + await stuckAfterFifteen(b); expect(await b.engine.debugStartHistoricalRefresh(), isFalse, reason: 'continuation belongs to a later connection, not to a ' - 'retry on this one (doc 05 §"Retry boundary")'); + 'retry on this one'); expect(b.engine.offloadSnapshot['stuck_refreshes_refused'], 1); // A reconnect is the remedy: the latch is session-scoped, so the next @@ -1365,5 +1362,133 @@ void _burstOrdering() { reason: 'the fresh session validated its burst normally'); expect(b.acceptedCounts.last, 1); }); + + test('HISTORY_COMPLETE still completes the drain after Stuck', () async { + final b = _Burst(); + await stuckAfterFifteen(b); + expect(b.engine.historyStuckThisSession, isTrue); + + b.rx(_historyComplete()); + await pumpEventQueue(); + expect( + b.logs.any((l) => l.contains('HistoryComplete — backlog drained')), + isTrue, + reason: 'COMPLETE ACKs nothing and must not be swallowed by the ' + 'latch, or every awaitComplete waiter runs out its timeout', + ); + }); + }); + + group('#260 review — burst boundaries the gate must respect', () { + final ts = _wallNow() - 3600; + + test('a new HISTORY_START starts a fresh validation cycle', () async { + final b = _Burst(); + // Burst A fails three times (slack stays 0 through attempt 3). + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 4100)); + for (var i = 0; i < 3; i++) { + b.rx(_historyEnd(expected: 5, token: 0x8620)); + await pumpEventQueue(); + } + expect(b.shortLines, hasLength(3)); + + // Burst B delivers 1 frame against expected 3. With burst A's three + // failures inherited, attempt 4's slack of 2 would ACCEPT 1/3 and let + // the band trim two frames never tallied. A fresh burst's first + // attempt demands every frame. + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts + 1, counter: 4101)); + b.rx(_historyEnd(expected: 3, token: 0x8621)); + await pumpEventQueue(); + expect(b.shortLines, hasLength(4), + reason: 'burst B is judged at attempt one, slack zero'); + expect(b.acceptedCounts, isEmpty); + }); + + test('chatter after HISTORY_END cannot push a short burst over the line', + () async { + final b = _Burst(); + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: ts, counter: 4200)); + b.rx(_historyEnd(expected: 3, token: 0x8630)); + await pumpEventQueue(); + expect(b.shortLines, hasLength(1)); // 1 of 3 — refused + + // Two console lines land during the re-offer window — numerically + // exactly the two frames the tally is missing, but they are NOT part + // of the window the band counted. + b.rx(_consoleInner(1), role: 'events'); + b.rx(_consoleInner(2), role: 'events'); + b.rx(_historyEnd(expected: 3, token: 0x8630)); + await pumpEventQueue(); + expect(b.shortLines, hasLength(2), + reason: 're-validation judges the tally frozen at the terminal'); + expect(b.acceptedCounts, isEmpty); + }); + + test('console chatter does not keep a stalled offload alive', () { + fakeAsync((async) { + final b = _Burst(); + b.rx(_historyStart()); + b.rx(_gen5V18Inner(ts: _wallNow() - 3600, counter: 4300)); + async.flushMicrotasks(); + + // 55 s of chatter-only traffic. Each console line is a count member + // riding the offload queue, and each used to re-arm the idle + // watchdog — so a strap that stalled mid-burst but kept logging + // never hit the timeout. + for (var i = 0; i < 5; i++) { + async.elapse(const Duration(seconds: 11)); + b.rx(_consoleInner(10 + i), role: 'events'); + async.flushMicrotasks(); + } + expect(b.opcodes, isNot(contains(Cmd.abortHistoricalTransmits))); + + async.elapse(const Duration(seconds: 10)); + async.flushMicrotasks(); + expect(b.opcodes, contains(Cmd.abortHistoricalTransmits), + reason: 'the fuse measures real drain progress, not chatter'); + }); + }); + + test('gen4 keeps the advisory-only count behaviour', () async { + final logs = []; + final frames = []; + final engine = BleEngine( + onRecord: (_, _) async {}, + onState: (_) {}, + log: logs.add, + ); + engine.debugInstallFakeLink( + onWrite: (f) async { + frames.add(f); + return true; + }, + band: BandProfile.gen4, + ); + engine.debugReceiveFrame(Frame(_historyStart(), true, true), + role: 'data'); + engine.debugReceiveFrame( + Frame(_gen4Inner(version: 24, ts: _wallNow() - 3600, counter: 9000), + true, true), + role: 'data', + ); + engine.debugReceiveFrame( + Frame(_historyEnd(expected: 5, token: 0x9900), true, true), + role: 'data'); + await pumpEventQueue(); + + expect(logs.any((l) => l.contains('ADVISORY, gen4')), isTrue, + reason: 'the mismatch is still visible'); + expect(logs.any((l) => l.contains('Burst packet-count SHORT')), isFalse, + reason: 'but never refused — gen4 count semantics are unpinned'); + final ops = frames + .map((f) => parseFrame(f)) + .where((p) => p != null && p.valid) + .map((p) => p!.inner[2]); + expect(ops, isNot(contains(Cmd.abortHistoricalTransmits))); + expect(engine.historyStuckThisSession, isFalse); + }); }); } diff --git a/test/v25_refusal_test.dart b/test/v25_refusal_test.dart index 19ac30e9..fa11614b 100644 --- a/test/v25_refusal_test.dart +++ b/test/v25_refusal_test.dart @@ -44,18 +44,18 @@ void main() { await LocalDb.close(); }); - test('protocol still hands us the vector — this is the thing we refuse', () { - // Not a change request against protocol (SEALED): asserted so that if the - // decoder ever DOES change, this test tells whoever changed it that edge - // is deliberately dropping the record. + test('protocol refuses the vector upstream now — pin the handoff', () { + // protocol stopped emitting the v25 "gravity" itself (accelG comes back + // EMPTY, not (0,0,0)) — the same refusal this file pins on edge's own + // seams. Asserted so that if the decoder ever hands values back again, + // this test tells whoever changed it that edge deliberately drops the + // record either way. final r = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25a)); expect(r, isNotNull); expect(r!.histVersion, 25); expect(r.hr, 0, reason: 'v25 carries no heart rate'); - // The tell: the same "y" value on both records, and a "z" of zero. - final s = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25b))!; - expect(r.accelG[1], s.accelG[1], reason: 'a wrist axis that never moves'); - expect(r.accelG[2], 0.0); + expect(r.accelG, isEmpty, + reason: 'no fabricated stillness from upstream either'); }); test('decodeSubstrate drops v25 rather than banking a still wrist', () { From 2e43bb116856214b88c82dc45e459904ef79456e Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 18:37:48 +0200 Subject: [PATCH 07/11] bound terminal Stuck to a window instead of the whole connection Review point: three CRC-corrupt frames on a marginal link can walk a burst to the 15-attempt boundary, and the latch then refused every further drain for the connection. Records already banked survive, so it is a stall rather than immediate loss -- but on a full band a stall becomes loss. The refusal itself stays. Refusing a short burst rather than ACKing it is the data-safe direction: every re-delivery is another chance at the corrupt frame, and an ACK deletes it. The latch also earns its keep -- without it the band's ~2.5 s re-offer storm re-enters validation and re-aborts (14+ times in 12 s on a real strap). What could not be defended is the scope. Continuation after `Stuck` comes from a later connection, a scheduler tick or an explicit trigger; a session-scoped latch refuses the last two outright, so it was stricter than the behaviour it models. So the latch is now windowed: `kHistoryStuckCooldown` = 2 min, comfortably outlasting both the re-offer storm and the 60 s idle watchdog. Inside the window nothing changes -- triggers refused, markers dropped, first occurrence of each logged and then silent. After it, a new trigger gets a fresh validation cycle. The band still holds its checkpoint, so nothing already committed is re-fetched. Every decision site reads one windowed getter, including `historyStuckThisSession`, which the backfill loop uses to mirror the engine's refusal -- left raw it would have kept breaking the loop after the engine had resumed accepting triggers. The raw latch stays in diagnostics as `history_stuck`. Co-Authored-By: Claude Opus 5 --- lib/ble/ble_engine.dart | 49 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index bdf86d8e..019a5c60 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -283,6 +283,24 @@ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => /// infinite re-request loop. const int kBurstValidationAttemptLimit = 15; +/// How long a terminal `Stuck` keeps refusing drain work within one connection. +/// +/// The latch exists to survive the band's re-offer storm: after an abort it +/// keeps re-offering the same HISTORY_END about every 2.5 s, and every re-offer +/// used to re-enter validation and abort again (14+ times in 12 s on a real +/// strap). This window has to outlast that storm AND the 60 s idle watchdog +/// that ends the offload. +/// +/// It is deliberately NOT "the rest of the connection". Continuation after +/// `Stuck` comes from a later connection, a scheduler tick or an explicit +/// trigger; a session-scoped latch refuses the last two outright, which is +/// stricter than the behaviour it models. Three CRC-corrupt frames on a +/// marginal link must not cost every later drain on a connection that may stay +/// up for hours. Once the window passes a genuinely new trigger gets a fresh +/// validation cycle; the band still holds its checkpoint, so nothing already +/// committed is re-fetched. +const Duration kHistoryStuckCooldown = Duration(minutes: 2); + @visibleForTesting int burstCountSlack(int consecutiveFailedValidations) => consecutiveFailedValidations >= 3 ? 2 : 0; @@ -495,6 +513,21 @@ class _Session { /// and the next connection drains normally from the band's checkpoint. bool historyStuck = false; + /// When [historyStuck] latched. Drives [historyStuckActive]. + DateTime? historyStuckAt; + + /// Whether the latch is still refusing work. + /// + /// Read this, never [historyStuck] directly, on any path that decides whether + /// to refuse a drain, drop a marker or suppress a terminal. [historyStuck] + /// stays true as a session diagnostic ("this connection hit Stuck at least + /// once") after the window has passed. + bool get historyStuckActive { + final at = historyStuckAt; + if (!historyStuck || at == null) return false; + return DateTime.now().difference(at) < kHistoryStuckCooldown; + } + /// Markers dropped, and drain triggers refused, by [historyStuck] /// (diagnostics). Each kind logs its FIRST occurrence and then stays silent: /// the band re-offers roughly every 2.5 s, and the whole point of the latch @@ -1479,7 +1512,12 @@ class BleEngine { /// [kBurstValidationAttemptLimit] times and the abort went out. Nothing may /// start another drain on this link; continuation belongs to a later /// connection. Callers that loop over sync sessions must stop on it. - bool get historyStuckThisSession => _session?.historyStuck ?? false; + /// Whether a terminal `Stuck` is currently refusing drain work. + /// + /// Windowed, not the raw latch -- callers use this to mirror the engine's own + /// refusal, so it has to go false when the engine starts accepting triggers + /// again. The raw latch stays visible in diagnostics as `history_stuck`. + bool get historyStuckThisSession => _session?.historyStuckActive ?? false; Map get offloadSnapshot => { 'active': _offloadActive, @@ -2516,7 +2554,7 @@ class BleEngine { // continuation loop — so refusing here closes all of them at once. The // FIRST drain of a fresh session is untouched: the latch lives on the // session object, so a reconnect clears it. - if (session!.historyStuck) { + if (session!.historyStuckActive) { session.stuckRefreshesRefused++; if (session.stuckRefreshesRefused == 1) { _log( @@ -4043,6 +4081,7 @@ class BleEngine { // the whole 15-failure cycle to the backfill continuation. Terminal has // to mean terminal for the session. session.historyStuck = true; + session.historyStuckAt = DateTime.now(); _log( '[SYNC] burst still short after ' '${d.consecutiveValidationFailures} attempts — aborting history for ' @@ -4264,7 +4303,7 @@ class BleEngine { // nothing, and swallowing it left `onComplete()` unreachable once the // latch was set, so every `awaitComplete()` waiter ran out its full // timeout against a drain that had already ended. - if (session.historyStuck && m.sub != SyncMeta.historyComplete) { + if (session.historyStuckActive && m.sub != SyncMeta.historyComplete) { session.stuckMarkersDropped++; if (session.stuckMarkersDropped == 1) { _log( @@ -4278,7 +4317,7 @@ class BleEngine { } // Stuck: the COMPLETE passing through above must not re-arm the watchdog // it deliberately left dead. - if (!session.historyStuck) _armIdleWatchdog(); + if (!session.historyStuckActive) _armIdleWatchdog(); _log( '[SYNC] META sub=${m.sub} inner=' '${frame.inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', @@ -4696,7 +4735,7 @@ class BleEngine { if (d == null) return; // After a Stuck abort the offload flag is already down by design — that // is not an out-of-band COMPLETE, so don't record it as one. - if (!_offloadActive && !session.historyStuck) { + if (!_offloadActive && !session.historyStuckActive) { _setHpsTerminal( _HpsTerminalKind.metadataWhileNotSyncing, reason: 'history_complete_while_not_syncing', From 8aa0f640fb8fe7b3c918ceab187736fbdfeb8f43 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 18:37:48 +0200 Subject: [PATCH 08/11] repin protocol to main @ b7990e1, the #31 merge commit Last commit in the stated order: #31 lands, edge repins, then this branch is reviewable. b7990e1 is protocol main's merge commit rather than the PR-branch head, per this file's own rule -- a deleted branch can orphan a PR-branch SHA. The lock is regenerated with no local override in it: git source, url and resolved-ref, not `path: "../protocol"`. Co-Authored-By: Claude Opus 5 --- pubspec.lock | 4 ++-- pubspec.yaml | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 33204675..481cb324 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -933,8 +933,8 @@ packages: dependency: "direct main" description: path: "." - ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 - resolved-ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 + ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 + resolved-ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 60cf34aa..e111dded 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,7 +74,12 @@ dependencies: # Repinned to the #27 head after its own review pass. NO kAlgoVersion # bump: the fixes only reject NaN/±inf, which was never a measurement, so # for any user whose data is valid the output is byte-identical. - ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 + # + # REPIN (this branch): protocol main @ b7990e1, the #31 merge commit. + # #31 carries the gen5 hello map, the real clock opcodes and the v18 + # record field map this branch's decoders need. main's pre-gen5 pin is + # deliberate THERE; this is the branch that wants gen5. + ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 40fcb30544a9204748567c8980b1e083639b90fd Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 18:39:50 +0200 Subject: [PATCH 09/11] point kProtocolPin at the repin; kAlgoVersion holds at 76 The sibling-pin guard caught the repin, which is what it is for: a day_result stamps kAlgoVersion and nothing about which siblings produced it, so a repin without visiting this block lets two builds serve each other's days as equivalent. Holding at 76 rather than bumping, and this one is checkable rather than argued: diff c761f29..b7990e1 and the gen4 record decoder (`lib/src/records.dart`) is untouched, as is every gen4 line in the package export. What moved is the gen5 surface -- hello map, control plane, command surface, v18/v20/v22/v26 field maps -- plus tests. So for anyone on a gen4 strap every number out of the package is byte-identical across this repin, and a bump would invalidate every stored day to recompute the same answers. The gen5 records it adds are new: no released build could decode them, so no stored day at v76 came from one. Co-Authored-By: Claude Opus 5 --- lib/compute/derivation_engine.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index e193ff37..ebf9872d 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1317,8 +1317,19 @@ const int kAlgoVersion = 76; // which re-orders a float summation — that is computed before the sort now, and // the real overnight capture staged identically down to the last digit of // confidence. +// +// The protocol repin to b7990e1 also holds at 76, and this one is checkable +// rather than argued: diff the two pins and the gen4 record decoder +// (`lib/src/records.dart`) is untouched, as is every gen4 line in the package +// export. What moved is the gen5 surface — the hello map, the control plane, +// the command surface and the v18/v20/v22/v26 field maps — plus their tests. +// For anyone on a gen4 strap every number out of this package is byte-identical +// across the repin, so a bump would invalidate every stored day to recompute +// the same answers. The gen5 records it adds are new: no released build could +// decode them, so no stored day at v76 was derived from one, and there is +// nothing for a same-version serve to confuse. const String kAnalyticsPin = 'd9362a66fbeac326d5d7d7b1fe27b28e41169a79'; -const String kProtocolPin = 'c761f29bcbed73886b1b059dcd9e92e4333574f5'; +const String kProtocolPin = 'b7990e1499f9ae83dbd4c1fa8481dbe8413e7337'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see From c67d3534c1e6aa8aaf60dfe5529659539f5e8d5e Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 19:35:15 +0200 Subject: [PATCH 10/11] arm the command expiry at registration; repair three doc sentences Two review findings. **`register` could leak, and the leak was not just memory.** The expiry armed only when a caller read `PendingCommand.response`. A command registered whose write path returned without awaiting and without `cancel()` stayed in `_pending` for the life of the connection, and `deliver` then refused every later sequence-zero fallback for that opcode -- the stale entry made `sameOpcode` ambiguous, so valid replies came back unmatched and their callers timed out. The lazy arm is replaced with an explicit one-shot `Timer` started in `register`. `response` still arms idempotently for callers that reach it first, and `cancel`/`_complete` cancel the timer, so the timeout is still applied exactly once with no automatic resend. Arming at registration starts the clock fractionally before the write returns: a few ms out of a multi-second window, in exchange for the invariant that nothing outlives its timeout. **Three doc comments lost their subject in the provenance scrub** and read as incomplete sentences: "The identity half of as an OBSERVATION.", "The bootstrap clock gate from ." and an orphaned ".". Rewritten as complete neutral descriptions that name what the gate or observation represents, without naming where it came from. Swept every changed file for the same pattern; these were the only three. Co-Authored-By: Claude Opus 5 --- lib/ble/ble_state.dart | 48 +++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 2caba10f..a16f5ccd 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1324,30 +1324,42 @@ class PendingCommand { PendingCommand._(this._owner, this.seq, this.opcode, this.timeout); - /// The correlated reply, or null once [timeout] expires. + Timer? _expiry; + + /// Start the one-shot expiry. Idempotent: the timeout is applied EXACTLY + /// ONCE and there is no automatic resend — retry, disconnect and abort + /// belong to the calling state machine. /// - /// The timeout is applied EXACTLY ONCE and there is no automatic resend - /// — retry, disconnect and abort belong to - /// the calling state machine. Lazily built, so registering a command that is - /// never awaited never arms a timer. - late final Future response = _completer.future.timeout( - timeout, - onTimeout: () { + /// Called from [CommandAwaiter.register] rather than lazily from [response], + /// so a command that is registered and then never awaited still leaves the + /// registry after [timeout]. Arming here starts the clock fractionally + /// before the write returns, which costs a few ms of a multi-second window + /// and buys the invariant that nothing can outlive its timeout. + void _armExpiry() { + _expiry ??= Timer(timeout, () { _owner._forget(this); - return null; - }, - ); + if (!_completer.isCompleted) _completer.complete(null); + }); + } + + /// The correlated reply, or null once [timeout] expires. + Future get response { + _armExpiry(); + return _completer.future; + } bool get isCompleted => _completer.isCompleted; /// Give up without waiting out the timeout — the write never went out, or /// the link died under it. void cancel() { + _expiry?.cancel(); _owner._forget(this); if (!_completer.isCompleted) _completer.complete(null); } void _complete(CorrelatedResponse r) { + _expiry?.cancel(); _owner._forget(this); if (!_completer.isCompleted) _completer.complete(r); } @@ -1416,6 +1428,12 @@ class CommandAwaiter { }) { final p = PendingCommand._(this, seq, opcode, timeout); _pending.add(p); + // Arm now, not on first await. An entry that is registered and never + // awaited would otherwise sit in `_pending` for the life of the + // connection, and `deliver` would refuse every later sequence-zero + // fallback for that opcode because the stale entry makes the match + // ambiguous. + p._armExpiry(); return p; } @@ -1471,7 +1489,8 @@ class CommandAwaiter { void _forget(PendingCommand p) => _pending.remove(p); } -/// The identity half of as an OBSERVATION. +/// The identity half of the bootstrap readiness check, kept as an +/// OBSERVATION rather than a gate. /// /// A strict readiness gate requires the serial and CPU strings to match /// `[a-zA-Z0-9]+` before it calls a connection ready. This app records the @@ -1514,7 +1533,7 @@ class HelloIdentity { '${eepromFailureSignal ? ' serial=all-zero(EEPROM)' : ''}'; } -/// The bootstrap clock gate from . +/// The bootstrap clock gate. /// /// The pinned flow compares the timestamp hello already returned (or, as a /// fallback, a `GET_CLOCK` reply) against host time and writes NOTHING below @@ -1546,8 +1565,7 @@ class BootstrapClockGate { driftSec == null || driftSec.abs() >= toleranceSeconds; } -/// Whether a `GET_BATTERY_PACK_INFO(151)` reply actually identifies a pack -///. +/// Whether a `GET_BATTERY_PACK_INFO(151)` reply actually identifies a pack. /// /// "A response is usable only if its pack address/name field is non-empty and /// is not `00:00:00:00:00:00`" — the band answers the command while it is still From 828c29226e8c0c2f2d0f13cca853362e3056f85b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:02:36 +0530 Subject: [PATCH 11/11] initialize the relay toggle capture plain `flutter analyze` treats prefer_typing_uninitialized_variables as fatal, so the single pre-existing info in band_notifications_test turned the whole test job red on an unrelated PR. ValueChanged makes the type bool; false matches the expect(toggled, isTrue). --- test/band_notifications_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/band_notifications_test.dart b/test/band_notifications_test.dart index 0eedb069..63eacdff 100644 --- a/test/band_notifications_test.dart +++ b/test/band_notifications_test.dart @@ -29,7 +29,10 @@ Future _pump(WidgetTester t, Widget w, {double scale = 1}) async { void main() { group('the relay screen', () { testWidgets('off is one tap from on, and says what it will do', (t) async { - var toggled; + // Typed + initialized: `var toggled;` tripped + // prefer_typing_uninitialized_variables, which plain `flutter analyze` + // treats as fatal — the reason CI went red on a test that passes. + var toggled = false; await _pump( t, BandNotificationsView(onEnabled: (v) => toggled = v),