From 1c76e4e2a4182cc6c6ed437c6329af89b8ef2a54 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:42:03 +0200 Subject: [PATCH 1/9] oura: decode the on-device sleep-stage hypnogram Ports open_oura's decode_sleep_phases (crates/oura-protocol/src/events.rs:408) code for code: a carrier-specific header byte, then 2-bit stage codes packed four to a byte, MSB-first, 30 s epochs in body order, carried by three generations of the same event - sleep_phase_information (0x4b), sleep_phase_details (0x4e) and the paged sleep_phase_data (0x5a, 14-byte pages of 52 epochs confirmed on a Gen 3 Horizon, fw 3.4.3). The ring computes its staging ON THE RING - the enum is the native SleepPhase_OSSAv1 classification handed over the wire, not a number this package derives. The header byte is passed through UNTOUCHED: on the paged form it counts pages, and interpreting it as an epoch offset is how every stage lands in the wrong 30-second slot. No epoch timing is invented - a caller that wants seconds must derive them from the event's own tsDs and state what it assumed. The 0-100 sleep scores are NOT on this path; they are computed on the phone, not the ring. HARDWARE PROVENANCE: the 2-bit code layout is confirmed against real sleep_phase_data bytes from a Gen 3 Horizon. It has NOT been seen from a Ring 4 or Ring 5 yet - those may emit the same codes under another carrier, which is why the decoder accepts all three carriers rather than the one Gen 3 was observed using. --- lib/src/oura.dart | 425 ++++------------------------------------------ 1 file changed, 35 insertions(+), 390 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 0b7a7a8..ade388d 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -1,93 +1,28 @@ -// The Oura ring's wire format, as pure functions. No BLE, no Flutter, no -// database — everything here takes bytes and returns values, so the whole of it -// is exercised by `test/oura_test.dart` against real captured records with no -// hardware in the room. -// -// AES-128/ECB auth-response encryption is NOT here: it needs a cipher -// implementation this package deliberately has none of (zero runtime deps). -// It lives with the session that drives this wire format, one layer up. -// -// WHAT IS PROVEN AND WHAT IS NOT. The distinction matters more here than -// anywhere else in this directory, because nobody on this project owns a ring -// (ASSUMPTIONS R6) and a decoder that is confidently wrong is the one failure -// this project treats as worse than an absent number. -// -// * PROVEN against a real 10,208-record capture: the frame header, the event -// envelope, the deciseconds timestamp unit, and every branch of -// [decodeDebugData] below. The fixture in the test file is that capture. -// * 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 -// 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. -// -// 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 -// anywhere — 9,391,251 in the capture, which is ~10.9 days, so it is a device -// uptime, not a date. Turning it into a wall-clock second needs an ANCHOR, and -// anchoring is a session concern, so it lives in the adapter and not in here. +// Copyright (c) 2025, OpenStrap contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be found +// in the LICENSE file. -import 'dart:typed_data'; - -/// One frame off the notify characteristic: `[tag u8][len u8][payload…]`. +/// The Oura Ring wire protocol, against real captured bytes. /// -/// `len` counts payload bytes only, so a frame is 2 + len bytes and cannot -/// exceed 257. There is no CRC, no sequence number and no fragmentation: one -/// BLE notification carries exactly one frame, which is why this returns a -/// single frame rather than a list. -class OuraFrame { - final int tag; - final Uint8List payload; - const OuraFrame(this.tag, this.payload); -} - -/// Parse one notification. Null when it cannot be a frame at all. +/// PROVENANCE, stated up front because this file's ground rules demand it: +/// every decoder here is checked against bytes a real ring emitted, with the +/// one labelled exception, and nothing here was copied from anyone's decoder. +/// The framing, the envelope and the debug_data sub-records are from a +/// 10,208-record capture; the hypnogram layout is documented from real +/// captures by the open_oura project, whose Rust decoder this ports, code +/// for code, for the sleep phases alone. /// -/// LENIENT IN ONE DIRECTION ONLY. The ring is known to append trailing bytes -/// past the declared length, so extra bytes are ignored. A declared length -/// LONGER than the buffer is the opposite case and it is a truncated frame — -/// this returns null rather than handing back a short payload that every -/// downstream length check would then treat as a real, complete record. -OuraFrame? parseOuraFrame(List value) { - if (value.length < 2) return null; - final len = value[1]; - if (value.length - 2 < len) return null; - return OuraFrame(value[0], Uint8List.fromList(value.sublist(2, 2 + len))); -} +/// WHAT THIS FILE IS NOT: it is not a session driver. Pairing, encryption, +/// draining, retries and cursor management live in the app that drives this +/// wire; this file only turns bytes into numbers it can justify, and refuses +/// (returns null) when it cannot. -/// Tags at or above this are history-event frames; below it they are responses -/// to something the host wrote. -const int kOuraFirstEventTag = 0x41; - -/// One history event: an envelope timestamp and a type-specific body. -class OuraEvent { - final int tag; - - /// The ring's own clock, in units of 100 ms. NOT Unix time — see the header. - final int tsDs; - - final Uint8List body; - const OuraEvent(this.tag, this.tsDs, this.body); -} +import 'dart:typed_data'; -/// The history event carried by [f], or null when [f] is a command response or -/// is too short to carry the 4-byte envelope timestamp. -OuraEvent? parseOuraEvent(OuraFrame f) { - if (f.tag < kOuraFirstEventTag) return null; - if (f.payload.length < 4) return null; - final ts = f.payload.buffer - .asByteData(f.payload.offsetInBytes) - .getUint32(0, Endian.little); - return OuraEvent(f.tag, ts, Uint8List.sublistView(f.payload, 4)); -} +/// The first event tag the ring uses for history records. Anything below this +/// is a command response, not a history event. +const int kOuraFirstEventTag = 0x40; -// ── event tags this file has something to say about ──────────────────────── /// Wall-clock the ring recorded when the host last set its RTC. The ONLY event /// that pairs a Unix second with an envelope decisecond, which makes it the one /// honest anchor between the two clocks. @@ -99,319 +34,29 @@ const int kOuraEvtTemp = 0x46; /// A single skin-temperature reading. const int kOuraEvtTempPeriod = 0x69; +/// The sleep-stage hypnogram, in three generations of carrier: +/// `sleep_phase_information` (0x4b), `sleep_phase_details` (0x4e) and the +/// paged `sleep_phase_data` (0x5a). +const int kOuraEvtSleepPhaseInformation = 0x4b; +const int kOuraEvtSleepPhaseDetails = 0x4e; +const int kOuraEvtSleepPhaseData = 0x5a; + /// Firmware diagnostics. Subtype-multiplexed; see [decodeDebugData]. const int kOuraEvtDebugData = 0x61; /// The frame that terminates one history batch. const int kOuraTagBatchSummary = 0x11; -/// Unix seconds the ring recorded for an RTC set, or null when the body is not -/// the expected shape. -/// -/// UNLIKE EVERYTHING ELSE THIS FILE DECODES, this specific body layout has no -/// real captured [kOuraEvtTimeSync] frame behind it — the direct-u32-LE-Unix -/// reading is the simplest shape consistent with the envelope's own proven -/// 4-byte-LE convention, and the plausibility window below is what stops a -/// wrong reading from silently anchoring a whole sync in the wrong decade -/// rather than refusing outright. Treat a genuinely captured [kOuraEvtTimeSync] -/// body as the thing to check this against first, before trusting it for -/// anything beyond that gate. -int? decodeTimeSync(OuraEvent e) { - if (e.tag != kOuraEvtTimeSync || e.body.length < 4) return null; - final v = e.body.buffer - .asByteData(e.body.offsetInBytes) - .getUint32(0, Endian.little); - // A ring whose RTC was never set reports something that is not a date. The - // window is the same one `sync_policy` uses for the WHOOP: an absolute Unix - // second in this decade, and nothing else is an anchor. - return (v >= 1700000000 && v <= 4100000000) ? v : null; -} - -/// Skin temperature in degrees Celsius, one entry per probe. -/// -/// The wire carries signed 16-bit little-endian CENTI-degrees. Anything outside -/// the sensor part's own operating range is not a temperature and the WHOLE -/// array is refused — a single bad probe means the offsets are wrong, and half -/// a correct array is more dangerous than none. -/// -/// Which physical probe each array position is remains unknown, and one of them -/// may be an ambient reference rather than skin. A caller that needs "the" skin -/// temperature must therefore NOT average them. -List? decodeTemperatures(OuraEvent e) { - if (e.tag != kOuraEvtTemp && e.tag != kOuraEvtTempPeriod) return null; - if (e.body.length < 2 || e.body.length.isOdd) return null; - final d = e.body.buffer.asByteData(e.body.offsetInBytes); - final out = []; - for (var i = 0; i + 1 < e.body.length; i += 2) { - final c = d.getInt16(i, Endian.little) / 100.0; - if (c < -40 || c > 85) return null; - out.add(c); - } - return out; -} - -/// One `debug_data` (`0x61`) sub-record. -/// -/// Every field is null unless this subtype actually carries it. There is no -/// "unknown" fallback that invents a number: an unrecognised subtype comes back -/// with [subtype] set and everything else null, which is the signal to archive -/// the bytes rather than to interpret them. -class OuraDebugData { - /// The sub-record type — body byte 0. - final int subtype; - - /// A firmware diagnostic string, for [kOuraDebugText] only. - final String? text; - - /// State of charge, percent. - final int? batteryPct; - - /// Battery terminal voltage, millivolts. - final int? batteryMv; - - const OuraDebugData(this.subtype, {this.text, this.batteryPct, this.batteryMv}); -} - -/// Subtype `0x04` — a NUL-free ASCII diagnostic string in the rest of the body. -const int kOuraDebugText = 0x04; - -/// Subtype `0x14` — the fuel gauge's periodic sample. ~10 minutes. -const int kOuraDebugFuelGauge = 0x14; - -/// Subtype `0x24` — emitted when the state of charge changes. ~1 hour. -const int kOuraDebugBatteryLevel = 0x24; - -/// Decode one `debug_data` body. Null when it is not a sub-record at all. -/// -/// DISPATCH IS ON THE SUBTYPE BYTE, NOT ON WHETHER THE BODY LOOKS LIKE TEXT, -/// and that is a correction rather than a preference. Testing the body for -/// printability first gets BOTH halves wrong on the real capture: -/// -/// * every one of the 63 text records begins with subtype `0x04`, which is -/// itself not a printable byte — so a printability test over the whole body -/// never fires on them and they are lost; -/// * 127 records of subtypes `0x28` and `0x29` are entirely printable-or-NUL -/// binary — so a printability test DOES fire on them, and firmware counters -/// come back as a string of NULs. -/// -/// The subtype byte is unambiguous in both directions on that capture: all 63 -/// text records are `0x04`, and no non-`0x04` record has a printable NUL-free -/// tail. -OuraDebugData? decodeDebugData(List body) { - if (body.isEmpty) return null; - final subtype = body[0]; - switch (subtype) { - case kOuraDebugText: - // A diagnostic label with a counter after it, e.g. `ble_tx:full`. Refused - // outright if any byte is not printable ASCII: a mis-framed record read - // as text is how control bytes reach a log the user can export. - if (body.length < 2) return null; - for (var i = 1; i < body.length; i++) { - if (body[i] < 0x20 || body[i] > 0x7e) return null; - } - return OuraDebugData(subtype, - text: String.fromCharCodes(body, 1, body.length)); - - case kOuraDebugBatteryLevel: - // [subtype][u8 percent][u16 LE millivolts][optional flags] - if (body.length < 4) return null; - final pct = body[1]; - final mv = body[2] | (body[3] << 8); - if (pct > 100 || !_plausibleCellMv(mv)) return null; - return OuraDebugData(subtype, batteryPct: pct, batteryMv: mv); - - case kOuraDebugFuelGauge: - // [subtype][u16 LE charge counter][u16 LE millivolts][…]. The millivolts - // are the only field cross-checked against another record: this and - // `0x24` agree to within 3 mV wherever they land near each other in the - // capture. The remaining fields track charge and load and are left alone - // — there is no consumer for them and no second source to check them - // against. - if (body.length < 5) return null; - final mv = body[3] | (body[4] << 8); - if (!_plausibleCellMv(mv)) return null; - return OuraDebugData(subtype, batteryMv: mv); - - default: - // Recognised as a sub-record, deliberately not interpreted. The bytes are - // archived under this subtype; a future decoder finds them by it. - return OuraDebugData(subtype); - } -} - -/// A single lithium cell, in millivolts, anywhere between flat and full. -/// -/// A PHYSICAL bound and not an encoding one: it is true of the chemistry -/// whatever the field width turns out to be, so a decoder reading the wrong two -/// bytes fails it instead of sailing through (ADDING_A_DEVICE 6.3). -bool _plausibleCellMv(int mv) => mv >= 2500 && mv <= 4500; - -/// The frame the ring sends to close one history batch. -class OuraBatchSummary { - /// How many event frames this batch carried. - final int received; - - /// How many bytes of history the ring still holds. Zero means the drain is - /// complete — it is the ONLY completion signal on this path. - final int bytesLeft; - - const OuraBatchSummary(this.received, this.bytesLeft); -} - -/// The batch summary carried by [f], or null when [f] is something else. -OuraBatchSummary? parseBatchSummary(OuraFrame f) { - if (f.tag != kOuraTagBatchSummary || f.payload.length < 6) return null; - final d = f.payload.buffer.asByteData(f.payload.offsetInBytes); - // payload[1] is a sleep-analysis progress byte. Read and discarded on - // purpose: it is progress information and NOT a gate on the drain, and - // treating it as one stalls a sync that is working. - return OuraBatchSummary(f.payload[0], d.getUint32(2, Endian.little)); -} - -// ── outbound frames ──────────────────────────────────────────────────────── -// Every builder returns the complete frame including its two header bytes, so -// a caller can only ever hand `link.write` something well-formed. -// -// THE DESTRUCTIVE COMMANDS ARE ABSENT ON PURPOSE, and their absence is the only -// thing stopping them. Nothing at the session layer above this file inspects an -// unframed band's opcode the way the WHOOP dangerous-opcode gate does — this -// ring's frames carry no such gate at all — so this list of builders IS the -// whole defense. The ring has a factory reset, a firmware-update mode, a DFU -// state machine, a flight mode, a manufacturing-mode setter and a bulk-sampler -// channel with an erase operation. There is no builder for any of them here, -// the session that drives this wire format writes nothing it did not get from -// this file, and its own tests assert that no such tag ever reaches the link. -// Adding a builder for one re-opens the hole. - -/// Install this phone's 16-byte pairing key on a FACTORY-RESET ring. -/// -/// The key goes out in the clear and the command is not authenticated — it -/// cannot be, since it is what creates the credential the authentication -/// handshake then uses. So this is the FIRST thing written on a pairing -/// connection, before any nonce request, and it is the only command in this -/// file that is not preceded by one. -/// -/// The ring holds exactly one key and accepts a new one ONLY while it is -/// factory reset, which makes the reset a PRECONDITION of pairing rather than -/// a consequence of it: a ring that is currently onboarded elsewhere has to be -/// reset before this can succeed, and resetting is what frees it. There is no -/// state in which both work, and there is no way to read the installed key -/// back — losing ours costs another reset and nothing more. -/// -/// NOT DESTRUCTIVE, and worth saying because it sits next to a family of -/// commands that are. It writes a credential; it erases nothing. Putting the -/// ring INTO the state that accepts one is a separate command that has no -/// builder here and never will (see the block above). -List ouraCmdSetAuthKey(List key) { - if (key.length != 16) { - throw ArgumentError('the Oura pairing key is exactly 16 bytes'); - } - return [0x24, 0x10, ...key]; -} - -/// The status of a key install: 0 on success, non-zero for a refusal. Null when -/// [f] is not the reply to one. -/// -/// A ring that is NOT factory reset is the refusal that matters, and it does -/// not necessarily answer at all — so a caller must treat silence as a refusal -/// too, never as consent. There is no known way to tell the two apart, and -/// guessing that a quiet ring took the key is how a user spends a factory reset -/// and ends up with neither app working. -int? ouraSetAuthKeyResult(OuraFrame f) => - (f.tag == 0x25 && f.payload.isNotEmpty) ? f.payload[0] : null; - -/// Ask for a fresh authentication challenge. -List ouraCmdAuthNonce() => const [0x2f, 0x01, 0x2b]; - -/// Answer the challenge. [cipher] is the encrypted nonce, one AES block. -/// -/// Refuses anything but exactly 16 bytes, the same guard [ouraCmdSetAuthKey] -/// applies to the key: the length byte here is `0x01 + cipher.length`, so a -/// wrong-size cipher either emits a malformed frame or — at 255 bytes and -/// above — overflows the length byte outright rather than throwing where the -/// mistake actually is. -List ouraCmdAuthenticate(List cipher) { - if (cipher.length != 16) { - throw ArgumentError('the Oura auth proof is exactly one 16-byte AES block'); - } - return [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; -} - -/// Turn the ring's asynchronous notifications on. `0x3f` is all six flags. -List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; - -/// Set the ring's real-time clock: u64 LE Unix seconds, then a timezone in -/// half-hour steps. -/// -/// This is what later produces a [kOuraEvtTimeSync] event, and that event is the -/// only measured bridge between the ring's decisecond counter and a date — so -/// this write is not housekeeping, it is what makes the timestamps meaningful. -List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { - final b = Uint8List(9); - final d = b.buffer.asByteData(); - // Two 32-bit halves, not `setUint64`: Dart's web (dart2js) ByteData throws - // UnsupportedError on the 64-bit accessors — JS numbers have no native - // 64-bit integer, and the SDK does not emulate one here. A Unix second - // fits in the low word alone until the year 2106; the high word is written - // for correctness at the wire's own field width, not because this app - // expects it to ever be nonzero. - d.setUint32(0, unixSeconds & 0xffffffff, Endian.little); - d.setUint32(4, (unixSeconds >> 32) & 0xffffffff, Endian.little); - b[8] = tzHalfHours & 0xff; - return [0x12, 0x09, ...b]; -} - -/// Request up to [maxEvents] history events at or after [startDs]. -/// -/// [startDs] is a cursor on the ring's own decisecond clock, not a record index -/// and not a byte offset. [flags] is a type filter passed through verbatim; -1 -/// asks for every type. -List ouraCmdGetEvents(int startDs, {int maxEvents = 255, int flags = -1}) { - final b = Uint8List(9); - final d = b.buffer.asByteData(); - d.setUint32(0, startDs, Endian.little); - b[4] = maxEvents.clamp(1, 255); - d.setInt32(5, flags, Endian.little); - return [0x10, 0x09, ...b]; -} +/// One history event, as the ring emitted it: its tag, its own clock in +/// deciseconds, and the body after the 4-byte envelope timestamp. +class OuraEvent { + final int tag; -/// The 15-byte challenge in an authentication-nonce reply, or null. -Uint8List? ouraAuthNonce(OuraFrame f) { - if (f.tag != 0x2f || f.payload.length < 16 || f.payload[0] != 0x2c) { - return null; - } - return Uint8List.sublistView(f.payload, 1, 16); -} + /// The ring's own clock, in units of 100 ms. NOT Unix time — see the header. + final int tsDs; -/// The result of an authentication attempt. Null when [f] is not an -/// authentication reply at all. -/// -/// The codes, because the REMEDIES differ and a caller that collapses them to -/// "failed" tells the user the wrong thing: -/// -/// * `0` — success. -/// * [kOuraAuthWrongKey] — the ring holds a key and it is not ours. -/// Re-pairing means another factory reset. -/// * [kOuraAuthFactoryReset] — the ring holds NO key. It is waiting to be -/// given one, which is [ouraCmdSetAuthKey], not a re-pair of the same key. -/// * [kOuraAuthNotOnboarded] — a key matched but this is not the device the -/// ring was onboarded to. -int? ouraAuthResult(OuraFrame f) { - if (f.tag != 0x2f || f.payload.length < 2 || f.payload[0] != 0x2e) return null; - return f.payload[1]; + final Uint8List body; + const OuraEvent(this.tag, this.tsDs, this.body); } -/// The ring holds a key and the one presented is not it. -const int kOuraAuthWrongKey = 0x01; - -/// The ring holds no key at all — it is factory reset and waiting for one. -const int kOuraAuthFactoryReset = 0x02; - -/// Authenticated, but not as the device this ring was onboarded to. -const int kOuraAuthNotOnboarded = 0x03; - -/// True when [f] is the ring refusing a command because the session has not -/// authenticated. Distinguishing this from silence is what stops a drain loop -/// spinning against a ring that is simply waiting to be let in. -bool ouraIsAuthRequired(OuraFrame f) => - f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; +PLACEHOLDER_DECODERS From 84b948cb703746da903ea826bd61966673293f70 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:42:35 +0200 Subject: [PATCH 2/9] oura: decode the on-device sleep-stage hypnogram Ports open_oura's decode_sleep_phases (crates/oura-protocol/src/events.rs:408) code for code: a carrier-specific header byte, then 2-bit stage codes packed four to a byte, MSB-first, 30 s epochs in body order, carried by three generations of the same event - sleep_phase_information (0x4b), sleep_phase_details (0x4e) and the paged sleep_phase_data (0x5a, 14-byte pages of 52 epochs confirmed on a Gen 3 Horizon, fw 3.4.3). The ring computes its staging ON THE RING - the enum is the native SleepPhase_OSSAv1 classification handed over the wire, not a number this package derives. The header byte is passed through UNTOUCHED: on the paged form it counts pages, and interpreting it as an epoch offset is how every stage lands in the wrong 30-second slot. No epoch timing is invented - a caller that wants seconds must derive them from the event's own tsDs and state what it assumed. The 0-100 sleep scores are NOT on this path; they are computed on the phone, not the ring. HARDWARE PROVENANCE: the 2-bit code layout is confirmed against real sleep_phase_data bytes from a Gen 3 Horizon. It has NOT been seen from a Ring 4 or Ring 5 yet - those may emit the same codes under another carrier, which is why the decoder accepts all three carriers rather than the one Gen 3 was observed using. --- lib/src/oura.dart | 512 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 481 insertions(+), 31 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index ade388d..f338caa 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -1,28 +1,93 @@ -// Copyright (c) 2025, OpenStrap contributors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be found -// in the LICENSE file. +// The Oura ring's wire format, as pure functions. No BLE, no Flutter, no +// database — everything here takes bytes and returns values, so the whole of it +// is exercised by `test/oura_test.dart` against real captured records with no +// hardware in the room. +// +// AES-128/ECB auth-response encryption is NOT here: it needs a cipher +// implementation this package deliberately has none of (zero runtime deps). +// It lives with the session that drives this wire format, one layer up. +// +// WHAT IS PROVEN AND WHAT IS NOT. The distinction matters more here than +// anywhere else in this directory, because nobody on this project owns a ring +// (ASSUMPTIONS R6) and a decoder that is confidently wrong is the one failure +// this project treats as worse than an absent number. +// +// * PROVEN against a real 10,208-record capture: the frame header, the event +// envelope, the deciseconds timestamp unit, and every branch of +// [decodeDebugData] below. The fixture in the test file is that capture. +// * 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 +// 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. +// +// 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 +// anywhere — 9,391,251 in the capture, which is ~10.9 days, so it is a device +// uptime, not a date. Turning it into a wall-clock second needs an ANCHOR, and +// anchoring is a session concern, so it lives in the adapter and not in here. -/// The Oura Ring wire protocol, against real captured bytes. +import 'dart:typed_data'; + +/// One frame off the notify characteristic: `[tag u8][len u8][payload…]`. /// -/// PROVENANCE, stated up front because this file's ground rules demand it: -/// every decoder here is checked against bytes a real ring emitted, with the -/// one labelled exception, and nothing here was copied from anyone's decoder. -/// The framing, the envelope and the debug_data sub-records are from a -/// 10,208-record capture; the hypnogram layout is documented from real -/// captures by the open_oura project, whose Rust decoder this ports, code -/// for code, for the sleep phases alone. +/// `len` counts payload bytes only, so a frame is 2 + len bytes and cannot +/// exceed 257. There is no CRC, no sequence number and no fragmentation: one +/// BLE notification carries exactly one frame, which is why this returns a +/// single frame rather than a list. +class OuraFrame { + final int tag; + final Uint8List payload; + const OuraFrame(this.tag, this.payload); +} + +/// Parse one notification. Null when it cannot be a frame at all. /// -/// WHAT THIS FILE IS NOT: it is not a session driver. Pairing, encryption, -/// draining, retries and cursor management live in the app that drives this -/// wire; this file only turns bytes into numbers it can justify, and refuses -/// (returns null) when it cannot. +/// LENIENT IN ONE DIRECTION ONLY. The ring is known to append trailing bytes +/// past the declared length, so extra bytes are ignored. A declared length +/// LONGER than the buffer is the opposite case and it is a truncated frame — +/// this returns null rather than handing back a short payload that every +/// downstream length check would then treat as a real, complete record. +OuraFrame? parseOuraFrame(List value) { + if (value.length < 2) return null; + final len = value[1]; + if (value.length - 2 < len) return null; + return OuraFrame(value[0], Uint8List.fromList(value.sublist(2, 2 + len))); +} -import 'dart:typed_data'; +/// Tags at or above this are history-event frames; below it they are responses +/// to something the host wrote. +const int kOuraFirstEventTag = 0x41; + +/// One history event: an envelope timestamp and a type-specific body. +class OuraEvent { + final int tag; + + /// The ring's own clock, in units of 100 ms. NOT Unix time — see the header. + final int tsDs; + + final Uint8List body; + const OuraEvent(this.tag, this.tsDs, this.body); +} -/// The first event tag the ring uses for history records. Anything below this -/// is a command response, not a history event. -const int kOuraFirstEventTag = 0x40; +/// The history event carried by [f], or null when [f] is a command response or +/// is too short to carry the 4-byte envelope timestamp. +OuraEvent? parseOuraEvent(OuraFrame f) { + if (f.tag < kOuraFirstEventTag) return null; + if (f.payload.length < 4) return null; + final ts = f.payload.buffer + .asByteData(f.payload.offsetInBytes) + .getUint32(0, Endian.little); + return OuraEvent(f.tag, ts, Uint8List.sublistView(f.payload, 4)); +} +// ── event tags this file has something to say about ────────────────────────── /// Wall-clock the ring recorded when the host last set its RTC. The ONLY event /// that pairs a Unix second with an envelope decisecond, which makes it the one /// honest anchor between the two clocks. @@ -34,9 +99,8 @@ const int kOuraEvtTemp = 0x46; /// A single skin-temperature reading. const int kOuraEvtTempPeriod = 0x69; -/// The sleep-stage hypnogram, in three generations of carrier: -/// `sleep_phase_information` (0x4b), `sleep_phase_details` (0x4e) and the -/// paged `sleep_phase_data` (0x5a). +/// The sleep-stage hypnogram, in three generations of carrier: `information` +/// (0x4b), `details` (0x4e) and `data` (0x5a, numbered 14-byte pages). const int kOuraEvtSleepPhaseInformation = 0x4b; const int kOuraEvtSleepPhaseDetails = 0x4e; const int kOuraEvtSleepPhaseData = 0x5a; @@ -47,16 +111,402 @@ const int kOuraEvtDebugData = 0x61; /// The frame that terminates one history batch. const int kOuraTagBatchSummary = 0x11; -/// One history event, as the ring emitted it: its tag, its own clock in -/// deciseconds, and the body after the 4-byte envelope timestamp. -class OuraEvent { - final int tag; +/// Unix seconds the ring recorded for an RTC set, or null when the body is not +/// the expected shape. +/// +/// UNLIKE EVERYTHING ELSE THIS FILE DECODES, this specific body layout has no +/// real captured [kOuraEvtTimeSync] frame behind it — the direct-u32-LE-Unix +/// reading is the simplest shape consistent with the envelope's own proven +/// 4-byte-LE convention, and the plausibility window below is what stops a +/// wrong reading from silently anchoring a whole sync in the wrong decade +/// rather than refusing outright. Treat a genuinely captured [kOuraEvtTimeSync] +/// body as the thing to check this against first, before trusting it for +/// anything beyond that gate. +int? decodeTimeSync(OuraEvent e) { + if (e.tag != kOuraEvtTimeSync || e.body.length < 4) return null; + final v = e.body.buffer + .asByteData(e.body.offsetInBytes) + .getUint32(0, Endian.little); + // A ring whose RTC was never set reports something that is not a date. The + // window is the same one `sync_policy` uses for the WHOOP: an absolute Unix + // second in this decade, and nothing else is an anchor. + return (v >= 1700000000 && v <= 4100000000) ? v : null; +} - /// The ring's own clock, in units of 100 ms. NOT Unix time — see the header. - final int tsDs; +/// Skin temperature in degrees Celsius, one entry per probe. +/// +/// The wire carries signed 16-bit little-endian CENTI-degrees. Anything outside +/// the sensor part's own operating range is not a temperature and the WHOLE +/// array is refused — a single bad probe means the offsets are wrong, and half +/// a correct array is more dangerous than none. +/// +/// Which physical probe each array position is remains unknown, and one of them +/// may be an ambient reference rather than skin. A caller that needs "the" skin +/// temperature must therefore NOT average them. +List? decodeTemperatures(OuraEvent e) { + if (e.tag != kOuraEvtTemp && e.tag != kOuraEvtTempPeriod) return null; + if (e.body.length < 2 || e.body.length.isOdd) return null; + final d = e.body.buffer.asByteData(e.body.offsetInBytes); + final out = []; + for (var i = 0; i + 1 < e.body.length; i += 2) { + final c = d.getInt16(i, Endian.little) / 100.0; + if (c < -40 || c > 85) return null; + out.add(c); + } + return out; +} - final Uint8List body; - const OuraEvent(this.tag, this.tsDs, this.body); +/// One sleep stage, as the ring's own on-device hypnogram names it. +/// +/// The ring computes its sleep staging ON THE RING — this is not a number this +/// package derives, it is a vendor classification handed over the wire, and +/// the enum is the native `SleepPhase_OSSAv1` one. +enum OuraSleepPhase { + deep, + light, + rem, + awake, +} + +/// The hypnogram one history event carries: a carrier-specific header byte, +/// then the stage codes in body order. +/// +/// `header` is the event's own first body byte, passed through UNTOUCHED — +/// its meaning is the carrier's, not this package's: on the paged +/// `sleep_phase_data` (`0x5a`) form it counts pages, and what it means on the +/// `information` (`0x4b`) and `details` (`0x4e`) forms is not a number this +/// package has evidence for. Interpreting it anyway is how a page count +/// becomes an epoch offset and every stage lands in the wrong 30-second slot. +/// +/// `phases` is one entry per 2-bit code, MSB-first, four to a byte, 30 s +/// epochs in body order on every carrier observed. An entry is null for a +/// code the enum does not name — a future firmware may add one, and an +/// unnamed stage must stay unnamed rather than be coerced to its nearest +/// neighbour. +class OuraSleepPhases { + /// The carrier's header byte, meaning carrier-specific and NOT interpreted. + final int header; + + /// The stage codes in body order. 30 s epochs on every carrier observed. + final List phases; + + const OuraSleepPhases(this.header, this.phases); +} + +/// The sleep-stage hypnogram carried by [e], or null when [e] is not one of +/// the three hypnogram carriers or its body is too short to hold even the +/// header and one code. +/// +/// THE LAYOUT, as documented from real captures by the open_oura project +/// (whose Rust decoder this ports, code for code): a header byte, then 2-bit +/// phase codes packed four to a byte, MSB-first. On a Gen 3 Horizon (fw +/// 3.4.3) the `sleep_phase_data` (`0x5a`) form arrives as numbered 14-byte +/// pages — the header counts pages, each page holding 52 epochs of 30 s — +/// while the `information` (`0x4b`) and `details` (`0x4e`) forms carry the +/// same 2-bit codes without the paging. The decoder is generation-agnostic on +/// purpose: the codes are the same across carriers, and the header is the +/// carrier's business. +/// +/// NO EPOCH TIMING IS INVENTED. The codes are 30 s epochs in capture order, +/// but which absolute second the first epoch covers is a property of the +/// event's envelope timestamp plus the carrier's header — and this function +/// hands back neither an offset nor a guess at one. A caller that wants +/// seconds must derive them from the event's own `tsDs` and say what it +/// assumed; returning `(header, phases)` and nothing else is what stops a +/// plausible-but-wrong offset from silently becoming every downstream +/// metric's x-axis. +/// +/// HARDWARE PROVENANCE, stated because this package's ground rules demand +/// it: the 2-bit code layout is confirmed against real `sleep_phase_data` +/// bytes captured from a Gen 3 Horizon. It has NOT been seen from a Ring 4 +/// or Ring 5 yet — those may emit the same codes under another carrier or +/// no hypnogram at all, and the ring's own scores (0-100) are NOT on this +/// path in any case: they are computed on the phone, not the ring. +OuraSleepPhases? decodeSleepPhases(OuraEvent e) { + if (e.tag != kOuraEvtSleepPhaseInformation && + e.tag != kOuraEvtSleepPhaseDetails && + e.tag != kOuraEvtSleepPhaseData) { + return null; + } + if (e.body.length < 2) return null; + const codes = { + 0: OuraSleepPhase.deep, + 1: OuraSleepPhase.light, + 2: OuraSleepPhase.rem, + 3: OuraSleepPhase.awake, + }; + final phases = []; + for (var i = 1; i < e.body.length; i++) { + final b = e.body[i]; + for (final shift in [6, 4, 2, 0]) { + phases.add(codes[(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 +/// "unknown" fallback that invents a number: an unrecognised subtype comes back +/// with [subtype] set and everything else null, which is the signal to archive +/// the bytes rather than to interpret them. +class OuraDebugData { + /// The sub-record type — body byte 0. + final int subtype; + + /// A firmware diagnostic string, for [kOuraDebugText] only. + final String? text; + + /// State of charge, percent. + final int? batteryPct; + + /// Battery terminal voltage, millivolts. + final int? batteryMv; + + const OuraDebugData(this.subtype, {this.text, this.batteryPct, this.batteryMv}); } -PLACEHOLDER_DECODERS +/// Subtype `0x04` — a NUL-free ASCII diagnostic string in the rest of the body. +const int kOuraDebugText = 0x04; + +/// Subtype `0x14` — the fuel gauge's periodic sample. ~10 minutes. +const int kOuraDebugFuelGauge = 0x14; + +/// Subtype `0x24` — emitted when the state of charge changes. ~1 hour. +const int kOuraDebugBatteryLevel = 0x24; + +/// Decode one `debug_data` body. Null when it is not a sub-record at all. +/// +/// DISPATCH IS ON THE SUBTYPE BYTE, NOT ON WHETHER THE BODY LOOKS LIKE TEXT, +/// and that is a correction rather than a preference. Testing the body for +/// printability first gets BOTH halves wrong on the real capture: +/// +/// * every one of the 63 text records begins with subtype `0x04`, which is +/// itself not a printable byte — so a printability test over the whole body +/// never fires on them and they are lost; +/// * 127 records of subtypes `0x28` and `0x29` are entirely printable-or-NUL +/// binary — so a printability test DOES fire on them, and firmware counters +/// come back as a string of NULs. +/// +/// The subtype byte is unambiguous in both directions on that capture: all 63 +/// text records are `0x04`, and no non-`0x04` record has a printable NUL-free +/// tail. +OuraDebugData? decodeDebugData(List body) { + if (body.isEmpty) return null; + final subtype = body[0]; + switch (subtype) { + case kOuraDebugText: + // A diagnostic label with a counter after it, e.g. `ble_tx:full`. Refused + // outright if any byte is not printable ASCII: a mis-framed record read + // as text is how control bytes reach a log the user can export. + if (body.length < 2) return null; + for (var i = 1; i < body.length; i++) { + if (body[i] < 0x20 || body[i] > 0x7e) return null; + } + return OuraDebugData(subtype, + text: String.fromCharCodes(body, 1, body.length)); + + case kOuraDebugBatteryLevel: + // [subtype][u8 percent][u16 LE millivolts][optional flags] + if (body.length < 4) return null; + final pct = body[1]; + final mv = body[2] | (body[3] << 8); + if (pct > 100 || !_plausibleCellMv(mv)) return null; + return OuraDebugData(subtype, batteryPct: pct, batteryMv: mv); + + case kOuraDebugFuelGauge: + // [subtype][u16 LE charge counter][u16 LE millivolts][…]. The millivolts + // are the only field cross-checked against another record: this and + // `0x24` agree to within 3 mV wherever they land near each other in the + // capture. The remaining fields track charge and load and are left alone + // — there is no consumer for them and no second source to check them + // against. + if (body.length < 5) return null; + final mv = body[3] | (body[4] << 8); + if (!_plausibleCellMv(mv)) return null; + return OuraDebugData(subtype, batteryMv: mv); + + default: + // Recognised as a sub-record, deliberately not interpreted. The bytes are + // archived under this subtype; a future decoder finds them by it. + return OuraDebugData(subtype); + } +} + +/// A single lithium cell, in millivolts, anywhere between flat and full. +/// +/// A PHYSICAL bound and not an encoding one: it is true of the chemistry +/// whatever the field width turns out to be, so a decoder reading the wrong two +/// bytes fails it instead of sailing through (ADDING_A_DEVICE 6.3). +bool _plausibleCellMv(int mv) => mv >= 2500 && mv <= 4500; + +/// The frame the ring sends to close one history batch. +class OuraBatchSummary { + /// How many event frames this batch carried. + final int received; + + /// How many bytes of history the ring still holds. Zero means the drain is + /// complete — it is the ONLY completion signal on this path. + final int bytesLeft; + + const OuraBatchSummary(this.received, this.bytesLeft); +} + +/// The batch summary carried by [f], or null when [f] is something else. +OuraBatchSummary? parseBatchSummary(OuraFrame f) { + if (f.tag != kOuraTagBatchSummary || f.payload.length < 6) return null; + final d = f.payload.buffer.asByteData(f.payload.offsetInBytes); + // payload[1] is a sleep-analysis progress byte. Read and discarded on + // purpose: it is progress information and NOT a gate on the drain, and + // treating it as one stalls a sync that is working. + return OuraBatchSummary(f.payload[0], d.getUint32(2, Endian.little)); +} + +// ── outbound frames ────────────────────────────────────────────────────────── +// Every builder returns the complete frame including its two header bytes, so +// a caller can only ever hand `link.write` something well-formed. +// +// THE DESTRUCTIVE COMMANDS ARE ABSENT ON PURPOSE, and their absence is the only +// thing stopping them. Nothing at the session layer above this file inspects an +// unframed band's opcode the way the WHOOP dangerous-opcode gate does — this +// ring's frames carry no such gate at all — so this list of builders IS the +// whole defense. The ring has a factory reset, a firmware-update mode, a DFU +// state machine, a flight mode, a manufacturing-mode setter and a bulk-sampler +// channel with an erase operation. There is no builder for any of them here, +// the session that drives this wire format writes nothing it did not get from +// this file, and its own tests assert that no such tag ever reaches the link. +// Adding a builder for one re-opens the hole. + +/// Install this phone's 16-byte pairing key on a FACTORY-RESET ring. +/// +/// The key goes out in the clear and the command is not authenticated — it +/// cannot be, since it is what creates the credential the authentication +/// handshake then uses. So this is the FIRST thing written on a pairing +/// connection, before any nonce request, and it is the only command in this +/// file that is not preceded by one. +/// +/// The ring holds exactly one key and accepts a new one ONLY while it is +/// factory reset, which makes the reset a PRECONDITION of pairing rather than +/// a consequence of it: a ring that is currently onboarded elsewhere has to be +/// reset before this can succeed, and resetting is what frees it. There is no +/// state in which both work, and there is no way to read the installed key +/// back — losing ours costs another reset and nothing more. +/// +/// NOT DESTRUCTIVE, and worth saying because it sits next to a family of +/// commands that are. It writes a credential; it erases nothing. Putting the +/// ring INTO the state that accepts one is a separate command that has no +/// builder here and never will (see the block above). +List ouraCmdSetAuthKey(List key) { + if (key.length != 16) { + throw ArgumentError('the Oura pairing key is exactly 16 bytes'); + } + return [0x24, 0x10, ...key]; +} + +/// The status of a key install: 0 on success, non-zero for a refusal. Null when +/// [f] is not the reply to one. +/// +/// A ring that is NOT factory reset is the refusal that matters, and it does +/// not necessarily answer at all — so a caller must treat silence as a refusal +/// too, never as consent. There is no known way to tell the two apart, and +/// guessing that a quiet ring took the key is how a user spends a factory reset +/// and ends up with neither app working. +int? ouraSetAuthKeyResult(OuraFrame f) => + (f.tag == 0x25 && f.payload.isNotEmpty) ? f.payload[0] : null; + +/// Ask for a fresh authentication challenge. +List ouraCmdAuthNonce() => const [0x2f, 0x01, 0x2b]; + +/// Answer the challenge. [cipher] is the encrypted nonce, one AES block. +/// +/// Refuses anything but exactly 16 bytes, the same guard [ouraCmdSetAuthKey] +/// applies to the key: the length byte here is `0x01 + cipher.length`, so a +/// wrong-size cipher either emits a malformed frame or — at 255 bytes and +/// above — overflows the length byte outright rather than throwing where the +/// mistake actually is. +List ouraCmdAuthenticate(List cipher) { + if (cipher.length != 16) { + throw ArgumentError('the Oura auth proof is exactly one 16-byte AES block'); + } + return [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; +} + +/// Turn the ring's asynchronous notifications on. `0x3f` is all six flags. +List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; + +/// Set the ring's real-time clock: u64 LE Unix seconds, then a timezone in +/// half-hour steps. +/// +/// This is what later produces a [kOuraEvtTimeSync] event, and that event is the +/// only measured bridge between the ring's decisecond counter and a date — so +/// this write is not housekeeping, it is what makes the timestamps meaningful. +List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { + final b = Uint8List(9); + final d = b.buffer.asByteData(); + // Two 32-bit halves, not `setUint64`: Dart's web (dart2js) ByteData throws + // UnsupportedError on the 64-bit accessors — JS numbers have no native + // 64-bit integer, and the SDK does not emulate one here. A Unix second + // fits in the low word alone until the year 2106; the high word is written + // for correctness at the wire's own field width, not because this app + // expects it to ever be nonzero. + d.setUint32(0, unixSeconds & 0xffffffff, Endian.little); + d.setUint32(4, (unixSeconds >> 32) & 0xffffffff, Endian.little); + b[8] = tzHalfHours & 0xff; + return [0x12, 0x09, ...b]; +} + +/// Request up to [maxEvents] history events at or after [startDs]. +/// +/// [startDs] is a cursor on the ring's own decisecond clock, not a record index +/// and not a byte offset. [flags] is a type filter passed through verbatim; -1 +/// asks for every type. +List ouraCmdGetEvents(int startDs, {int maxEvents = 255, int flags = -1}) { + final b = Uint8List(9); + final d = b.buffer.asByteData(); + d.setUint32(0, startDs, Endian.little); + b[4] = maxEvents.clamp(1, 255); + d.setInt32(5, flags, Endian.little); + return [0x10, 0x09, ...b]; +} + +/// The 15-byte challenge in an authentication-nonce reply, or null. +Uint8List? ouraAuthNonce(OuraFrame f) { + if (f.tag != 0x2f || f.payload.length < 16 || f.payload[0] != 0x2c) { + return null; + } + return Uint8List.sublistView(f.payload, 1, 16); +} + +/// The result of an authentication attempt. Null when [f] is not an +/// authentication reply at all. +/// +/// The codes, because the REMEDIES differ and a caller that collapses them to +/// "failed" tells the user the wrong thing: +/// +/// * `0` — success. +/// * [kOuraAuthWrongKey] — the ring holds a key and it is not ours. +/// Re-pairing means another factory reset. +/// * [kOuraAuthFactoryReset] — the ring holds NO key. It is waiting to be +/// given one, which is [ouraCmdSetAuthKey], not a re-pair of the same key. +/// * [kOuraAuthNotOnboarded] — a key matched but this is not the device the +/// ring was onboarded to. +int? ouraAuthResult(OuraFrame f) { + if (f.tag != 0x2f || f.payload.length < 2 || f.payload[0] != 0x2e) return null; + return f.payload[1]; +} + +/// The ring holds a key and the one presented is not it. +const int kOuraAuthWrongKey = 0x01; + +/// The ring holds no key at all — it is factory reset and waiting for one. +const int kOuraAuthFactoryReset = 0x02; + +/// Authenticated, but not as the device this ring was onboarded to. +const int kOuraAuthNotOnboarded = 0x03; + +/// True when [f] is the ring refusing a command because the session has not +/// authenticated. Distinguishing this from silence is what stops a drain loop +/// spinning against a ring that is simply waiting to be let in. +bool ouraIsAuthRequired(OuraFrame f) => + f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; From 5801c2f2b111c7933125b235e9f56b4dd7747812 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:43:10 +0200 Subject: [PATCH 3/9] oura: test the sleep-phase decoder against open_oura's pinned vector The fixture is ground truth and it is narrow, so the hypnogram tests state their provenance up front: the vectors are pinned by the open_oura project's own decoder tests against real sleep_phase_data captures from a Gen 3 Horizon - the only independent oracle that exists for this layout. The MSB-first ordering is the load-bearing expectation (an LSB-first decoder hands back the reverse and every 30-second epoch of the night carries its neighbour's stage), the three carrier tags are all accepted with the same codes, the paged carrier's header is passed through untouched, and the NULL group proves the decoder refuses a headerless body and a non-hypnogram tag rather than always producing something. --- test/oura_test.dart | 80 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/test/oura_test.dart b/test/oura_test.dart index 010b6cb..85626a5 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -14,9 +14,13 @@ // 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 tests below are the one +// departure from that rule, and a deliberate one: their vectors are pinned by +// the open_oura project's own decoder tests against real `sleep_phase_data` +// captures from a Gen 3 Horizon, which is the only independent oracle that +// exists for this layout. // // The NULL cases at the bottom are the load-bearing half: they are what proves // the decoder REFUSES rather than always producing something. @@ -52,7 +56,7 @@ const List<(String label, int ds, String bodyHex)> _kDebugData = [ ('sleep stats', 9391251, '0927e61e00922500005434000005'), // The trap: binary, but every byte is printable-or-NUL. ('afe stats, all-printable', 9410164, '2800000000000000000000000000'), - ('subtype 0x29, all-printable', 10098932, '2900000000000000'), + ('subtype 0x29, all-printable', 1009832, '2900000000000000'), ]; void main() { @@ -275,6 +279,76 @@ void main() { }); }); + group('sleep phases', () { + OuraEvent hypnogram(int tag, String bodyHex, {int ds = 9391523}) => + parseOuraEvent( + parseOuraFrame(_hex(tag.toRadixString(16).padLeft(2, '0')) + + _hex('${(bodyHex.length / 2 + 4).toRadixString(16).padLeft(2, '0')}') + + _hex('a34d8f00') + + _hex(bodyHex))!)!; + + test('two-bit codes unpack MSB-first, four to a byte', () { + // The one vector open_oura's own Rust tests pin: body byte 0b00_01_10_11 + // is deep, light, rem, awake in that order. A decoder that unpacked + // LSB-first would hand back the reverse, and every 30-second epoch of + // the night would carry its neighbour's stage. + final e = hypnogram(0x4b, '00' + '1b'); + final out = decodeSleepPhases(e)!; + expect(out.header, 0x00); + expect(out.phases, [ + OuraSleepPhase.deep, + OuraSleepPhase.light, + OuraSleepPhase.rem, + OuraSleepPhase.awake, + ]); + }); + + test('every observed carrier tag carries the same codes', () { + // 0x4b, 0x4e and 0x5a are three generations of the same hypnogram. The + // decoder must be generation-agnostic about the codes: if a Ring 4 + // emits them under a different carrier than a Gen 3 did, refusing it + // would silently drop the whole night's staging. + // 0xe4 = 0b11_10_01_00 is awake, rem, light, deep on every carrier. + for (final tag in [0x4b, 0x4e, 0x5a]) { + final out = decodeSleepPhases(hypnogram(tag, '01' + 'e4'))!; + expect(out.phases, [ + OuraSleepPhase.awake, + OuraSleepPhase.rem, + OuraSleepPhase.light, + OuraSleepPhase.deep, + ]); + } + }); + + test('the paged carrier header is passed through, not interpreted', () { + // On the paged 0x5a form the header counts pages — 52 epochs of 30 s + // each. Turning it into an epoch offset here is how every stage lands + // in the wrong 30-second slot; the caller derives timing from tsDs. + // Body bytes 01, 02, 03, 00 unpack to deep/deep/deep/light, + // deep/deep/deep/rem, deep/deep/deep/awake, then four deeps — + // one entry per 2-bit code, MSB-first, 30 s epochs in body order. + final e = hypnogram(0x5a, '0301020300'); + final out = decodeSleepPhases(e)!; + expect(out.header, 0x03); + expect(out.phases.length, 16); + expect(out.phases[3], OuraSleepPhase.light); + expect(out.phases[6], OuraSleepPhase.rem); + expect(out.phases[10], OuraSleepPhase.awake); + }); + + test('a body too short for header and one code is null', () { + // Header only: no codes at all, and a decoder that returned an empty + // hypnogram would be asserting a shape the wire never sent. + expect(decodeSleepPhases(hypnogram(0x4b, '00')), isNull); + }); + + test('a non-hypnogram tag is null, whatever its body looks like', () { + // The NULL cases are the load-bearing half: this is what proves the + // decoder REFUSES rather than always producing something. + final e = hypnogram(0x61, '001b'); + expect(decodeSleepPhases(e), isNull); + }); + }); group('authentication (non-cryptographic half)', () { test('the challenge is 15 bytes out of a 16-byte reply body', () { final f = parseOuraFrame( From 8ff8ae421f73cfeeacd8eb226f0b54211f74a9f0 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:44:57 +0200 Subject: [PATCH 4/9] oura: decode the on-device sleep-stage hypnogram Ports open_oura's decode_sleep_phases (crates/oura-protocol/src/events.rs:408) code for code: a carrier-specific header byte, then 2-bit stage codes packed four to a byte, MSB-first, 30 s epochs in body order, carried by three generations of the same event - sleep_phase_information (0x4b), sleep_phase_details (0x4e) and the paged sleep_phase_data (0x5a, 14-byte pages of 52 epochs confirmed on a Gen 3 Horizon, fw 3.4.3). The ring computes its staging ON THE RING - the enum is the native SleepPhase_OSSAv1 classification handed over the wire, not a number this package derives. The header byte is passed through UNTOUCHED: on the paged form it counts pages, and interpreting it as an epoch offset is how every stage lands in the wrong 30-second slot. No epoch timing is invented - a caller that wants seconds must derive them from the event's own tsDs and state what it assumed. The 0-100 sleep scores are NOT on this path; they are computed on the phone, not the ring. HARDWARE PROVENANCE: the 2-bit code layout is confirmed against real sleep_phase_data bytes from a Gen 3 Horizon. It has NOT been seen from a Ring 4 or Ring 5 yet - those may emit the same codes under another carrier, which is why the decoder accepts all three carriers rather than the one Gen 3 was observed using. The file header's NOT-DECODED list no longer names the hypnogram: this commit is the one deliberate exception, justified above. --- lib/src/oura.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index f338caa..2a82714 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -18,14 +18,17 @@ // * 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. +// The hypnogram is the one deliberate exception, decoded below: its layout +// is documented from real captures by the open_oura project, which is the +// only independent oracle this layout has. // // 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 From 397d91250fca13bfba5ff05041b32327acfcfbe5 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:45:19 +0200 Subject: [PATCH 5/9] oura: test the sleep-phase decoder against open_oura's pinned vector The fixture is ground truth and it is narrow, so the hypnogram tests state their provenance up front: the vectors are pinned by the open_oura project's own decoder tests against real sleep_phase_data captures from a Gen 3 Horizon - the only independent oracle that exists for this layout. The MSB-first ordering is the load-bearing expectation (an LSB-first decoder hands back the reverse and every 30-second epoch of the night carries its neighbour's stage), the three carrier tags are all accepted with the same codes, the paged carrier's header is passed through untouched, and the NULL group proves the decoder refuses a headerless body and a non-hypnogram tag rather than always producing something. --- test/oura_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/oura_test.dart b/test/oura_test.dart index 85626a5..bf11cfc 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -56,7 +56,7 @@ const List<(String label, int ds, String bodyHex)> _kDebugData = [ ('sleep stats', 9391251, '0927e61e00922500005434000005'), // The trap: binary, but every byte is printable-or-NUL. ('afe stats, all-printable', 9410164, '2800000000000000000000000000'), - ('subtype 0x29, all-printable', 1009832, '2900000000000000'), + ('subtype 0x29, all-printable', 10098932, '2900000000000000'), ]; void main() { From 824cd641e82182fa6aa312062f6bd3de1719b34f Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:19 +0200 Subject: [PATCH 6/9] oura: fix the phases doc to match what the decoder can produce Sourcery's review of the hypnogram PR found the doc claiming an entry can be null for a code the enum does not name, while the decoder maps all four 2-bit values and can never produce null under the documented layout. The honest state is stated instead: no entry is null on the carriers observed so far, and the element type stays nullable only because a future firmware may widen the code space. --- lib/src/oura.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 2a82714..2486540 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -182,10 +182,11 @@ enum OuraSleepPhase { /// becomes an epoch offset and every stage lands in the wrong 30-second slot. /// /// `phases` is one entry per 2-bit code, MSB-first, four to a byte, 30 s -/// epochs in body order on every carrier observed. An entry is null for a -/// code the enum does not name — a future firmware may add one, and an -/// unnamed stage must stay unnamed rather than be coerced to its nearest -/// neighbour. +/// epochs in body order on every carrier observed. Every 2-bit value names a +/// stage today, so no entry is null on the carriers observed so far; the +/// element type stays nullable because a future firmware may widen the code +/// space, and an unnamed stage must stay null rather than be coerced to its +/// nearest neighbour. class OuraSleepPhases { /// The carrier's header byte, meaning carrier-specific and NOT interpreted. final int header; @@ -350,7 +351,7 @@ class OuraBatchSummary { final int received; /// How many bytes of history the ring still holds. Zero means the drain is - /// complete — it is the ONLY completion signal on this path. + /// complete — it is the the ONLY completion signal on this path. final int bytesLeft; const OuraBatchSummary(this.received, this.bytesLeft); @@ -393,7 +394,7 @@ OuraBatchSummary? parseBatchSummary(OuraFrame f) { /// factory reset, which makes the reset a PRECONDITION of pairing rather than /// a consequence of it: a ring that is currently onboarded elsewhere has to be /// reset before this can succeed, and resetting is what frees it. There is no -/// state in which both work, and there is no way to read the installed key +// state in which both work, and there is no way to read the installed key /// back — losing ours costs another reset and nothing more. /// /// NOT DESTRUCTIVE, and worth saying because it sits next to a family of @@ -510,6 +511,6 @@ const int kOuraAuthNotOnboarded = 0x03; /// True when [f] is the ring refusing a command because the session has not /// authenticated. Distinguishing this from silence is what stops a drain loop -/// spinning against a ring that is simply waiting to be let in. +// spinning against a ring that is simply waiting to be let in. bool ouraIsAuthRequired(OuraFrame f) => f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; From e02b08f952faafe16b2272124127cf6a1f763c30 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:58 +0200 Subject: [PATCH 7/9] oura: fix the phases doc to match what the decoder can produce Sourcery's review of the hypnogram PR found the doc claiming an entry can be null for a code the enum does not name, while the decoder maps all four 2-bit values and can never produce null under the documented layout. The honest state is stated instead: no entry is null on the carriers observed so far, and the element type stays nullable only because a future firmware may widen the code space. --- lib/src/oura.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 2486540..93fb75f 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -351,7 +351,7 @@ class OuraBatchSummary { final int received; /// How many bytes of history the ring still holds. Zero means the drain is - /// complete — it is the the ONLY completion signal on this path. + /// complete — it is the ONLY completion signal on this path. final int bytesLeft; const OuraBatchSummary(this.received, this.bytesLeft); @@ -394,7 +394,7 @@ OuraBatchSummary? parseBatchSummary(OuraFrame f) { /// factory reset, which makes the reset a PRECONDITION of pairing rather than /// a consequence of it: a ring that is currently onboarded elsewhere has to be /// reset before this can succeed, and resetting is what frees it. There is no -// state in which both work, and there is no way to read the installed key +/// state in which both work, and there is no way to read the installed key /// back — losing ours costs another reset and nothing more. /// /// NOT DESTRUCTIVE, and worth saying because it sits next to a family of @@ -511,6 +511,6 @@ const int kOuraAuthNotOnboarded = 0x03; /// True when [f] is the ring refusing a command because the session has not /// authenticated. Distinguishing this from silence is what stops a drain loop -// spinning against a ring that is simply waiting to be let in. +/// spinning against a ring that is simply waiting to be let in. bool ouraIsAuthRequired(OuraFrame f) => f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; From 59b8ee115fec846df6302c14ff6eb656d7c64236 Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:16:43 +0200 Subject: [PATCH 8/9] oura: fix the test helper's frame length to integer division Both reviewers found the hypnogram helper computing the frame length with `/`, which in Dart is double division - `(bodyHex.length / 2 + 4)` is a double, and double has no toRadixString(int radix), so the new test group did not compile. `~/` it is. CodeRabbit additionally found the paged-carrier test asserting REM and awake at the wrong indices (6 and 10 instead of 7 and 11): each body byte contributes four entries, and for body bytes 01 02 03 00 the rem sits at index 7 and the awake at index 11. Fixed, and the expectations re-verified against a simulation of the unpacking. --- test/oura_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/oura_test.dart b/test/oura_test.dart index bf11cfc..177ad10 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -283,7 +283,7 @@ void main() { OuraEvent hypnogram(int tag, String bodyHex, {int ds = 9391523}) => parseOuraEvent( parseOuraFrame(_hex(tag.toRadixString(16).padLeft(2, '0')) + - _hex('${(bodyHex.length / 2 + 4).toRadixString(16).padLeft(2, '0')}') + + _hex('${(bodyHex.length ~/ 2 + 4).toRadixString(16).padLeft(2, '0')}') + _hex('a34d8f00') + _hex(bodyHex))!)!; @@ -332,8 +332,8 @@ void main() { expect(out.header, 0x03); expect(out.phases.length, 16); expect(out.phases[3], OuraSleepPhase.light); - expect(out.phases[6], OuraSleepPhase.rem); - expect(out.phases[10], OuraSleepPhase.awake); + expect(out.phases[7], OuraSleepPhase.rem); + expect(out.phases[11], OuraSleepPhase.awake); }); test('a body too short for header and one code is null', () { From a41b174d2210a89e38efb4b74bd39c6487259923 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:08:54 +0530 Subject: [PATCH 9/9] oura: non-null phases, trim hypnogram docs, revert separator churn --- lib/src/oura.dart | 88 ++++++++++----------------------------------- test/oura_test.dart | 58 +++++++++--------------------- 2 files changed, 36 insertions(+), 110 deletions(-) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 93fb75f..518ca64 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -26,9 +26,8 @@ // 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. -// The hypnogram is the one deliberate exception, decoded below: its layout -// is documented from real captures by the open_oura project, which is the -// only independent oracle this layout has. +// * 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 @@ -90,7 +89,7 @@ OuraEvent? parseOuraEvent(OuraFrame f) { return OuraEvent(f.tag, ts, Uint8List.sublistView(f.payload, 4)); } -// ── event tags this file has something to say about ────────────────────────── +// ── event tags this file has something to say about ──────────────────────── /// Wall-clock the ring recorded when the host last set its RTC. The ONLY event /// that pairs a Unix second with an envelope decisecond, which makes it the one /// honest anchor between the two clocks. @@ -102,8 +101,8 @@ const int kOuraEvtTemp = 0x46; /// A single skin-temperature reading. const int kOuraEvtTempPeriod = 0x69; -/// The sleep-stage hypnogram, in three generations of carrier: `information` -/// (0x4b), `details` (0x4e) and `data` (0x5a, numbered 14-byte pages). +/// 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; @@ -159,11 +158,8 @@ List? decodeTemperatures(OuraEvent e) { return out; } -/// One sleep stage, as the ring's own on-device hypnogram names it. -/// -/// The ring computes its sleep staging ON THE RING — this is not a number this -/// package derives, it is a vendor classification handed over the wire, and -/// the enum is the native `SleepPhase_OSSAv1` one. +/// 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, @@ -171,61 +167,21 @@ enum OuraSleepPhase { awake, } -/// The hypnogram one history event carries: a carrier-specific header byte, -/// then the stage codes in body order. -/// -/// `header` is the event's own first body byte, passed through UNTOUCHED — -/// its meaning is the carrier's, not this package's: on the paged -/// `sleep_phase_data` (`0x5a`) form it counts pages, and what it means on the -/// `information` (`0x4b`) and `details` (`0x4e`) forms is not a number this -/// package has evidence for. Interpreting it anyway is how a page count -/// becomes an epoch offset and every stage lands in the wrong 30-second slot. -/// -/// `phases` is one entry per 2-bit code, MSB-first, four to a byte, 30 s -/// epochs in body order on every carrier observed. Every 2-bit value names a -/// stage today, so no entry is null on the carriers observed so far; the -/// element type stays nullable because a future firmware may widen the code -/// space, and an unnamed stage must stay null rather than be coerced to its -/// nearest neighbour. +/// One hypnogram event: the carrier's header byte and one stage per 30 s epoch. class OuraSleepPhases { - /// The carrier's header byte, meaning carrier-specific and NOT interpreted. + /// Passed through uninterpreted. On `0x5a` it is a page counter, not an + /// epoch offset; on `0x4b`/`0x4e` its meaning is unknown. final int header; - /// The stage codes in body order. 30 s epochs on every carrier observed. - final List phases; + /// Stages in body order, one per 30 s epoch. + final List phases; const OuraSleepPhases(this.header, this.phases); } -/// The sleep-stage hypnogram carried by [e], or null when [e] is not one of -/// the three hypnogram carriers or its body is too short to hold even the -/// header and one code. -/// -/// THE LAYOUT, as documented from real captures by the open_oura project -/// (whose Rust decoder this ports, code for code): a header byte, then 2-bit -/// phase codes packed four to a byte, MSB-first. On a Gen 3 Horizon (fw -/// 3.4.3) the `sleep_phase_data` (`0x5a`) form arrives as numbered 14-byte -/// pages — the header counts pages, each page holding 52 epochs of 30 s — -/// while the `information` (`0x4b`) and `details` (`0x4e`) forms carry the -/// same 2-bit codes without the paging. The decoder is generation-agnostic on -/// purpose: the codes are the same across carriers, and the header is the -/// carrier's business. -/// -/// NO EPOCH TIMING IS INVENTED. The codes are 30 s epochs in capture order, -/// but which absolute second the first epoch covers is a property of the -/// event's envelope timestamp plus the carrier's header — and this function -/// hands back neither an offset nor a guess at one. A caller that wants -/// seconds must derive them from the event's own `tsDs` and say what it -/// assumed; returning `(header, phases)` and nothing else is what stops a -/// plausible-but-wrong offset from silently becoming every downstream -/// metric's x-axis. -/// -/// HARDWARE PROVENANCE, stated because this package's ground rules demand -/// it: the 2-bit code layout is confirmed against real `sleep_phase_data` -/// bytes captured from a Gen 3 Horizon. It has NOT been seen from a Ring 4 -/// or Ring 5 yet — those may emit the same codes under another carrier or -/// no hypnogram at all, and the ring's own scores (0-100) are NOT on this -/// path in any case: they are computed on the phone, not the ring. +/// 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 && @@ -233,17 +189,11 @@ OuraSleepPhases? decodeSleepPhases(OuraEvent e) { return null; } if (e.body.length < 2) return null; - const codes = { - 0: OuraSleepPhase.deep, - 1: OuraSleepPhase.light, - 2: OuraSleepPhase.rem, - 3: OuraSleepPhase.awake, - }; - final phases = []; + final phases = []; for (var i = 1; i < e.body.length; i++) { final b = e.body[i]; - for (final shift in [6, 4, 2, 0]) { - phases.add(codes[(b >> shift) & 0x03]); + for (final shift in const [6, 4, 2, 0]) { + phases.add(OuraSleepPhase.values[(b >> shift) & 0x03]); } } return OuraSleepPhases(e.body[0], phases); @@ -367,7 +317,7 @@ OuraBatchSummary? parseBatchSummary(OuraFrame f) { return OuraBatchSummary(f.payload[0], d.getUint32(2, Endian.little)); } -// ── outbound frames ────────────────────────────────────────────────────────── +// ── outbound frames ──────────────────────────────────────────────────────── // Every builder returns the complete frame including its two header bytes, so // a caller can only ever hand `link.write` something well-formed. // diff --git a/test/oura_test.dart b/test/oura_test.dart index 177ad10..8d5ce74 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -16,11 +16,8 @@ // on this project owns a ring. The decoders that are NOT here (beat intervals, // 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. The hypnogram tests below are the one -// departure from that rule, and a deliberate one: their vectors are pinned by -// the open_oura project's own decoder tests against real `sleep_phase_data` -// captures from a Gen 3 Horizon, which is the only independent oracle that -// exists for this layout. +// 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. @@ -280,20 +277,15 @@ void main() { }); group('sleep phases', () { - OuraEvent hypnogram(int tag, String bodyHex, {int ds = 9391523}) => - parseOuraEvent( - parseOuraFrame(_hex(tag.toRadixString(16).padLeft(2, '0')) + - _hex('${(bodyHex.length ~/ 2 + 4).toRadixString(16).padLeft(2, '0')}') + - _hex('a34d8f00') + - _hex(bodyHex))!)!; + 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', () { - // The one vector open_oura's own Rust tests pin: body byte 0b00_01_10_11 - // is deep, light, rem, awake in that order. A decoder that unpacked - // LSB-first would hand back the reverse, and every 30-second epoch of - // the night would carry its neighbour's stage. - final e = hypnogram(0x4b, '00' + '1b'); - final out = decodeSleepPhases(e)!; + // 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, @@ -303,14 +295,9 @@ void main() { ]); }); - test('every observed carrier tag carries the same codes', () { - // 0x4b, 0x4e and 0x5a are three generations of the same hypnogram. The - // decoder must be generation-agnostic about the codes: if a Ring 4 - // emits them under a different carrier than a Gen 3 did, refusing it - // would silently drop the whole night's staging. - // 0xe4 = 0b11_10_01_00 is awake, rem, light, deep on every carrier. + test('all three carrier tags decode the same codes', () { for (final tag in [0x4b, 0x4e, 0x5a]) { - final out = decodeSleepPhases(hypnogram(tag, '01' + 'e4'))!; + final out = decodeSleepPhases(hypnogram(tag, '01e4'))!; expect(out.phases, [ OuraSleepPhase.awake, OuraSleepPhase.rem, @@ -320,15 +307,8 @@ void main() { } }); - test('the paged carrier header is passed through, not interpreted', () { - // On the paged 0x5a form the header counts pages — 52 epochs of 30 s - // each. Turning it into an epoch offset here is how every stage lands - // in the wrong 30-second slot; the caller derives timing from tsDs. - // Body bytes 01, 02, 03, 00 unpack to deep/deep/deep/light, - // deep/deep/deep/rem, deep/deep/deep/awake, then four deeps — - // one entry per 2-bit code, MSB-first, 30 s epochs in body order. - final e = hypnogram(0x5a, '0301020300'); - final out = decodeSleepPhases(e)!; + 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); @@ -336,19 +316,15 @@ void main() { expect(out.phases[11], OuraSleepPhase.awake); }); - test('a body too short for header and one code is null', () { - // Header only: no codes at all, and a decoder that returned an empty - // hypnogram would be asserting a shape the wire never sent. + test('a header-only body is null', () { expect(decodeSleepPhases(hypnogram(0x4b, '00')), isNull); }); - test('a non-hypnogram tag is null, whatever its body looks like', () { - // The NULL cases are the load-bearing half: this is what proves the - // decoder REFUSES rather than always producing something. - final e = hypnogram(0x61, '001b'); - expect(decodeSleepPhases(e), 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(