Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
746c95a
Documented finding — docs/OURA_PROTOCOL.md
pipiche38 Jul 8, 2026
63d62df
Added live 0x43 debug-text logging
pipiche38 Jul 8, 2026
a042268
A logDebug(_:rt:) gate on the 0x43 case (OuraLiveSource.swift) with t…
pipiche38 Jul 8, 2026
95a884c
Oura: send SyncTime on connect to unlock the UTC anchor
pipiche38 Jul 8, 2026
09ef138
Oura: resync the TLV reassembler on an implausible record type
pipiche38 Jul 8, 2026
6f83358
Oura: gate SpO2 persistence to plausible [85,100] percentages
pipiche38 Jul 8, 2026
2e7a6ff
Docs: SleepNet is cloud-locked — NOOP builds its own sleep session…
pipiche38 Jul 8, 2026
df261a3
Oura: reject phantom ring timestamps far from the session anchor
pipiche38 Jul 8, 2026
ff49d7b
Oura: build a NOOP sleep session from the ring's phase timeline
pipiche38 Jul 8, 2026
ae554b4
Docs: digest open_oura crates+tools into OURA_PROTOCOL.md (section 9)
pipiche38 Jul 8, 2026
ba3a26e
Surface Oura activity/steps by promoting the Tier-B MET stream. Thre…
pipiche38 Jul 8, 2026
e465e10
Oura SyncTime (0x12): emit authoritative u64-LE-seconds + tz layout
pipiche38 Jul 8, 2026
e7b6359
Oura: wire the ring's phase timeline into a real sleep session
pipiche38 Jul 9, 2026
107d5b9
Oura: drop phantom history records instead of banking them at wall-c…
pipiche38 Jul 9, 2026
8103215
Merge remote-tracking branch 'upstream/main' into oura-integration-clean
pipiche38 Jul 9, 2026
b6d8940
Oura: parse the ring's own check_sleep window (PROTOTYPE, honest slee…
pipiche38 Jul 9, 2026
c586aa4
Oura: persist the check_sleep window as an honest sleep session
pipiche38 Jul 9, 2026
1cdc31a
Oura: surface the check_sleep window without hiding a richer import
pipiche38 Jul 9, 2026
dddbe95
Oura: fix #91 full re-dump loop — client-managed ring-time resume …
pipiche38 Jul 9, 2026
9100e2b
Oura: gate the UTC anchor on ring-time plausibility, not just epoch
pipiche38 Jul 9, 2026
63117a6
Merge remote-tracking branch 'origin/main' into oura-integration-clean
pipiche38 Jul 9, 2026
980fb88
Merge remote-tracking branch 'upstream/main' into oura-integration-clean
pipiche38 Jul 10, 2026
d56871b
Oura: bound history-drain with full-pull fast-exit (fix #92 infini…
pipiche38 Jul 10, 2026
da5ef5a
Oura: gate anchor epoch to ±7d of now, not the loose 2020–2035 window
pipiche38 Jul 10, 2026
7b0f764
Merge remote-tracking branch 'origin/main' into oura-integration-clean
pipiche38 Jul 10, 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
81 changes: 81 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/CheckSleepParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

/// Parse the ring's OWN computed sleep window out of its `check_sleep` debug-text (`0x43`) stream
/// (OURA_PROTOCOL.md §6.15). This is a PROTOTYPE / INVESTIGATION source (OURA_PROTOCOL.md §6.12.1):
/// the ring's firmware periodically logs its sleep-detection state as plain ASCII lines —
///
/// check_sleep
/// s: 114643 ← bedtime, a ring timestamp in the anchor's domain
/// e: 446340 ← wake, a ring timestamp in the anchor's domain
/// not needed
///
/// — and those `s:` / `e:` values are ring timestamps in the SAME domain as the `0x42` UTC anchor
/// (§5.5), so `OuraDriver.unixSeconds(forRingTimestamp:)` converts them straight to bedtime/wake UTC.
///
/// Why this matters: the decoded `OURA_SLEEP_PHASE` (`0x4E`) events turn out to be sparse bursts the
/// ring emits at connection time, NOT a continuous overnight timeline — so a session built from them
/// under-counts (a 9.2 h night read as ~5 h). The `s:`/`e:` window is the ring's own boundary decision
/// and is far more reliable for sleep DURATION. Unlike the Tier-B `sleep_summary` tags (`0x49/4B/4C/
/// 57/58`, which are ASCII letters `I/K/L/W/X` a framing desync aliases out of debug text — verified as
/// junk, never a real summary), the `s:`/`e:` lines are unambiguous ASCII and safe to read.
///
/// Honest-data stance: this only reads what the ring itself computed; it never fabricates stages. Kept
/// pure and platform-neutral (input is text lines) so it unit-tests in the fast package loop. It is an
/// INVESTIGATION prototype — the caller LOGS the anchored window, does not yet persist a session.
public struct OuraCheckSleepParser {
private var lastStartRt: UInt32?
private var lastEndRt: UInt32?
private var lastEmitted: Window?

/// A ring-timestamp sleep window: `startRt` = bedtime, `endRt` = wake, both in the anchor domain.
public struct Window: Equatable, Sendable {
public let startRt: UInt32
public let endRt: UInt32
public init(startRt: UInt32, endRt: UInt32) { self.startRt = startRt; self.endRt = endRt }
}

/// Longest plausible in-bed span, in ring ticks (100 ms/tick, §5.5): 18 h. A `check_sleep` block can
/// emit a lone `e:` (wake) with no fresh `s:`, which would otherwise pair the NEW wake against the
/// PREVIOUS night's stale `s:` — observed live as a phantom 33 h window (2026-07-09). No real sleep is
/// 18 h, so reject any window longer than this rather than emit a cross-block mis-pairing (honest-data).
private static let maxWindowTicks: UInt32 = 648_000 // 18 h × 3600 s × 10 ticks/s

public init() {}

/// Clear the accumulated state (call on stop/disconnect so a new session starts fresh).
public mutating func reset() {
lastStartRt = nil
lastEndRt = nil
lastEmitted = nil
}

/// Feed ONE trimmed debug-text line. Returns a `Window` when a NEW, complete (`endRt > startRt`)
/// sleep window is recognized; nil otherwise (an unrelated line, an incomplete pair, or a window
/// identical to the last one emitted — so repeated `e:` refinements collapse to one emit each).
///
/// Matches ONLY the exact lowercase boundary lines `s: <digits>` / `e: <digits>` the firmware emits
/// for sleep (so `tsc:…`, `bed:…`, `ns=…`, etc. are ignored), keeping the guess honest.
public mutating func ingest(line: String) -> Window? {
if let rt = Self.ringValue(line, prefix: "s:") {
lastStartRt = rt
} else if let rt = Self.ringValue(line, prefix: "e:") {
lastEndRt = rt
} else {
return nil // not a boundary line — nothing to update
}
guard let s = lastStartRt, let e = lastEndRt, e > s, e - s <= Self.maxWindowTicks else { return nil }
let window = Window(startRt: s, endRt: e)
guard window != lastEmitted else { return nil } // unchanged since last emit → don't re-log
lastEmitted = window
return window
}

/// Parse `"<prefix> <digits>"` (single ASCII space) into a ring timestamp, or nil if the line is
/// not exactly that shape. Rejects a non-numeric or overflowing tail rather than guessing.
private static func ringValue(_ line: String, prefix: String) -> UInt32? {
guard line.hasPrefix(prefix) else { return nil }
let rest = line.dropFirst(prefix.count).drop(while: { $0 == " " })
guard !rest.isEmpty, rest.allSatisfy(\.isNumber) else { return nil }
return UInt32(rest)
}
}
21 changes: 12 additions & 9 deletions Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,18 @@ 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.
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
45 changes: 37 additions & 8 deletions Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,21 @@ public enum OuraFraming {
/// caller fails to special-case it.
public static let batteryResponseOp: UInt8 = 0x0D

/// Parse a 0x11 GetEvents response body: `status:1 sub_status:1 last_ring_timestamp:4LE pad:2`
/// (OURA_PROTOCOL.md s5.2). `status` 0x00 = empty/no more; any other value = data follows. The
/// `last_ring_timestamp` is the new cursor to resume the fetch from. Returns nil on a short body
/// (never guesses a cursor).
public static func parseGetEventsResponse(_ body: [UInt8]) -> (cursor: UInt32, moreData: Bool)? {
/// Parse a 0x11 GetEvents response body per open_oura's `EventBatchSummary` (events.rs):
/// `events_received:1 sleep_analysis_progress:1 bytes_left:4LE`. The drain loop runs until
/// `bytes_left == 0` (sync-orchestration.md). There is **no resume cursor in this packet** — the
/// resume position is a CLIENT-managed event-envelope ring-time (`nextEventToSync`), never read back
/// here. Returns nil on a short body.
///
/// #91: NOOP previously decoded bytes[2..5] as a `last_ring_timestamp` cursor, persisted it, and
/// compared it across sessions. Those bytes are `bytes_left` — a remaining-byte count (~800 KB full →
/// 0). Two unrelated byte-counts compared as clocks minted a phantom "ring-time regression", which
/// reset the cursor to 0 and re-dumped the ring's entire banked history on every connect.
public static func parseGetEventsResponse(_ body: [UInt8]) -> (eventsReceived: UInt8, bytesLeft: UInt32, moreData: Bool)? {
guard body.count >= 6 else { return nil }
let status = body[0]
let cursor = UInt32(body[2]) | (UInt32(body[3]) << 8) | (UInt32(body[4]) << 16) | (UInt32(body[5]) << 24)
return (cursor, status != 0x00)
let eventsReceived = body[0]
let bytesLeft = UInt32(body[2]) | (UInt32(body[3]) << 8) | (UInt32(body[4]) << 16) | (UInt32(body[5]) << 24)
return (eventsReceived, bytesLeft, bytesLeft > 0)
}

/// Parse one outer frame from the front of `bytes`. Returns nil on a short buffer (header or body
Expand Down Expand Up @@ -168,13 +174,26 @@ public final class OuraReassembler {
buf.append(contentsOf: fragment)
var out: [OuraRecord] = []
while buf.count >= 2 {
let type = buf[0]
let len = Int(buf[1])
// A record must cover its 4 timestamp bytes. A len < 4 here is a misaligned byte: drop one
// and resync rather than emit garbage (honest-data invariant).
if len < OuraFraming.minRecordLen {
buf.removeFirst(1)
continue
}
// RESYNC on an implausible type (OURA_PROTOCOL.md §2.4). The type byte must be a real record
// start: a known inner event tag (>= 0x41), or the GetEvents-summary (0x11) / battery (0x0D)
// OUTER responses that legitimately ride the same wire and round-trip as sized no-op records.
// Any other byte means we are byte-misaligned - typically landed INSIDE a 0x43 debug-text
// payload, where ASCII bytes that alias real tags ('p'=0x70 / 'I'=0x49 / 'L'=0x4C / 'Q'=0x51)
// would otherwise mint a PHANTOM "summary" whose bogus len swallows the following real records
// (e.g. the sleep-phase timeline). Drop one byte and re-scan instead of consuming 2+len of
// garbage, so the parser realigns to the next genuine record boundary.
if !Self.isPlausibleRecordStart(type) {
buf.removeFirst(1)
continue
}
let total = 2 + len
if buf.count < total {
break // wait for the rest of this record
Expand All @@ -187,6 +206,16 @@ public final class OuraReassembler {
return out
}

/// A byte that can legitimately BEGIN a record on the notify channel: a known inner event tag, or
/// the two OUTER command responses that share the wire and are consumed as sized no-op records - the
/// GetEvents summary (`0x11`) and battery (`0x0D`), both below the `0x41` tag range. Everything else
/// is a byte-misalignment to resync past (§2.4). Kept private + pure so it is exercised via `feed`.
static func isPlausibleRecordStart(_ type: UInt8) -> Bool {
OuraEventTag(rawValue: type) != nil
|| type == OuraFraming.getEventsResponseOp
|| type == OuraFraming.batteryResponseOp
}

/// Discard any buffered partial bytes (call on disconnect so a half-record does not bleed into the
/// next session). Mirrors the StandardHRSource stop()/reset discipline.
public func reset() {
Expand Down
114 changes: 114 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraActivityEstimator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

// MARK: - Phase-1 Oura activity estimate (Tier-B INVESTIGATION — never persisted, never scored)

/// PHASE 1 of promoting the Oura `0x50 activity_info` (MET) stream out of Tier B. This pure roll-up
/// folds decoded MET samples into a per-local-day estimate that `OuraLiveSource` LOGS (never stores)
/// so the numbers can be eyeballed against the WHOOP band's own steps / active-kcal on the following
/// day (the user wears both). It exists ONLY to decide whether 0x50 is worth promoting to Tier A; it
/// never touches `OuraStreamMapping` / `Streams` / scoring, and it never mints an honest step count
/// (MET ≠ steps — the `stepProxy` below is explicitly a labelled proxy, not a stored value).
///
/// HONEST UNCERTAINTY: the MET sample *spacing* is undocumented (docs/OURA_PROTOCOL.md §6.13 marks the
/// layout "UNVERIFIED - partial"). So the cadence-independent facts (sample counts per MET band, mean /
/// max MET) are reported as-is, while every minute / kcal / step figure is derived under an EXPLICIT
/// `assumedIntervalSec` and labelled as such. The WHOOP cross-check is precisely what calibrates that
/// unknown: if Oura active-minutes at the assumed interval read ~2× WHOOP, the true spacing is ~2×.
public struct OuraActivitySample: Equatable, Sendable {
/// Wall-clock unix seconds. In Phase 1 this is the live *arrival* time (history-backlog anchoring is
/// future work, and blocked today by the history-fetch cursor regression — see CLAUDE.md).
public let ts: Int
public let met: Double
public init(ts: Int, met: Double) { self.ts = ts; self.met = met }
}

/// The per-day roll-up. Cadence-independent fields (`*Samples`, `meanMet`, `maxMet`) carry no interval
/// assumption; the `active*` / `est*` / `stepProxy` fields do (see `assumedIntervalSec`).
public struct OuraActivityEstimate: Equatable, Sendable {
public let day: String
public let sampleCount: Int
public let firstTs: Int?
public let lastTs: Int?
public let meanMet: Double
public let maxMet: Double

// Cadence-INDEPENDENT: how many MET samples fell in each standard activity band.
public let sedentarySamples: Int // met < 1.5
public let lightSamples: Int // 1.5 <= met < 3
public let moderateSamples: Int // 3 <= met < 6
public let vigorousSamples: Int // met >= 6

// Cadence-DEPENDENT: everything below scales linearly with `assumedIntervalSec`.
public let assumedIntervalSec: Double
public let activeMinutes: Double // (light+moderate+vigorous) samples * interval / 60
public let estActiveKcal: Double // Σ max(met-1,0) * bodyweightKg * interval/3600 (net-of-resting)
public let stepProxy: Int // PROXY ONLY: activeMinutes * stepsPerActiveMin (never stored)

/// A single-line, grep-friendly log string. `final == true` means the local day rolled over (a
/// complete day for whatever coverage we got); `false` is a running "so far" snapshot.
public func logLine(final: Bool) -> String {
let tag = final ? "FINAL" : "so far"
func f(_ v: Double, _ p: Int = 1) -> String { String(format: "%.\(p)f", v) }
return "activity estimate \(tag) day=\(day) samples=\(sampleCount) "
+ "meanMET=\(f(meanMet, 2)) maxMET=\(f(maxMet, 2)) "
+ "bands[sed/light/mod/vig]=\(sedentarySamples)/\(lightSamples)/\(moderateSamples)/\(vigorousSamples) "
+ "activeMin≈\(f(activeMinutes)) estKcal≈\(f(estActiveKcal, 0)) stepProxy≈\(stepProxy) "
+ "[assumed \(f(assumedIntervalSec, 0))s/sample — PROXY, not stored]"
}
}

/// Pure, deterministic summariser. No I/O, no clock, no persistence — fully unit-testable.
public enum OuraActivityEstimator {
// Standard compendium-of-physical-activities MET band edges.
public static let lightFloor = 1.5 // below this = sedentary
public static let moderateFloor = 3.0
public static let vigorousFloor = 6.0

/// Fold MET samples for a single local `day` into an estimate. `bodyweightKg` feeds the (secondary)
/// active-kcal figure; `assumedIntervalSec` is the UNVERIFIED per-sample spacing that all time-based
/// outputs scale with; `stepsPerActiveMin` is the labelled step-proxy cadence (never an honest count).
public static func summarize(_ samples: [OuraActivitySample],
day: String,
bodyweightKg: Double,
assumedIntervalSec: Double,
stepsPerActiveMin: Double) -> OuraActivityEstimate {
guard !samples.isEmpty else {
return OuraActivityEstimate(day: day, sampleCount: 0, firstTs: nil, lastTs: nil,
meanMet: 0, maxMet: 0,
sedentarySamples: 0, lightSamples: 0, moderateSamples: 0,
vigorousSamples: 0, assumedIntervalSec: assumedIntervalSec,
activeMinutes: 0, estActiveKcal: 0, stepProxy: 0)
}
var sum = 0.0, maxMet = 0.0
var sed = 0, light = 0, mod = 0, vig = 0
var activeKcal = 0.0
let hours = assumedIntervalSec / 3600.0
for s in samples {
sum += s.met
if s.met > maxMet { maxMet = s.met }
switch s.met {
case ..<lightFloor: sed += 1
case ..<moderateFloor: light += 1
case ..<vigorousFloor: mod += 1
default: vig += 1
}
// Net-of-resting active energy: only MET above 1.0 (resting) burns "active" kcal.
activeKcal += max(s.met - 1.0, 0.0) * bodyweightKg * hours
}
let activeSamples = light + mod + vig
let activeMinutes = Double(activeSamples) * assumedIntervalSec / 60.0
let stepProxy = Int((activeMinutes * stepsPerActiveMin).rounded())
return OuraActivityEstimate(
day: day,
sampleCount: samples.count,
firstTs: samples.map(\.ts).min(),
lastTs: samples.map(\.ts).max(),
meanMet: sum / Double(samples.count),
maxMet: maxMet,
sedentarySamples: sed, lightSamples: light, moderateSamples: mod, vigorousSamples: vig,
assumedIntervalSec: assumedIntervalSec,
activeMinutes: activeMinutes,
estActiveKcal: activeKcal,
stepProxy: stepProxy)
}
}
Loading
Loading