Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0ac8bc8
Oura: fix #91 on the clean stack - client-managed resume cursor, drai…
pipiche38 Jul 11, 2026
576b7bf
Oura: sleep-phase corrections - stage mapping, PK-collapse fix, arriv…
pipiche38 Jul 11, 2026
9258cab
Oura: send SyncTime (0x12, u64-LE layout) on connect - anchor the sho…
pipiche38 Jul 12, 2026
66f6575
Merge origin/main into oura-sleep-phase-fixes
pipiche38 Jul 12, 2026
0e5bf1a
Oura: hypnogram time-axis reconstruction - 30s/code backward from bur…
pipiche38 Jul 12, 2026
11d1b91
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 12, 2026
cebde9d
Oura PR #325 review: Android stage-mapping parity, doc fix, no wall-c…
pipiche38 Jul 12, 2026
2bbdaa4
Oura: align the history drain with open_oura drain_events - fix the s…
pipiche38 Jul 12, 2026
3062a02
Oura: 0x71 fixture-capture logging + self-chained drain passes (reboo…
pipiche38 Jul 12, 2026
7e1b3a3
Oura: ingest ringverse/protocol - 0x71 candidate decoder, 0x49 window…
pipiche38 Jul 12, 2026
71ca827
Oura: deterministic anchor from the 0x13 SyncTime response - fix the …
pipiche38 Jul 13, 2026
ae4740d
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 13, 2026
0f6313f
Oura: anchor the hypnogram burst end at the 0x49 true sleep end (writ…
pipiche38 Jul 13, 2026
9ccf036
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 13, 2026
2f52d9f
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 13, 2026
cae2847
Oura Android twin: port the full Swift protocol stack (parity contrac…
pipiche38 Jul 13, 2026
0fb1b22
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 13, 2026
b82d87f
Merge remote-tracking branch 'origin/main' into oura-hypnogram-recons…
pipiche38 Jul 14, 2026
a3b6e32
Merge remote-tracking branch 'origin/main' into oura-sleep-stage-time…
pipiche38 Jul 14, 2026
02526ad
feat(oura): persist reconstructed hypnogram as a ring-provided sleep …
pipiche38 Jul 14, 2026
fbf2b00
Merge remote-tracking branch 'origin/main' into oura-sleep-stage-time…
pipiche38 Jul 16, 2026
97cd3b3
Merge branch 'main' into oura-sleep-stage-timeline-persist
pipiche38 Jul 16, 2026
28205c0
Oura: pair each hypnogram burst with its OWN 0x49 window (multi-window)
pipiche38 Jul 17, 2026
c71bd75
Merge remote-tracking branch 'origin/main' into oura-sleep-stage-time…
pipiche38 Jul 17, 2026
3939c69
Test Centre: attach Oura ring diagnostics JSONL to the report bundle
pipiche38 Jul 18, 2026
6d76ea9
Test Centre: don't inline large raw streams in the report review shee…
pipiche38 Jul 18, 2026
9d80116
Merge remote-tracking branch 'origin/main' into oura-sleep-stage-time…
pipiche38 Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,19 @@ public enum OuraCommands {

// MARK: - Time sync

/// SyncTime: `12 09 <token:1> <counter:3 LE> 00 00 00 00 f6` where counter = floor(unix_s / 256)
/// and the trailer 0xf6 is fixed. Per OURA_PROTOCOL.md s5.4. `token` defaults to 0.
public static func syncTime(unixSeconds: Int, token: UInt8 = 0x00) -> OuraCommand {
let counter = unixSeconds / 256
let c0 = UInt8(counter & 0xFF)
let c1 = UInt8((counter >> 8) & 0xFF)
let c2 = UInt8((counter >> 16) & 0xFF)
return OuraCommand(label: "sync_time",
bytes: [0x12, 0x09, token, c0, c1, c2, 0x00, 0x00, 0x00, 0x00, 0xF6])
/// SyncTime (`0x12`): hand the ring the current wall-clock so it can emit a usable `0x42` UTC anchor
/// (§5.5). Layout `12 09 <unix_secs: u64 LE (8 B)> <tz: i8 half-hours>` — unix **seconds**, 8-byte
/// little-endian, one signed timezone byte in 30-minute units. Matches the authoritative open_oura
/// `req_sync_time(secs, 0)` ([oura-proto]/[oura-link], OURA_PROTOCOL.md §5.4/§9.2). Supersedes an
/// earlier reverse-engineered guess (`token` + `unix_s/256` in 3 bytes + `0xF6` trailer) that did NOT
/// match the native client. `tzHalfHours` defaults to 0 (UTC), exactly as the reference client sends;
/// NOOP does its own LOCAL-day bucketing downstream regardless of what the ring is told here.
/// On-device proven (2026-07-08..10): sending this on connect makes the ring emit the 0x42 anchor.
public static func syncTime(unixSeconds: Int, tzHalfHours: Int8 = 0) -> OuraCommand {
let secs = UInt64(bitPattern: Int64(unixSeconds))
var body: [UInt8] = (0..<8).map { UInt8((secs >> (UInt64($0) * 8)) & 0xFF) } // u64 seconds, LE
body.append(UInt8(bitPattern: tzHalfHours)) // i8 tz (30-min units)
return OuraCommand(label: "sync_time", bytes: [0x12, UInt8(body.count)] + body)
}

// MARK: - Event fetch (cursor)
Expand Down
44 changes: 42 additions & 2 deletions Packages/OuraProtocol/Sources/OuraProtocol/Decoders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,45 @@ public enum OuraDecoders {
return out.isEmpty ? nil : out
}

// MARK: - Green IBI + amplitude CANDIDATE decode (0x71; s6.2, upstream #287)

/// CANDIDATE decode of the 0x71 green_ibi_and_amp_event, ported verbatim from ringverse's
/// `p_green_ibi_and_amp` (parse.js, firmware @0x503960): 5 densely bit-packed 11-bit IBIs +
/// amplitudes (7-bit mantissa << shift), first entry amplitude-only (`ibiMs == 0`), timestamps
/// walking backward from the event time by each IBI in turn (caller's job — entries are returned
/// in that walk order). `shift` comes from payload[13] bits [2:0] (`s == 7 → 0`, else `s+1`);
/// bit [3] set means a firmware-layout mismatch → nil (never guess).
///
/// TIER-B (#287): this layout has NO verified NOOP capture yet — the result is for the 0x71
/// fixture-capture log ONLY (side-by-side with the raw bytes, cross-checked against concurrent
/// live-HR R-R), never a stored rrInterval. Promote only after a real capture validates it.
/// Payload indexing: ringverse's `b[i]` spans the whole frame (type/len/rt4/body); our `payload`
/// starts at spec offset 6, so `b[i] == payload[i-6]`. Strict 14-byte gate (= wire len 18).
public static func decodeGreenIBIAmpCandidate(payload p: [UInt8], ringTimestamp: UInt32)
-> (shift: Int, samples: [OuraIBI])? {
guard p.count == 14 else { return nil }
let b13 = Int(p[13])
guard (b13 >> 3) & 1 == 0 else { return nil } // reserved bit set → firmware mismatch
let s = b13 & 7
let shift = (s == 7) ? 0 : s + 1
let b12 = Int(p[12])
// Each 11-bit IBI: 1 low bit (an amplitude byte's LSB) | 8 mid bits (a full byte << 3)
// | 2 bits [2:1] from the pack bytes. Ordering per the firmware's scrambled layout.
let ds = [
(Int(p[10]) & 1) | (Int(p[4]) << 3) | ((b13 >> 5) & 6),
(Int(p[9]) & 1) | (Int(p[3]) << 3) | ((b12 & 3) << 1),
(Int(p[8]) & 1) | (Int(p[2]) << 3) | ((b12 >> 1) & 6),
(Int(p[7]) & 1) | (Int(p[1]) << 3) | ((b12 >> 3) & 6),
(Int(p[6]) & 1) | (Int(p[0]) << 3) | ((b12 >> 5) & 6),
]
let amps = (6...10).map { (Int(p[$0]) >> 1) << shift }
var samples = [OuraIBI(ringTimestamp: ringTimestamp, ibiMs: 0, amplitude: amps[0])]
for i in 0..<5 {
samples.append(OuraIBI(ringTimestamp: ringTimestamp, ibiMs: ds[i], amplitude: amps[i]))
}
return (shift, samples)
}

// MARK: - Green IBI quality, 2 bytes/sample (0x80; s6.4)

/// Decode the 0x80 green_ibi_quality_event: per 2-byte sample `ibi_ms = (b1 & 7) | (b0 << 3)`
Expand Down Expand Up @@ -345,8 +384,9 @@ public enum OuraDecoders {
// MARK: - Sleep phase, 2-bit codes (0x4E / 0x5A; s6.12)

/// Decode the 0x4E/0x5A sleep_phase_details: byte6 = header; phase codes are 2-bit, 4 per byte
/// (bits [7:6][5:4][3:2][1:0]); codes 0=awake,1=light,2=deep,3=REM. Per OURA_PROTOCOL.md s6.12.
/// Returns nil on a short body. The header byte is skipped; phase bytes follow.
/// (bits [7:6][5:4][3:2][1:0]); codes 0=deep, 1=light, 2=rem, 3=awake per open_oura's VALIDATED
/// `decode_sleep_phases` mapping (see OuraSleepStage). Returns nil on a short body. The header
/// byte is skipped; phase bytes follow.
public static func decodeSleepPhase(_ rec: OuraRecord) -> [OuraSleepPhase]? {
let b = rec.payload
// body[0] is the header (spec offset 6); phase codes begin at body[1].
Expand Down
19 changes: 19 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ public enum OuraFraming {
/// caller fails to special-case it.
public static let batteryResponseOp: UInt8 = 0x0D

/// The SyncTime response outer opcode (OURA_PROTOCOL.md s5.4, [ringverse BLE.md]). Below the
/// event-tag range, so it round-trips safely through the TLV decoder as an "unknown tag" no-op if a
/// caller fails to special-case it.
public static let syncTimeResponseOp: UInt8 = 0x13

/// Parse a 0x13 SyncTime response body per ringverse BLE.md:
/// `current_device_timestamp:4 LE status:1`. The device timestamp is the ring's own clock counter
/// AT THE MOMENT it processed our SyncTime — paired with the host wall-clock at receipt it forms a
/// deterministic ring-time→UTC anchor available at EVERY connect (the 0x42 time-sync record is only
/// logged when the ring actually adjusts its clock, so a session on an already-synced ring may never
/// see one — observed 2026-07-13: a whole night's drain with no anchor). ringverse labels the field
/// "seconds" but the tick unit is unconfirmed; the caller disambiguates against the persisted resume
/// cursor (OuraDriver.syncTimeAnchorCandidate). Returns nil on a short body.
public static func parseSyncTimeResponse(_ body: [UInt8]) -> (deviceTimestamp: UInt32, status: UInt8)? {
guard body.count >= 5 else { return nil }
let ts = UInt32(body[0]) | (UInt32(body[1]) << 8) | (UInt32(body[2]) << 16) | (UInt32(body[3]) << 24)
return (ts, body[4])
}

/// Parse a 0x11 GetEvents response body per open_oura's `EventBatchSummary`:
/// `events_received:1 sleep_analysis_progress:1 bytes_left:4LE [pad:2]`. The drain loop runs
/// until `bytes_left == 0`; there is NO resume cursor in this packet — the resume position is a
Expand Down
128 changes: 128 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/HypnogramAssembler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

// HypnogramAssembler: reconstruct the TIME AXIS of the ring's sleep-phase hypnogram.
//
// THE PROBLEM (observed on-device 2026-07-12, Gen3): the ring's SleepNet finalizes a night's staging
// AFTER wake and writes the whole hypnogram to its event log in ONE BURST — e.g. 23 records x 52 codes,
// all sharing essentially the same envelope ring-time (~09:30, the WRITE moment). The envelope
// timestamp therefore marks WHEN THE ANALYSIS WAS SAVED, not when the sleep happened; anchoring each
// record at its envelope collapses an entire night onto a few seconds.
//
// THE RECONSTRUCTION: the burst's codes are one contiguous sequence at 30 s/code, ENDING at the burst's
// envelope time. Laying N codes backward from the anchored burst end recovers the real window. The
// 30 s epoch is triple-confirmed: open_oura's sleepnet.md ("DEEP/LIGHT/REM/WAKE classification at
// 30-second intervals"), the observed window math (1,196 codes over a ~23:35-09:00 night), and the
// 0x49 summary's 600-minute window over those same 1,196 codes (~30.1 s/code).
//
// Platform-pure, value types + one small accumulator class; no CoreBluetooth, no clock (the caller
// resolves the anchored end time). Builds and tests on Linux.

/// One completed burst of consecutive sleep-phase records (usually a whole night's hypnogram).
public struct OuraHypnogramBurst: Equatable, Sendable {
/// The records in arrival (= log) order, each with its envelope ring-time and decoded codes.
public let records: [OuraHypnogramRecord]

public init(records: [OuraHypnogramRecord]) {
self.records = records
}

/// Total 2-bit codes across the burst.
public var totalCodes: Int { records.reduce(0) { $0 + $1.phases.count } }

/// The burst's LAST envelope ring-time — the write/finalization moment the reconstruction anchors
/// its END to, and the resume-cursor note for the whole burst.
public var lastRingTimestamp: UInt32 { records.last?.ringTimestamp ?? 0 }

/// True when any record's envelope ring-time is LOWER than its predecessor's within this burst —
/// the one signal that the arrival order (which the layout trusts as the sequence ground truth)
/// might not be chronological. Surfaced so the caller can LOG it rather than fail silently;
/// re-sorting is deliberately not done (envelope ring-times of a burst are near-identical write
/// moments, so a sort on them could scramble the true code sequence).
public var hasNonMonotonicRingTimes: Bool {
zip(records, records.dropFirst()).contains { $1.ringTimestamp < $0.ringTimestamp }
}

/// Lay the burst's codes out backward from `endUnixSeconds` at `secondsPerCode`. Code j of N gets
/// `ts = end - (N - j) * secondsPerCode` — i.e. each ts marks the START of that code's interval and
/// the final code's interval ends exactly at the burst end. Order: records in arrival order, codes
/// by their in-record index (the sequence is the ground truth; the spacing is the documented 30 s
/// SleepNet epoch). Arrival order is deliberately NOT re-sorted by envelope ring-time: the envelopes
/// mark the near-identical WRITE moments of a finalization burst, so they are useless as a sort key
/// (an unstable sort on near-equal keys could scramble the true sequence) — see
/// `hasNonMonotonicRingTimes` for the surfaced escape hatch.
public func codesWithTimes(endUnixSeconds: Int, secondsPerCode: Int = 30)
-> [(phase: OuraSleepPhase, ts: Int)] {
let n = totalCodes
var out: [(OuraSleepPhase, Int)] = []
out.reserveCapacity(n)
var j = 0
for record in records {
for phase in record.phases {
out.append((phase, endUnixSeconds - (n - j) * secondsPerCode))
j += 1
}
}
return out
}
}

/// One sleep-phase record as fed to the assembler: its envelope ring-time + decoded codes in order.
public struct OuraHypnogramRecord: Equatable, Sendable {
public let ringTimestamp: UInt32
public let phases: [OuraSleepPhase]
public init(ringTimestamp: UInt32, phases: [OuraSleepPhase]) {
self.ringTimestamp = ringTimestamp
self.phases = phases
}
}

/// Accumulate consecutive sleep-phase records into bursts. Records whose envelope ring-times sit
/// within `burstGapTicks` of the previous record belong to the same burst (a finalization write-out);
/// a larger gap (a different night / a separate analysis pass) closes the burst and starts a new one.
public final class OuraHypnogramAssembler {
/// Max envelope ring-time gap (ticks, 100 ms each) between records of ONE burst. Observed bursts
/// share rts within a few seconds; 600 ticks = 60 s is generous while still splitting nights
/// (separate finalizations are hours apart).
public let burstGapTicks: UInt32
private var current: [OuraHypnogramRecord] = []

public init(burstGapTicks: UInt32 = 600) {
self.burstGapTicks = burstGapTicks
}

/// Feed one record. Returns the PREVIOUS burst when this record's ring-time gap closes it (the fed
/// record then starts the next burst); nil while the current burst is still growing. Records with
/// no codes are ignored (nothing to place).
public func feed(ringTimestamp: UInt32, phases: [OuraSleepPhase]) -> OuraHypnogramBurst? {
guard !phases.isEmpty else { return nil }
let record = OuraHypnogramRecord(ringTimestamp: ringTimestamp, phases: phases)
if let last = current.last {
let gap = ringTimestamp >= last.ringTimestamp
? ringTimestamp - last.ringTimestamp
: last.ringTimestamp - ringTimestamp
if gap > burstGapTicks {
let done = OuraHypnogramBurst(records: current)
current = [record]
return done
}
}
current.append(record)
return nil
}

/// Close and return the in-progress burst (call at drain end / teardown), or nil if none.
public func flush() -> OuraHypnogramBurst? {
guard !current.isEmpty else { return nil }
let done = OuraHypnogramBurst(records: current)
current = []
return done
}

/// Discard any partial state (fresh session).
public func reset() {
current = []
}

/// Number of records currently accumulating (observability only).
public var pendingRecordCount: Int { current.count }
}
36 changes: 34 additions & 2 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@ public final class OuraDriver {
phase = .streaming
return []
}
// Ack-fetch (max=0) at the new cursor advances without re-pulling data (s5.3 step 4).
return [OuraCommands.getEvents(cursor: cursor, maxEvents: 0)]
// Continuation fetch at the ADVANCED cursor (max seen ring-time + 1), same shape as the
// initial request — open_oura `drain_events` re-issues `req_get_event(start, 255, -1)` every
// batch. The old open_ring "ack-fetch" (max=0 at a non-advancing cursor) made the ring
// RESTART serving from that cursor: the observed same-window re-serve loop (s5.3).
return [OuraCommands.getEvents(cursor: cursor, maxEvents: 255)]
}
}

Expand Down Expand Up @@ -228,6 +231,35 @@ public final class OuraDriver {
return Int(seconds)
}

/// Resolve the 0x13 SyncTime-response device timestamp into ring TICKS, or nil when no unambiguous
/// reading exists. ringverse BLE.md labels the field "seconds" but the ring's record clock runs in
/// 100 ms ticks, so both readings are tried: the raw value (already ticks) and value×10 (seconds→
/// ticks). The ring's clock at connect must sit shortly AFTER where the last drain ended, so a
/// candidate is plausible iff it falls in `[historyCursor, historyCursor + 7 days]`; exactly one
/// must fit (ambiguity or a fresh/reset cursor → nil → the caller logs raw instead of guessing).
/// Pure and testable; the honest-data invariant is "no anchor beats a wrong anchor".
public static func syncTimeAnchorCandidate(responseValue: UInt32, historyCursor: UInt32) -> UInt32? {
guard historyCursor > 0 else { return nil }
let lower = Int64(historyCursor)
let upper = lower + 6_048_000 // 7 days of 100 ms ticks
let readings = [Int64(responseValue), Int64(responseValue) * 10]
let fits = readings.filter { $0 >= lower && $0 <= upper && $0 <= Int64(UInt32.max) }
guard fits.count == 1 else { return nil }
return UInt32(fits[0])
}

/// Adopt a ring-time→UTC anchor from the 0x13 SyncTime response pair (`rt` = the ring's clock counter
/// in ticks when it processed our SyncTime, `unixSeconds` = host wall-clock at receipt). Same
/// plausibility gate as the 0x42/0x85 paths; returns whether the anchor was set. The freshest
/// possible pair, so it overwrites any earlier anchor (an in-log 0x42 from the same clock domain
/// yields the same mapping anyway).
public func adoptSyncTimeAnchor(ringTimestamp rt: UInt32, unixSeconds: Int64) -> Bool {
guard let ms = Self.plausibleAnchorMs(fromEpochSeconds: unixSeconds) else { return false }
anchorUtcMs = ms
anchorRingTime = rt
return true
}

/// Bounds for a plausible anchor epoch (unix seconds): 2020-01-01 to 2035-01-01. A decoded 0x42/0x85
/// value outside this range is a corrupt/misaligned record (seen on real hardware: a full cursor=0
/// history dump hit one deep in the backlog) and is never trusted as an anchor (honest-data invariant).
Expand Down
36 changes: 32 additions & 4 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,18 @@ public struct OuraBattery: Equatable, Sendable, Codable {
}
}

/// Sleep phase code (OURA_PROTOCOL.md s6.12): 2-bit codes 0=awake, 1=light, 2=deep, 3=REM.
/// The 2-bit sleep-phase code values, per open_oura's VALIDATED `decode_sleep_phases` mapping
/// (events.rs `PHASE = ["deep", "light", "rem", "awake"]`): 0=deep, 1=light, 2=rem, 3=awake.
///
/// CORRECTION (2026-07-11): NOOP previously mapped 0=awake/2=deep/3=rem from the same unverified doc
/// as the rest of s6.12. Two live captures contradict that: phase records decoded AT WAKE (wearer
/// demonstrably awake) carry code 3 — awake under open_oura's mapping, "REM" under the old one. The
/// raw code is what persists (`stage.rawValue`); only these LABELS changed, so stored rows are stable.
public enum OuraSleepStage: Int, Sendable, Equatable, Codable {
case awake = 0
case deep = 0
case light = 1
case deep = 2
case rem = 3
case rem = 2
case awake = 3
}

/// One decoded sleep-phase code in order within a 0x4E/0x5A record (OURA_PROTOCOL.md s6.12).
Expand Down Expand Up @@ -204,4 +210,26 @@ public enum OuraEvent: Equatable, Sendable {
default: return false
}
}

/// The record's envelope ring-time, when it carries one (battery is a plain response, not a log
/// record). Feeds the history drain's in-session continuation cursor: open_oura's `drain_events`
/// advances `start` past the max timestamp of EVERY event in a batch, whatever its tag.
public var envelopeRingTimestamp: UInt32? {
switch self {
case .hr(let v): return v.ringTimestamp
case .ibi(let v): return v.ringTimestamp
case .hrv(let v): return v.ringTimestamp
case .spo2(let v): return v.ringTimestamp
case .temp(let v): return v.ringTimestamp
case .battery: return nil
case .sleepPhase(let v): return v.ringTimestamp
case .motion(let v): return v.ringTimestamp
case .state(let v): return v.ringTimestamp
case .timeSync(let v): return v.ringTimestamp
case .rtcBeacon(let v): return v.ringTimestamp
case .debugText(let rt, _): return rt
case .tierB(let v): return v.ringTimestamp
case .activityInfo(let v): return v.ringTimestamp
}
}
}
Loading
Loading