diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c1d79707..019a5c60 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -123,9 +123,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, // MT-12 — the record's second and third temperature channels, and the // band's own per-second signal-quality figure. Carried by CHANNEL INDEX, @@ -206,6 +223,20 @@ int countBurstTrafficPackets({ unknownCount; } +/// 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 +/// 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; @@ -227,19 +258,62 @@ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => /// compared). /// /// [droppedThisBurst] (RecordGate.dropped delta across this burst) must be -/// added back in before comparing: a record the gate rejects is, by design, -/// "neither stored nor counted" (see RecordGate.admit) while the band's own -/// count has no such carve-out — it just counts every packet it physically -/// transmitted. Without it, a burst containing even one 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). +/// added back in before comparing, or a burst containing even one +/// 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 pinned rule is ONE-SIDED with a failure-dependent slack, not equality +///: +/// +/// ```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 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. Bounding it is what stops a permanently-short burst becoming an +/// 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; + @visibleForTesting bool burstPacketCountMatches({ required int expectedPacketCount, required int receivedTrafficCount, required int droppedThisBurst, + int consecutiveFailedValidations = 0, }) => - expectedPacketCount <= receivedTrafficCount + droppedThisBurst; + expectedPacketCount - burstCountSlack(consecutiveFailedValidations) <= + receivedTrafficCount + droppedThisBurst; /// Honest burst-completeness signal for TELEMETRY ONLY — this NEVER gates the /// commit/ACK decision (see the log-only call site). @@ -424,6 +498,42 @@ 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; + + /// 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. + /// "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; + + /// 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 + /// is to stop that from generating traffic and log noise. + int stuckMarkersDropped = 0; + int stuckRefreshesRefused = 0; _Session(this.device); @@ -876,6 +986,40 @@ class BleEngine { @visibleForTesting Future debugWriteRaw(Uint8List raw) => _write(raw); + /// 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. + @visibleForTesting + int get helloFailureCount => _helloFailures; + + /// The identity verdict from the last successful hello. + @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(); + + /// 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 — 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). /// @@ -886,6 +1030,31 @@ 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); + + /// 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 @@ -1000,6 +1169,19 @@ 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; + + /// Why the last running haptics pattern stopped (HAPTICS_TERMINATED(100), + /// `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; + // ── 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 @@ -1055,7 +1237,6 @@ class BleEngine { int _frameRevRejectsTotal = 0; /// Bounded "ask the band to re-send a short burst" budget (see P-03 / the /// class doc — an unconditional FAIL here wedged sync forever). - final BurstShortfallGate _shortfallGate = BurstShortfallGate(); ClockRef? _clockRef; // strap-RTC ↔ wall correlation (set from GET_CLOCK) /// Latest strap-RTC ↔ wall correlation, or null until GET_CLOCK is answered. @@ -1065,6 +1246,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. + /// + /// 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. + /// 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. + bool _bootstrapClockWrite = false; // Proactive RTC recheck timestamp for long-lived connections — see // kRtcReverifyIntervalSeconds. Every other clock recheck is symptom-driven. DateTime? _lastClockVerifyAt; @@ -1107,10 +1300,52 @@ 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 + ///. 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 primary input to the + /// clock decision. + Gen5HelloInfo? _gen5Hello; + + /// 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 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 + /// 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); + + /// 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 — observable, never a disconnect. Null until a hello lands. + HelloIdentity? _helloIdentity; + + /// 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; 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 @@ -1272,6 +1507,18 @@ class BleEngine { bool get offloadActive => _offloadActive; + /// True once this connection's history hit the terminal `Stuck` 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. + /// 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, 'queued_frames': _offloadFrames.length, @@ -1296,6 +1543,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 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, @@ -1329,6 +1581,30 @@ 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, + // 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, + // 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, + // 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, }; int? get strapHistoryNewestTs => _strapHistoryNewestTs; @@ -1684,54 +1960,24 @@ class BleEngine { return false; } + // (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; - // 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. - 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 @@ -1740,8 +1986,6 @@ class BleEngine { _stuckStrap = StuckStrapDetector(); _frameCorruption = FrameCorruptionDetector(); _crcFailuresThisSession = 0; - // Per-session budget only: the engine-run total deliberately carries over. - _shortfallGate.onSessionStart(); _burstMismatchStreak = 0; _autoContinue.end(); _lastBackfillAt = 0; @@ -1771,8 +2015,7 @@ class BleEngine { // Left behind, a HISTORY_END arriving on connection N>1 before that // connection's first HISTORY_START computed a NEGATIVE droppedThisBurst // against the previous connection's baseline — which fabricates a - // positive burstPacketShortfall (one needless re-delivery through - // _shortfallGate.refuse) and makes TrimAckVerdict.blockedNoDurableProgress + // positive burstPacketShortfall and makes TrimAckVerdict.blockedNoDurableProgress // unfireable (`!hadDurableRows && droppedThisBurst > 0`), i.e. a // gate-drop-only burst could authorise the band to trim flash we never // banked. The startless HISTORY_END is expected, not hypothetical: the @@ -1863,6 +2106,255 @@ class BleEngine { } } + // ── bootstrap ──────────────────────────────────── + + /// 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. + /// + /// 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 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 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 { + // 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 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 + // 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 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 + // 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 the pinned fallback 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 + // 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: 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; + } + 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; + } + // 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. + /// + /// 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 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 + /// 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; no SET_CLOCK written.'); + return; + } + } + await setClock(); + } + + /// `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; 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.'); + } + })); + } + + /// 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; + } + // "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.'); + } + // ── keep-alive + periodic backfill ────────────────────────────────────────── void _keepAliveFire(_Session session) { if (_session != session || !session.connected) return; @@ -1949,12 +2441,36 @@ 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(); - } + // 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 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 / @@ -2029,7 +2545,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`: 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!.historyStuckActive) { + 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.', @@ -2402,7 +2936,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 @@ -2415,8 +2951,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); @@ -2427,6 +2968,49 @@ class BleEngine { return ok; } + /// 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 + /// 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 @@ -2448,13 +3032,22 @@ class BleEngine { Future _sendHistoricalData() => _send(Cmd.sendHistoricalData, _offloadPayload); + /// Ask the strap to prompt more frequent history syncs around a wake time. + /// + /// 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 pinned + /// 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; @@ -2561,12 +3154,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) { @@ -2617,17 +3220,25 @@ 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 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); + // 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)); } - } else if (pt == PacketType.consoleLogs && _offloadActive) { - _drain?.onBurstConsole(); } final band = _session?.band ?? BandProfile.gen4; final decoded = _maybeAugmentClockEpoch( @@ -2681,13 +3292,24 @@ 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) { 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) { @@ -2702,6 +3324,38 @@ 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}` on a real strap. + 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. + 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) => @@ -2974,16 +3628,35 @@ class BleEngine { void _absorbState(Decoded d) { final f = d.fields; if (d.kind == 'cmd_response') _clearRepairGuideOnCommandReply(); - // 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 upstream because the decode returned a plausible-but-wrong epoch — + // that was the protocol-side bug (the rev-4 reply's epoch sits at body[2:6] + // after the revision and active-flag bytes, which the old parse misread); + // parseCommandResponse now decodes the confirmed layout including the + // active flag. The readback stays NON-authoritative for display: the + // locally-set value is what the user sees, and this only verifies it. // - // if (f.containsKey('alarm_epoch')) { ... } + // 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: + // 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. + // + // 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). @@ -3022,166 +3695,42 @@ 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. + _absorbClockEpoch(f['clock_epoch'] as int); + } + // THE ONE GET_DATA_RANGE consumer. `range_oldest`/`range_newest` come from + // the reply's real field map (protocol emits them together or not at all). + // They used to come from a local byte-scan that took the min/max of every + // 4-byte window that looked like a unix time — which is how a cross-field + // read once landed as "newest" in 2034 and left `backlogRemains` true + // forever, chasing a target the band could never reach. The scanner was + // then given a tighter ceiling instead of being replaced, and it kept + // running alongside the correct value, feeding a different decision. + // + // The same field map was then absorbed TWICE — once here behind + // isCorruptFutureRtc, and once in a sibling block that assigned + // `range_newest` straight into `_strapHistoryNewestTs` with no check at + // all. Protocol only screens to `_maxPlausibleUnix` (year 2100), so every + // junk value between now+1 day and 2100 reached the ungated path, pinned + // `backlogRemains` true in AppState._runSyncBurst and burned all 20 + // backfill sessions on every foreground catch-up. One field map, one gate. + if (f.containsKey('range_oldest') && f.containsKey('range_newest')) { + final oldest = (f['range_oldest'] as num).toInt(); + final newest = (f['range_newest'] as num).toInt(); + // GET_DATA_RANGE responses are documented to occasionally carry junk at + // unstable offsets. Nothing previously sanity-checked `range_newest` + // before it tightened RecordGate's session window for the whole + // connection — a corrupt "newest" implausibly far in the future would + // silently poison that window. Reject and fall back to the broad + // absolute floor/ceiling instead. + if (isCorruptFutureRtc(newest, _wallSecs().round())) { + _corruptDataRangeCount++; _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 - } - } - // THE ONE GET_DATA_RANGE consumer. `range_oldest`/`range_newest` come from - // the reply's real field map (protocol emits them together or not at all). - // They used to come from a local byte-scan that took the min/max of every - // 4-byte window that looked like a unix time — which is how a cross-field - // read once landed as "newest" in 2034 and left `backlogRemains` true - // forever, chasing a target the band could never reach. The scanner was - // then given a tighter ceiling instead of being replaced, and it kept - // running alongside the correct value, feeding a different decision. - // - // The same field map was then absorbed TWICE — once here behind - // isCorruptFutureRtc, and once in a sibling block that assigned - // `range_newest` straight into `_strapHistoryNewestTs` with no check at - // all. Protocol only screens to `_maxPlausibleUnix` (year 2100), so every - // junk value between now+1 day and 2100 reached the ungated path, pinned - // `backlogRemains` true in AppState._runSyncBurst and burned all 20 - // backfill sessions on every foreground catch-up. One field map, one gate. - if (f.containsKey('range_oldest') && f.containsKey('range_newest')) { - final oldest = (f['range_oldest'] as num).toInt(); - final newest = (f['range_newest'] as num).toInt(); - // GET_DATA_RANGE responses are documented to occasionally carry junk at - // unstable offsets. Nothing previously sanity-checked `range_newest` - // before it tightened RecordGate's session window for the whole - // connection — a corrupt "newest" implausibly far in the future would - // silently poison that window. Reject and fall back to the broad - // absolute floor/ceiling instead. - if (isCorruptFutureRtc(newest, _wallSecs().round())) { - _corruptDataRangeCount++; - _log( - '[SYNC] GET_DATA_RANGE newest=$newest is implausibly far in the ' - 'future — treating as a corrupt strap RTC read; NOT tightening ' - 'this session\'s plausibility window, and NOT adopting it as the ' - 'backlog target ' - '(corrupt_ranges_total=$_corruptDataRangeCount).', + '[SYNC] GET_DATA_RANGE newest=$newest is implausibly far in the ' + 'future — treating as a corrupt strap RTC read; NOT tightening ' + 'this session\'s plausibility window, and NOT adopting it as the ' + 'backlog target ' + '(corrupt_ranges_total=$_corruptDataRangeCount).', ); } else { _sessionOldestUnix = oldest; @@ -3233,15 +3782,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 revision-1 body map — battery, charge state, the + // strap's own timestamp, serial, firmware and on-body state all come from + // 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) { + 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; @@ -3252,6 +3809,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 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)) || + (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 + @@ -3275,7 +3861,30 @@ class BleEngine { } void _handleEventInfo(EventInfo event) { + final f = event.decoded; switch (event.eventId) { + case EventId.strapConditionReport: + // 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}', + ); + return; + case EventId.hapticsTerminated: + // . `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.', @@ -3421,6 +4030,106 @@ 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 pinned retry boundary. + 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. + // + // 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 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. + session.historyStuck = true; + session.historyStuckAt = DateTime.now(); + _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.currentBurstTrafficCount, + '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.currentBurstTrafficCount, + 'dropped_this_burst': droppedThisBurst, + 'attempts': d.consecutiveValidationFailures, + }, + )); + } + Future _refuseHistoryEndTrim( TrimAckVerdict verdict, { required DrainController d, @@ -3467,7 +4176,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( @@ -3584,7 +4293,31 @@ class BleEngine { if (_sessionIsStale(session)) return; final m = parseMetadata(frame.inner); if (m == null) return; - _armIdleWatchdog(); + // 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. + // + // 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.historyStuckActive && m.sub != SyncMeta.historyComplete) { + 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; + } + // Stuck: the COMPLETE passing through above must not re-arm the watchdog + // it deliberately left dead. + if (!session.historyStuckActive) _armIdleWatchdog(); _log( '[SYNC] META sub=${m.sub} inner=' '${frame.inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', @@ -3642,23 +4375,38 @@ 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 currentBurstTrafficCount. - // The baseline is reset per connection alongside the gate itself, so a - // HISTORY_END with no HISTORY_START before it cannot go negative here. + // 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; + // 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, ); - // 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). + // 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 + // 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( @@ -3666,26 +4414,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, ' 'dropped_this_burst=$droppedThisBurst, ' @@ -3706,32 +4459,62 @@ class BleEngine { 'burst_shortfall': shortfall, }, )); + await _refuseHistoryEndOnShortCount( + d: d, + session: session, + tokenHex: tokenHex, + batchId: m.batchId, + expected: expected, + 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; } - // A positive shortfall = frames the band counted that we did not count - // as valid received traffic (missing, or CRC-corrupted). It used to be - // log-only, which meant a hole in the burst was counted and then handed - // a trim authorisation anyway — the missing seconds left flash forever. + // 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. // - // It now costs the band ONE re-delivery, and only one: the refusal is - // taken AFTER the commit (so the records we did get are already durable - // and the re-delivery only has to make up the difference) and is bounded - // per token / per session / per engine run by [BurstShortfallGate]. That - // recovers the transient-radio case without reviving the always-FAIL - // behaviour that wedged sync forever. - final shortfallRetry = shortfall > 0 && _shortfallGate.refuse(tokenHex); - if (shortfall > 0) { + // 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 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 (gateEnforced && shortfall > 0) { _log( - '[SYNC] burst completeness shortfall: 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) — ' - '${shortfallRetry ? "REFUSING the trim token once so the band " - "re-delivers this chunk" : "budget spent, committing + ACKing " - "(refusals: session=${_shortfallGate.refusalsThisSession} " - "total=${_shortfallGate.refusalsTotal})"}', + '[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; @@ -3808,7 +4591,10 @@ class BleEngine { commitDurable: durable, hadDurableRows: hadDurableRows, droppedThisBurst: droppedThisBurst, - shortfallRetry: shortfallRetry, + // 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, ); if (verdict != TrimAckVerdict.send) { await _refuseHistoryEndTrim( @@ -3947,7 +4733,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.historyStuckActive) { _setHpsTerminal( _HpsTerminalKind.metadataWhileNotSyncing, reason: 'history_complete_while_not_syncing', @@ -4158,15 +4946,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 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; // 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]. @@ -4327,42 +5118,170 @@ 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 real + // command on BOTH generations. It used to send opcode 146 ("Maverick + // 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 + // 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 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) { + 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 (_bootstrapClockWrite) { + // The bootstrap's own clock step is the single writer for this + // 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).'); + } 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 @@ -4380,25 +5299,161 @@ 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 + /// Send the gen5 `GET_HELLO(0x91)` and wait for its reply. + /// + /// 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 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 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, 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; + } + 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 the + // "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 standard 5-second command timeout. + static const Duration _helloTimeout = Duration(seconds: 5); + + /// (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) — 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; the band stays usable.'); + } + } + + /// 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 pending.future.timeout(_clockReadTimeout); + 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 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( + Cmd.getClock, // band-correct opcode + body; gen4 sent to gen5 is silence + const [], + timeout: _clockReadTimeout, + ); + 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; - } 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).', - ); - return false; - } finally { - if (identical(_clockReadPending, pending)) _clockReadPending = null; } + _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 `clock_epoch`. A connected-link round trip + /// 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); @@ -4417,8 +5472,24 @@ 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. + /// 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, @@ -4448,16 +5519,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: @@ -4714,6 +5810,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; @@ -5021,7 +6121,11 @@ class DrainController { records++; recordsThisOffload++; _lastProgressAt = DateTime.now(); - burstStats.onHistoricalData(raw.packetType, raw.counter, revision); + // 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, revision); + } if (_buffering) { _raws.add(raw); _samples.add(sample); @@ -5059,6 +6163,27 @@ class DrainController { if (a.reason != kGateDroppedReason) { records++; recordsThisOffload++; + // The band's expected count tallies every type-47 frame it TRANSMITTED, + // 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 + // 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 && !_burstTallyClosed) { + // The revision moved to the caller when the decoded path started + // carrying one. An archive has no decoded record, so read it off the + // frame the same way the callee used to: inner[1]. A frame too short + // to have one passes -1, which the callee buckets as unknown. + final inner = hexToBytes(a.hex); + burstStats.onHistoricalData( + a.packetType, + a.counter, + inner.length < 2 ? -1 : inner[1], + ); + } } _lastProgressAt = DateTime.now(); if (_buffering) { @@ -5073,9 +6198,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 @@ -5093,6 +6222,7 @@ class DrainController { expectedPacketCount: expectedPacketCount, receivedTrafficCount: currentBurstTrafficCount, droppedThisBurst: droppedThisBurst, + consecutiveFailedValidations: consecutiveValidationFailures, )) { consecutiveValidationFailures = 0; return true; @@ -5118,6 +6248,7 @@ class DrainController { _linkDown = false; _lastProgressAt = DateTime.now(); burstStats.reset(); + _burstTallyClosed = false; } /// The band declared a new burst (HISTORY_START) — clear the poison latch. @@ -5129,7 +6260,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 1e85d69a..a16f5ccd 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; @@ -616,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 @@ -718,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 @@ -852,6 +795,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 — ) 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]. @@ -866,13 +826,21 @@ enum FrameRoute { class FrameRoutePolicy { const FrameRoutePolicy._(); + /// [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({ 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; } } @@ -1292,3 +1260,334 @@ class AlarmConfirmation { } } } + +// ── command/response correlation ──────────────────────────────────── + +/// A command response that was matched to a request we actually made. +/// +/// 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. + 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 — 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. +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); + + 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. + /// + /// 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); + 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); + } +} + +/// The registry that turns fire-and-forget writes into real request/response +/// transactions. +/// +/// 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 { + /// The generic five-second 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: 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 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 + /// 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 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); + // 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; + } + + /// 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 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 +/// 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)' : ''}'; +} + +/// 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 +/// 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. +/// +/// "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(); + final nm = name.trim().toLowerCase(); + if (id == unsetAddress) return false; + 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; + } +} + 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 diff --git a/lib/data/db.dart b/lib/data/db.dart index bd049433..698b11bf 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -250,7 +250,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 = 45; + static const int schemaVersion = 46; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -766,6 +766,14 @@ class LocalDb { await _ensureBandBatteryChargeUnits(db); await _backfillBandBatteryFromEvents(db); } + if (oldV < 46) { + // Retire the disproven gen5 columns that v34-era dev builds banked + // (`on_wrist` / `hr_valid`, plus the -50.00 °C skin-temp sentinel). + // Data-only: the DDL is untouched, so this does NOT diverge an + // upgraded install's schema from a fresh one. See + // _retireDisprovenOneHzColumns for the evidence. + await _retireDisprovenOneHzColumns(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1029,6 +1037,40 @@ class LocalDb { static Future _ensureBeatTimeColumn(Database db) => _addColumnIfMissing(db, 'decoded_rr', 'beat_ts_ms', 'INTEGER'); + + /// v46: 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, @@ -3263,6 +3305,12 @@ class LocalDb { // optical/thermal ADCs at all, so those three are ALWAYS absent there. // Absence now lands as NULL. `_relaxDecodedSensorNulls` (v39) rebuilds the // table on existing installs. + // `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 since that mapping change 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); // NO index on `counter`. There was one, described as a forensic-only // lookup — and nothing in the app ever filtered or ordered by `counter` @@ -3780,9 +3828,11 @@ class LocalDb { stepCount: g.stepMotionCounter, stepCadence: g.stepCadence, activityClass: g.activityClassKnown, - skinTempC: g.skinTempC, - onWrist: g.onWristRaw, - hrValid: g.hrRrValidThisSecond, + // Same honesty contract as the live mapper: the -50.00 °C code is + // the sensor's unavailable sentinel, and body-60 bits 0-1 / body-15 + // bit7 are disproven as wear / HR-validity (see + // sampleFromGen5Historical) — a replay must not resurrect them. + skinTempC: g.skinTempCOrNull, hrAlt: g.heartRateAlt, // MT-12's three columns exist (v43) and the write below names // them. Carried here because they are free and the point of @@ -6400,6 +6450,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/data/models.dart b/lib/data/models.dart index 02eede79..cce9a7c6 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; /// Ambient-light ADC count — GEN4 ONLY (gen5 sends no per-second equivalent). diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 3d1cb0ec..e295481f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3355,6 +3355,18 @@ class AppState extends ChangeNotifier { }) async { var last = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { + // 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 + // 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 @@ -3615,8 +3627,13 @@ 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. 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.'); + // 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 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 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 b5acee9a..2d2544ae 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 +//, 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 +/// the correlation contract demands and the one a fast strap 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] @@ -233,4 +287,105 @@ void main() { expect(a.targetEpoch, 1750000000); }); }); + + + // 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'); + }); + }); + } 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), diff --git a/test/ble_clock_gate_test.dart b/test/ble_clock_gate_test.dart index 440c6aaa..57daca66 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, 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 cd08cfb4..9c57a15e 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -121,6 +121,56 @@ void main() { isFalse, ); }); + + // 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. + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + receivedTrafficCount: 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, + receivedTrafficCount: 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, + receivedTrafficCount: 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)', () { @@ -232,6 +282,30 @@ void main() { ); expect(matches, (shortfall <= 0)); }); + + 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, + receivedTrafficCount: received, + droppedThisBurst: dropped, + ), + isTrue, + ); + }); }); group('maintenance traffic gating', () { diff --git a/test/command_correlation_test.dart b/test/command_correlation_test.dart new file mode 100644 index 00000000..2cf5f188 --- /dev/null +++ b/test/command_correlation_test.dart @@ -0,0 +1,566 @@ +// Command/response correlation — 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, +/// 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 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); + 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', () { + 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: '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', () { + 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 { + // 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. + 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', () { + 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', () { + 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 — 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); + 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: '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'); + + 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', () { + 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: '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', () { + 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/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_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index 355aee5b..aa02bec8 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(); @@ -119,6 +149,14 @@ void main() { // CORROBORATION, never a stage — see Sample.bandSleepState. expect(rows.first['ts_subsec'], 18022); expect(rows.first['band_sleep_state'], 0); + // 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', @@ -176,6 +214,92 @@ void main() { expect(rows.first['skin_temp_raw'], isNull); }); + // 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 93f8b811..c73a31bf 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 @@ -85,6 +113,32 @@ void main() { expect(sample!.tempCh3C, 26.5); expect(sample!.signalQualityLogVar, isNotNull); }); + + 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`', + ); + }); }); // The band's own wake/sleep envelope: modelled by protocol, polarity already @@ -120,9 +174,11 @@ void main() { final s = sampleFromGen5Historical(parseGen5Historical(hex(e.value))); expect(s, isNotNull, reason: 'state ${e.key} failed to decode'); expect(s!.bandSleepState, e.key); - // The neighbouring 2-bit field in the same byte still reads - // independently — proof the nibble is being sliced, not the byte. - expect(s.onWrist, isNotNull); + // The neighbouring 2-bit field in the same byte is NOT mapped: bits + // 0-1 are the primary-flags bit-8 snapshot, not wear (disproven on + // 1.59M records), so onWrist stays absent while the nibble beside it + // decodes — proof the byte is being sliced, not read whole. + expect(s.onWrist, isNull); } }); @@ -141,6 +197,58 @@ void main() { }); }); + 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', () { test('a null decode (unrecognised version/garbage) maps to null', () { expect(sampleFromGen5Historical(null), isNull); diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index d38dc7d2..c1887943 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'; @@ -345,10 +346,70 @@ 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 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. + 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 pinned 180 s / 7200 s Smart Alarm values', + () async { + // 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('gen5 reads the clock with the established GET_CLOCK(11), empty body', + () async { + // 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(2), [Cmd.getClockGen5, 0x01]); + expect(w.lastCommandOf(1), [Cmd.getClock]); + expect(Cmd.getClock, 11); + }); + + 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: + // 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]); }); }); @@ -429,4 +490,1005 @@ void main() { expect(sent, hasLength(1)); }); }); + + _events(); + _bootstrap(); + _burstOrdering(); +} + +/// A type-48 EVENT inner: +/// `[0x30][u8 seq][u16 id][u32 unix][u16 subsec][u16 body len][body…]` +///. 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, + ); + // 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) 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( + _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 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', () { + 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); + }); + + 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'); + }); + }); +} + +// ── T11: the doc-01 bootstrap 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 +// 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: 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 +/// 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 + /// the correlation contract 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, 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', () { + 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: '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', () { + 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: '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: '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('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 the one-SET_CLOCK-per-bootstrap rule. + 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: '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(); + // 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', () { + 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: '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: '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', () { + /// 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); + 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', () { + fakeAsync((async) { + final link = chargingRig(async, charging: false); + async.elapse(const Duration(seconds: 40)); + expect(link.count(Cmd.getBatteryPackInfo), 0, + reason: '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: 'body 01'); + + for (var expected = 2; expected <= 5; expected++) { + async.elapse(const Duration(seconds: 5)); + expect(link.count(Cmd.getBatteryPackInfo), expected); + } + // 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))); + }); + }); + }); +} + +// ── T14: burst count membership must follow the band's arrival order ───────── +// +// 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. +// +// 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, +// 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]); + +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}) { + 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 `traffic=` field of each HistoryEnd line, in order — i.e. the + /// all-types tally the count gate judged for each burst that passed it. + List get acceptedCounts => logs + .where((l) => l.contains('[SYNC] HistoryEnd batch=')) + .map((l) => int.parse( + RegExp(r'traffic=(\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', () 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 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)); + 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; + + /// 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: 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(); + await stuckAfterFifteen(b); + + expect(b.shortLines, hasLength(kBurstValidationAttemptLimit)); + expect( + b.opcodes.where((o) => o == Cmd.abortHistoricalTransmits).length, + 1, + reason: 'ONE abort at the 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 on a + // real strap. + final before = b.opcodes.length; + for (var i = 0; i < 4; i++) { + b.rx(_historyEnd(expected: 5, token: 0x8600)); + await pumpEventQueue(); + } + 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(); + await stuckAfterFifteen(b); + + expect(await b.engine.debugStartHistoricalRefresh(), isFalse, + reason: 'continuation belongs to a later connection, not to a ' + '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 + // 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); + }); + + 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); + }); + }); }