Skip to content
Open
55 changes: 52 additions & 3 deletions lib/src/oura.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
// * PROVEN by layout plus an independent physiological sanity check: the
// temperature decoders. centi-degrees Celsius, and a worn ring reads
// 33-35 C.
// * NOT DECODED AT ALL, on purpose: beat-to-beat intervals, SpO2, the
// hypnogram, steps, raw PPG. Their layouts are bit-packed and this project
// has not one byte of any of them. A guessed bit order produces a resting
// * NOT DECODED AT ALL, on purpose: beat-to-beat intervals, SpO2, steps,
// raw PPG. Their layouts are bit-packed and this project has not one byte
// of any of them. A guessed bit order produces a resting
// 50 bpm read as 100 that passes every plausibility bound it is shown, so
// those frames are ARCHIVED VERBATIM instead (owner rulings R1-R3: capture
// everything, decode when someone has the hardware). `raw_archive` is never
// pruned and `LocalDb.redrivableArchiveReasons` is how they get re-decoded
// in place later. See the report accompanying this change for the layouts.
// * KNOWN LAYOUT, NOT VERIFIED HERE: the hypnogram. Seen on a Gen 3 only,
// never on a Ring 4/5.
//
// TIME IS THE HARD PART, and it is not solved here. An event's envelope carries
// a u32 of DECISECONDS on a clock whose epoch is not Unix and is not documented
Expand Down Expand Up @@ -99,6 +101,12 @@ const int kOuraEvtTemp = 0x46;
/// A single skin-temperature reading.
const int kOuraEvtTempPeriod = 0x69;

/// Sleep-stage hypnogram carriers: `information`, `details`, and `data`
/// (numbered 14-byte pages, 52 epochs each). Same codes on all three.
const int kOuraEvtSleepPhaseInformation = 0x4b;
const int kOuraEvtSleepPhaseDetails = 0x4e;
const int kOuraEvtSleepPhaseData = 0x5a;

/// Firmware diagnostics. Subtype-multiplexed; see [decodeDebugData].
const int kOuraEvtDebugData = 0x61;

Expand Down Expand Up @@ -150,6 +158,47 @@ List<double>? decodeTemperatures(OuraEvent e) {
return out;
}

/// One sleep stage as the ring itself staged it. Declaration order is the
/// 2-bit wire code (0 = deep .. 3 = awake).
enum OuraSleepPhase {
deep,
light,
rem,
awake,
}

/// One hypnogram event: the carrier's header byte and one stage per 30 s epoch.
class OuraSleepPhases {
/// Passed through uninterpreted. On `0x5a` it is a page counter, not an
/// epoch offset; on `0x4b`/`0x4e` its meaning is unknown.
final int header;

/// Stages in body order, one per 30 s epoch.
final List<OuraSleepPhase> phases;

const OuraSleepPhases(this.header, this.phases);
}

/// The hypnogram carried by [e]: a header byte, then 2-bit stage codes packed
/// four to a byte, MSB-first. Null when [e] is not a hypnogram carrier or has
/// no codes. Absolute timing is left to the caller (from [OuraEvent.tsDs]).
OuraSleepPhases? decodeSleepPhases(OuraEvent e) {
if (e.tag != kOuraEvtSleepPhaseInformation &&
e.tag != kOuraEvtSleepPhaseDetails &&
e.tag != kOuraEvtSleepPhaseData) {
return null;
}
if (e.body.length < 2) return null;
final phases = <OuraSleepPhase>[];
for (var i = 1; i < e.body.length; i++) {
final b = e.body[i];
for (final shift in const [6, 4, 2, 0]) {
phases.add(OuraSleepPhase.values[(b >> shift) & 0x03]);
}
}
return OuraSleepPhases(e.body[0], phases);
}

/// One `debug_data` (`0x61`) sub-record.
///
/// Every field is null unless this subtype actually carries it. There is no
Expand Down
54 changes: 52 additions & 2 deletions test/oura_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
// determinism, regression and physiological sanity. It does not prove
// correctness, because there is no independent oracle for this band — nobody
// on this project owns a ring. The decoders that are NOT here (beat intervals,
// SpO2, hypnogram, steps) are absent precisely because there are no bytes to
// SpO2, steps, raw PPG) are absent precisely because there are no bytes to
// build such a fixture from, and shipping a guess would have this file
// faithfully encoding the wrong answer.
// faithfully encoding the wrong answer. The hypnogram vectors below are
// synthetic: they pin bit order and refusal, not real-ring correctness.
//
// The NULL cases at the bottom are the load-bearing half: they are what proves
// the decoder REFUSES rather than always producing something.
Expand Down Expand Up @@ -275,6 +276,55 @@ void main() {
});
});

group('sleep phases', () {
OuraEvent hypnogram(int tag, String bodyHex) {
final body = _hex(bodyHex);
return parseOuraEvent(parseOuraFrame(
[tag, body.length + 4, ..._hex('a34d8f00'), ...body])!)!;
}

test('two-bit codes unpack MSB-first, four to a byte', () {
// 0b00_01_10_11: LSB-first would hand back the reverse.
final out = decodeSleepPhases(hypnogram(0x4b, '001b'))!;
expect(out.header, 0x00);
expect(out.phases, [
OuraSleepPhase.deep,
OuraSleepPhase.light,
OuraSleepPhase.rem,
OuraSleepPhase.awake,
]);
});

test('all three carrier tags decode the same codes', () {
for (final tag in <int>[0x4b, 0x4e, 0x5a]) {
final out = decodeSleepPhases(hypnogram(tag, '01e4'))!;
expect(out.phases, [
OuraSleepPhase.awake,
OuraSleepPhase.rem,
OuraSleepPhase.light,
OuraSleepPhase.deep,
]);
}
});

test('the header is passed through, every following byte is codes', () {
final out = decodeSleepPhases(hypnogram(0x5a, '0301020300'))!;
expect(out.header, 0x03);
expect(out.phases.length, 16);
expect(out.phases[3], OuraSleepPhase.light);
expect(out.phases[7], OuraSleepPhase.rem);
expect(out.phases[11], OuraSleepPhase.awake);
});

test('a header-only body is null', () {
expect(decodeSleepPhases(hypnogram(0x4b, '00')), isNull);
});

test('a non-hypnogram tag is null', () {
expect(decodeSleepPhases(hypnogram(0x61, '001b')), isNull);
});
});

group('authentication (non-cryptographic half)', () {
test('the challenge is 15 bytes out of a 16-byte reply body', () {
final f = parseOuraFrame(
Expand Down