diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 770ba8d19..849ea7d60 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,5 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" + auto_review: + drafts: true \ No newline at end of file diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3d7f07476..a14f61d17 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -307,6 +307,10 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, + // Raw candidate byte, never a percentage — the encoding is not pinned and + // no ground truth exists, so this is carried for diagnostics only and is + // not persisted (see Sample.spo2CandidateRaw). + spo2CandidateRaw: g.spo2CandidateRaw, ); } @@ -5778,6 +5782,12 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); + // The gen5 SpO2 candidate byte is never written to _log — FileLog lives in + // Android external storage, so a timestamped candidate/sleep line there + // would be readable by other apps (CWE-532). It is not persisted as a + // decoded column either; only whole UNDECODABLE frames reach raw_archive, + // which can contain the byte (kept for the commit-before-ACK invariant). + // In-memory carrier: Sample.spo2CandidateRaw — raw, never a percentage. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just diff --git a/lib/data/models.dart b/lib/data/models.dart index 95f7f1a46..6b127b382 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -152,6 +152,22 @@ class Sample { /// (analytics `band_offset.dart`) — never to create, extend or stage one. final int? bandSleepState; + /// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, + /// inner[74]) — GEN5/MG ONLY, raw and unvalidated. + /// + /// Zero in ~99% of records, nonzero only during band-declared sleep + /// (community-observed bursts of 30 s every 1,200 s), encoding not fully + /// pinned (values above 128 decompose as 128 + flag). Carried as the raw + /// byte for capture correlation; NEVER displayed or stored as a + /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. + /// Not persisted (no column); in-memory diagnostic only, never logged + /// (FileLog is external storage — CWE-532). After the validation gate in + /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that + /// feeds `Spo2Data` — the single publication path. The validation gate this + /// refers to lives in docs/proposals/whoop-spo2.md, added by PR #17 + /// (upstream OpenStrap/edge#675) — not part of this branch. + final int? spo2CandidateRaw; + Sample({ required this.tsEpoch, required this.counter, @@ -177,6 +193,7 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, + this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -208,6 +225,7 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, + spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart new file mode 100644 index 000000000..4f7311017 --- /dev/null +++ b/test/gen5_spo2_candidate_test.dart @@ -0,0 +1,68 @@ +// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, inner[74]) +// must be carried through `decodeGen5HistoricalSample` as a RAW byte — never +// gated, never converted to a percentage. The byte is unvalidated community +// knowledge (nonzero only in band-declared sleep bursts), so the contract +// under test is passthrough + null on gen4-shaped input, not a value claim. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_edge/models/payloads.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. +Uint8List v18Inner({int spo2Candidate = 0}) { + final b = Uint8List(kGen5V18InnerLen); + b[1] = 18; + final v = ByteData.sublistView(b); + v.setUint32(7, 1780916150, Endian.little); // unix + b[73] = 2; // sleepStateByte: sleep + b[74] = spo2Candidate; + return b; +} + +void main() { + test('gen5 v18 spo2CandidateRaw is carried through as the raw byte', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97)); + expect(s, isNotNull); + expect(s!.spo2CandidateRaw, 97); + expect(s.bandSleepState, isNotNull); + }); + + test('zero candidate stays zero — absent measurement, not a gated null', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 0)); + expect(s!.spo2CandidateRaw, 0); + }); + + test('flag-style values above 128 are passed through unmasked', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); + expect(s!.spo2CandidateRaw, 128 + 97); + }); + + // copyWith must carry the candidate along — a regression that drops + // spo2CandidateRaw from copyWith would silently lose it on every copy. + // (copyWith only overrides tsEpoch; the field must pass through unchanged.) + test('copyWith preserves a nonzero spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.copyWith(tsEpoch: s.tsEpoch + 1).spo2CandidateRaw, 97); + }); + + test('copyWith preserves an absent (null) spo2CandidateRaw', () { + // A gen4-shaped sample carries no candidate byte at all. + final s = Sample(tsEpoch: 1000, counter: 0, hr: 60); + expect(s.spo2CandidateRaw, isNull); + expect(s.copyWith(tsEpoch: 1001).spo2CandidateRaw, isNull); + }); + + // The seam to PR #472: a captured candidate byte must NOT unlock the + // publication path. `Spo2Data` (payloads.dart) stays unread until the + // validation gate passes — capturing is not displaying. + test('captured candidate stays unread: Spo2Data refusal is intact', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.spo2CandidateRaw, 97); + final payload = TodayData.fromJson({'spo2': const {}}); + expect(payload.spo2, isNull, reason: 'no odi_per_hour -> Spo2Data unread'); + }); +}