diff --git a/Strand/System/ReportReviewGate.swift b/Strand/System/ReportReviewGate.swift index 7b9f564947..4f34b969bb 100644 --- a/Strand/System/ReportReviewGate.swift +++ b/Strand/System/ReportReviewGate.swift @@ -12,24 +12,40 @@ struct ReportReviewGate { init(entries: [FileExport.BundleEntry]) { self.entries = entries } + /// The bundle files that are NEVER shown inline in the review sheet by NAME: the binary screenshot plus + /// the large raw research streams (WHOOP raw-capture + the Oura Tier-B sidecars — each up to the 20 MB + /// cap). Rendering megabytes of text as a single SwiftUI `Text` pins the main thread in CoreText glyph + /// layout — a ~12 MB `oura-raw.jsonl` allocated >1 GB and hung the review sheet indefinitely. They are + /// already PII-scrubbed and are NAMED in the "attached" note below, so the review stays honest without + /// trying to lay them out. + static let notShownInline: Set = [ + "screenshot.png", "raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl", + ] + + /// Belt-and-braces size guard: ANY entry larger than this is named, never inlined, even one NOT in + /// `notShownInline` (a future stream, or a pathologically large report.txt). 1 MiB sits far above a + /// normal report.txt / meta.json (so those still show in full) yet well below the layout-choke point, + /// so no single Text can ever pin the main thread again regardless of which files a branch attaches. + static let maxInlineBytes = 1024 * 1024 + /// Every text file the user is about to share, shown in the review sheet so they can read the WHOLE /// bundle (not just report.txt) and cancel if anything looks personal , the gate promises the user sees - /// exactly what they share. Each text entry is prefixed with a `=== ===` header so the three - /// files (report.txt, meta.json, and last-crash.txt when present) are clearly delimited. The - /// raw-capture stream is excluded: it is the bounded binary capture (up to the 20 MB cap), not a report - /// surface, and is already PII-scrubbed by the assembler. Order is the natural bundle order. Empty - /// string if there is nothing text-decodable to show. + /// exactly what they share. Each text entry is prefixed with a `=== ===` header so the files + /// (report.txt, meta.json, and last-crash.txt when present) are clearly delimited. The large raw streams + /// (by name, or anything over `maxInlineBytes`) are excluded: they are bounded research captures, not a + /// report surface, already PII-scrubbed, and far too large to lay out inline. Order is the natural + /// bundle order. Empty string if there is nothing text-decodable to show. var previewText: String { let textBlocks = entries.compactMap { entry -> String? in - guard entry.name != "raw-capture.jsonl", + guard !Self.notShownInline.contains(entry.name), entry.data.count <= Self.maxInlineBytes, let text = String(data: entry.data, encoding: .utf8) else { return nil } return "=== \(entry.name) ===\n\(text)" }.joined(separator: "\n\n") - // Binary attachments (the Display mode's screenshot.png, raw-capture.jsonl) are not text, so they - // cannot be shown inline. Name them so the review is honest about EVERYTHING in the bundle: the - // user sees that a screenshot is attached and can cancel if they don't want to share it. + // Binary / large attachments (screenshot.png, raw-capture.jsonl, the oura-*.jsonl sidecars, anything + // oversized) are not shown inline. Name them so the review is honest about EVERYTHING in the bundle: + // the user sees that a screenshot / raw capture / ring dump is attached and can cancel if unwanted. let binaryNames = entries.compactMap { entry -> String? in - if entry.name == "raw-capture.jsonl" || entry.name == "screenshot.png" { return entry.name } + if Self.notShownInline.contains(entry.name) || entry.data.count > Self.maxInlineBytes { return entry.name } return String(data: entry.data, encoding: .utf8) == nil ? entry.name : nil } guard !binaryNames.isEmpty else { return textBlocks } diff --git a/Strand/System/TestBundleAssembler.swift b/Strand/System/TestBundleAssembler.swift index 23165a53b4..376add747f 100644 --- a/Strand/System/TestBundleAssembler.swift +++ b/Strand/System/TestBundleAssembler.swift @@ -14,6 +14,15 @@ enum TestBundleAssembler { /// The redaction stamp written into meta.json so a maintainer knows the whole-bundle scrub ran. static let redactionVersion = "v2" + /// The bundle files that may be trimmed to fit the cap (newest-tail kept). The strap-log tail and + /// meta.json are already bounded, so only these raw research streams can blow the budget: the WHOOP + /// frame capture plus the Oura ring's Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET). + /// Everything NOT listed here is kept whole and its bytes are reserved before the trimmable group is + /// given the remainder. These are the NORMALIZED entry names (the ring id is dropped from the filename). + static let trimmableNames: Set = [ + "raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl", + ] + /// Re-run the redaction sink over every entry. Text entries are decoded as UTF-8, scrubbed via the same /// LiveState.redactPii used by the live sink, and re-encoded. A non-UTF-8 entry (none today) passes /// through untouched rather than risk corrupting binary. meta.json and report.txt have no PII shapes so @@ -27,24 +36,32 @@ enum TestBundleAssembler { } /// Hard cap the bundle at `capBytes` (20 MB default, under GitHub's 25 MB; spec section 5.4). The - /// strap-log tail is already bounded, so only raw-capture can exceed. We keep the MOST-RECENT tail of - /// raw-capture.jsonl (newest data is the most diagnostic) and trim from the front. Returns the capped - /// entries plus whether any truncation happened, which the caller writes to meta.truncated. + /// strap-log tail and meta.json are already bounded, so only the `trimmableNames` research streams can + /// exceed. We reserve the whole size of every non-trimmable file, then split the remaining budget across + /// the trimmable files IN PROPORTION to their size, keeping the MOST-RECENT tail of each (newest data is + /// the most diagnostic) and trimming from the front. With a single trimmable file this is identical to + /// the prior raw-capture-only behaviour (its share is the whole remainder). Returns the capped entries + /// plus whether any truncation happened, which the caller writes to meta.truncated. static func capEntries(_ entries: [FileExport.BundleEntry], capBytes: Int = 20 * 1024 * 1024) -> (entries: [FileExport.BundleEntry], truncated: Bool) { let total = entries.reduce(0) { $0 + $1.data.count } guard total > capBytes else { return (entries, false) } - // Budget for everything that is NOT raw-capture (kept whole), then give raw-capture the remainder. - let rawName = "raw-capture.jsonl" - let nonRaw = entries.filter { $0.name != rawName }.reduce(0) { $0 + $1.data.count } - let budget = max(0, capBytes - nonRaw) + // Reserve the non-trimmable files (kept whole), then share the remainder across the trimmable ones. + let nonTrimmable = entries.filter { !trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count } + let budget = max(0, capBytes - nonTrimmable) + let trimmableTotal = entries.filter { trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count } var truncated = false let capped = entries.map { entry -> FileExport.BundleEntry in - guard entry.name == rawName, entry.data.count > budget else { return entry } + guard trimmableNames.contains(entry.name) else { return entry } + // Each trimmable file gets a byte share proportional to its size (integer floor keeps the sum + // under budget, so the bundle never breaches the cap). Kept whole if already within its share. + let share = trimmableTotal > 0 + ? Int(Double(budget) * Double(entry.data.count) / Double(trimmableTotal)) + : 0 + guard entry.data.count > share else { return entry } truncated = true - // Keep the tail (most recent): the last `budget` bytes. - let tail = entry.data.suffix(budget) - return FileExport.BundleEntry(name: entry.name, data: Data(tail)) + // Keep the tail (most recent): the last `share` bytes. + return FileExport.BundleEntry(name: entry.name, data: Data(entry.data.suffix(share))) } return (capped, truncated) } @@ -130,12 +147,22 @@ enum TestBundleAssembler { let crash: FileExport.BundleEntry? = crashLogURL() .flatMap { fileEntry(at: $0, name: "last-crash.txt") } - // 2. Redact the TEXT files (report.txt, raw-capture.jsonl, last-crash.txt), then cap. The screenshot + // 1e. Oura ring diagnostics: the Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET) + // the ring writes to /OpenWhoop/Diagnostics whenever it connects. Attached WHEN + // PRESENT — exactly like raw-capture.jsonl, NOT behind a test domain: they cut across Sleep / + // HRV / Connection / Sources, and file presence is the honest gate (no ring used → no files → + // nothing attached, so an Experimental ring needs no separate brand check here). They are TEXT + // (JSON lines) whose only PII is the ring UUID inside each line, which the redactEntries pass + // below masks to ; the ENTRY name is normalized (id dropped) since redaction never + // touches names. Trimmed to the cap alongside raw-capture via `trimmableNames`. + let ouraDiagnostics = ouraDiagnosticEntries() + + // 2. Redact the TEXT files (report.txt, raw-capture.jsonl, last-crash.txt, oura-*.jsonl), then cap. The screenshot // is included in the cap input (NOT the redact input) so its bytes COUNT against the 20 MB cap: // capEntries budgets raw-capture as capBytes - (everything else), so a large/retina PNG shrinks // the raw-capture tail rather than breaching the cap. Only raw-capture is trimmed; report.txt and // last-crash are bounded and the PNG is kept whole. - let textEntries = [reportEntry] + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? []) + let textEntries = [reportEntry] + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? []) + ouraDiagnostics let redacted = redactEntries(textEntries) let (capped, truncated) = capEntries(redacted + (shot.map { [$0] } ?? [])) var entries = capped @@ -223,6 +250,49 @@ enum TestBundleAssembler { return FileExport.BundleEntry(name: name, data: data) } + /// The `OpenWhoop/Diagnostics` directory the Oura dumps write into, mirroring the dumps' own resolver + /// (`OuraRawDump.resolveURL` et al.): Application Support + `OpenWhoop/Diagnostics`. Read-only — never + /// creates the directory (a bundle build must not have side effects), so it returns nil when nothing has + /// ever been captured. iOS and macOS both resolve Application Support here. + static func ouraDiagnosticsDir() -> URL? { + guard let base = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: false) else { return nil } + return base.appendingPathComponent("OpenWhoop/Diagnostics", isDirectory: true) + } + + /// Map a diagnostics filename to a NORMALIZED bundle entry name that drops the ring id, or nil when the + /// file is not one of the three Oura sidecars. `oura--.jsonl` → `oura-.jsonl`, which + /// (a) keeps the ring id out of the bundle's filenames (redaction scrubs content, never names) and + /// (b) lands on the `trimmableNames` set so the cap can trim it. Pure/string-only for unit testing. + static func normalizedOuraEntryName(forFile filename: String) -> String? { + for kind in ["raw", "ibihr", "activity"] { + if filename.hasPrefix("oura-\(kind)-"), filename.hasSuffix(".jsonl") { + return "oura-\(kind).jsonl" + } + } + return nil + } + + /// Gather the Oura ring's Tier-B JSONL sidecars as bundle entries, normalized names and RAW bytes (the + /// caller redacts + caps). Enumerates the Diagnostics dir rather than reconstructing per-ring filenames, + /// so it needs no active-ring id and picks up whatever was captured. If two rings produced the same kind + /// (rare), the entry names would collide; we keep the LARGEST file per normalized name (the fuller + /// capture is the more useful one) so the bundle never carries duplicate names. + static func ouraDiagnosticEntries() -> [FileExport.BundleEntry] { + guard let dir = ouraDiagnosticsDir(), + let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + else { return [] } + var byName: [String: FileExport.BundleEntry] = [:] + for url in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard let name = normalizedOuraEntryName(forFile: url.lastPathComponent), + let entry = fileEntry(at: url, name: name) else { continue } + if let existing = byName[name], existing.data.count >= entry.data.count { continue } + byName[name] = entry + } + // Stable order (raw, ibihr, activity) so the bundle listing is deterministic. + return ["oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl"].compactMap { byName[$0] } + } + /// The on-disk path a crash report would live at, when a crash handler is wired. There is no producer /// yet, so this normally points at a non-existent file and `fileEntry` returns nil. Centralised here so a /// future handler need only write to this path for the crash log to start attaching (fully redacted) diff --git a/StrandTests/ReportReviewGateTests.swift b/StrandTests/ReportReviewGateTests.swift index 92403c2e7d..a1417c2269 100644 --- a/StrandTests/ReportReviewGateTests.swift +++ b/StrandTests/ReportReviewGateTests.swift @@ -30,4 +30,30 @@ final class ReportReviewGateTests: XCTestCase { gate.confirm() XCTAssertTrue(gate.isCleared) } + + /// The large raw research streams (raw-capture + the Oura Tier-B sidecars) must NEVER be inlined into + /// the preview: laying out a multi-MB `oura-raw.jsonl` as one SwiftUI Text pinned the main thread in + /// CoreText glyph layout (>1 GB, hung). They are still NAMED so the review stays honest. + func testLargeRawStreamsAreNamedNotInlined() { + let bigOura = String(repeating: "A", count: 2 * 1024 * 1024) // 2 MB of would-be inline text + let entries = [ + FileExport.BundleEntry(name: "report.txt", data: Data("visible report body".utf8)), + FileExport.BundleEntry(name: "raw-capture.jsonl", data: Data("raw whoop bytes".utf8)), + FileExport.BundleEntry(name: "oura-raw.jsonl", data: Data(bigOura.utf8)), + FileExport.BundleEntry(name: "oura-ibihr.jsonl", data: Data("ring hr".utf8)), + FileExport.BundleEntry(name: "oura-activity.jsonl", data: Data("ring met".utf8)), + ] + let preview = ReportReviewGate(entries: entries).previewText + // report.txt is shown in full… + XCTAssertTrue(preview.contains("visible report body")) + // …but none of the large stream CONTENT is inlined (the 2 MB blob never reaches the Text). + XCTAssertFalse(preview.contains(bigOura)) + XCTAssertFalse(preview.contains("=== oura-raw.jsonl ===\nA")) + // Every excluded stream is still NAMED under the attached note, so the review is honest. + for name in ["raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl"] { + XCTAssertTrue(preview.contains(name), "\(name) must be named in the review") + } + // The whole preview stays tiny regardless of the 2 MB stream. + XCTAssertLessThan(preview.count, 10_000) + } } diff --git a/StrandTests/TestBundleAssemblerTests.swift b/StrandTests/TestBundleAssemblerTests.swift index db46a91dfb..dcfa1d4962 100644 --- a/StrandTests/TestBundleAssemblerTests.swift +++ b/StrandTests/TestBundleAssemblerTests.swift @@ -52,4 +52,51 @@ final class TestBundleAssemblerTests: XCTestCase { XCTAssertFalse(truncated) XCTAssertEqual(capped, entries) } + + // MARK: - Oura diagnostics attachment (#Test-Centre Oura sidecars) + + func testNormalizedOuraEntryNameDropsRingId() { + // The three sidecars normalize to id-free names; the ring UUID is gone from the filename. + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-raw-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-raw.jsonl") + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-ibihr-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-ibihr.jsonl") + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-activity-oura-5C4C0BF8.jsonl"), "oura-activity.jsonl") + // Non-sidecar files are ignored. + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "raw-capture.jsonl")) + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "whoop.sqlite")) + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "oura-raw.txt")) + } + + func testNormalizedOuraNamesAreAllTrimmable() { + // Every normalized sidecar name must be in the cap's trimmable set, else a big night's dump could + // blow the 20 MB cap instead of being tail-trimmed. + for kind in ["raw", "ibihr", "activity"] { + XCTAssertTrue(TestBundleAssembler.trimmableNames.contains("oura-\(kind).jsonl")) + } + } + + func testCapSharesBudgetAcrossOuraAndRawCaptureKeepingTails() { + // report.txt kept whole; raw-capture + oura-ibihr together blow the cap → BOTH trimmed to a + // proportional tail, bundle stays under cap, and each keeps its most-recent byte. + let small = FileExport.BundleEntry(name: "report.txt", data: Data("small".utf8)) + let raw = FileExport.BundleEntry(name: "raw-capture.jsonl", + data: Data((String(repeating: "r", count: 30 * 1024 * 1024) + "R").utf8)) + let ibi = FileExport.BundleEntry(name: "oura-ibihr.jsonl", + data: Data((String(repeating: "i", count: 10 * 1024 * 1024) + "I").utf8)) + let cap = 20 * 1024 * 1024 + let (capped, truncated) = TestBundleAssembler.capEntries([small, raw, ibi], capBytes: cap) + XCTAssertTrue(truncated) + XCTAssertLessThanOrEqual(capped.reduce(0) { $0 + $1.data.count }, cap) + XCTAssertEqual(capped.first { $0.name == "report.txt" }?.data, small.data) // whole + let cappedRaw = capped.first { $0.name == "raw-capture.jsonl" }! + let cappedIbi = capped.first { $0.name == "oura-ibihr.jsonl" }! + XCTAssertLessThan(cappedRaw.data.count, raw.data.count) // trimmed + XCTAssertLessThan(cappedIbi.data.count, ibi.data.count) // trimmed + XCTAssertEqual(cappedRaw.data.last, raw.data.last) // newest kept + XCTAssertEqual(cappedIbi.data.last, ibi.data.last) + // Bigger stream gets the bigger share (raw was 3× ibi). + XCTAssertGreaterThan(cappedRaw.data.count, cappedIbi.data.count) + } } diff --git a/docs/DEVICE_SUPPORT_ROADMAP.md b/docs/DEVICE_SUPPORT_ROADMAP.md index 59db91295e..7e5fc548ba 100644 --- a/docs/DEVICE_SUPPORT_ROADMAP.md +++ b/docs/DEVICE_SUPPORT_ROADMAP.md @@ -14,7 +14,7 @@ source stands and the protocol facts we've verified, so the next build can pick | **Polar deep streams** (ECG / PPG / ACC / PPI) | 🔬 Pure PPI decoder built (`Packages/PolarProtocol` + `com.noop.polar`, tests green both platforms); live `PolarPMDSource` + ECG/PPG decode still to build | PMD service (below) — alpha, hardware-gated | | **Garmin** (sleep / HRV / Body Battery / SpO₂ / FIT) | 📋 Researched, not built | Local BLE re-derive (Gadgetbridge-informed, **never** GPLv3 copy) | | **Amazfit / Zepp** (incl. Helio deep) | 📋 Researched, not built | Encrypted Huami BLE — needs a one-time **user-pasted** vendor key (NOOP never logs into the vendor cloud) | -| **Oura** | 🔬 Built (cloud import) | Cloud API v2 — off-by-default OAuth, one-time backfill; local BLE ring also supported | +| **Oura** (Gen 3/4/5) | 🔬 Cloud import shipped; local BLE ring **experimental** | Cloud API v2 (off-by-default OAuth backfill) **+** clean-room BLE ring — auth, live HR/IBI, history drain, sleep hypnogram, activity/HR research (below) | | **Fitbit / Google** | 📋 Researched, not built | Build against **Google Health** API (Fitbit Web API sunsets Sept 2026) — off-by-default import | ## Polar Measurement Data (PMD) — verified protocol @@ -113,6 +113,47 @@ band in an iterative BLE test loop. Verified facts to pick up from: This earns its place only if it stays tractable and never threatens WHOOP stability — it will not ship blind. +## Oura Ring — BLE (experimental) + +A clean-room BLE lane for a ring the user owns, alongside the shipped cloud import. **Experimental** +(`ExperimentalBrand`-gated, not a shipped supported strap). NOOP computes its **own** Charge/Rest from the +ring's raw signals and **never** reads Oura's encrypted readiness/sleep scores. Full byte-level spec: +[`OURA_PROTOCOL.md`](OURA_PROTOCOL.md); this is the where-we-stand summary. + +**Working (validated on a live Gen 3):** app-auth handshake (nonce → AES-128 proof), live HR + IBI stream, +SyncTime (`0x12`/`0x13`) UTC anchoring, and a history drain aligned with open_oura `drain_events` (per-batch +cursor advance + quiet window → converges to `bytes_left 0`, no re-serve loop). Decoded signals: HR/IBI, HRV +(`0x5D` + reconstructed), skin temp (`0x46`), SpO₂, battery. Own central/GATT — never the WHOOP path. + +**Sleep — hypnogram persist (DRAFT PR #446).** The ring writes the whole night's SleepNet phase codes in one +burst after wake; NOOP reconstructs the time axis (30 s/code, end anchored by the `0x49` sleep-summary) and +banks it as a `CachedSleepSession` with a `[{start,end,stage}]` timeline under the ring's own deviceId, so +`SleepMerge`'s imported-over-computed rule surfaces Oura's staging (reusing the #988/HC stage-timeline path). +Cross-checks: **light** matches WHOOP/the Oura app well (±2–3 min); **deep/REM undercounted, awake over** vs +the Oura app — the raw on-device SleepNet stream ≠ the app's post-processed hypnogram (surfaced honestly with +an "Oura / raw on-device stages" badge). **Display-only** — traced: it does NOT feed the recovery score (Charge +is computed from the engine's own HR staging). **Open:** a main-night gate so junk daytime fragments don't win +a day (same class as the import-side fix **#375**); on-device multi-night validation. + +**Activity / MET — `0x50` (DRAFT PR #447, Tier-B, never scored).** A plausible third-party MET decode +(PR #960): `state` byte + per-sample MET. Captured to a diagnostic JSONL corpus (`oura-activity-.jsonl`), +never a durable/scoring row. **Validated:** MET tracks land-activity intensity — three walks read mean ≈ 4 MET +vs a ≈ 0.9 sleep floor, and per-minute MET vs a Suunto `.fit` speed profile correlates **r = 0.89**. It +underreads water, and the stream is sparse with **ring-side** cadence gaps (~86 % coverage) so daily totals +undercount. **NOOP has no MET field** in its HR/strain model, so `0x50` stays research only — the ring's path +into NOOP activity is HR, never MET, and it is never a step count. + +**Banked IBI → HR — `0x80` (research instrumentation).** open_oura derives per-minute HR (`hr_bpm = 60000/ibi`) +from the `0x80` green-IBI record, not from `0x50`. NOOP already decodes those IBIs for HRV; a tagged JSONL +sidecar (`oura-ibihr-.jsonl`) also reconstructs an HR history from the banked stream for offline study. +**First daytime sample was sparse + noisy** (~7 usable beats/min, ~15 % impossible-HR artifacts) — looks like a +quality-sampled subset, not a full beat record. **Decisive test pending:** overnight density (ring still → +cleanest optics), per source tag. If a clean+dense stream emerges it becomes a real sleep-HR/RHR source (own +branch/PR); if not, it is a documented dead-end and MET stays the daytime proxy. + +**Analysis tooling:** `diagnostics/oura_met_crosscheck.py` cross-checks the MET + IBI-HR corpora against the +app SQLite (workouts/sleep) and, with `--suunto`, a `.fit` export — per-minute MET/HR profiles + correlations. + ## Notes on the deep-band lanes (Garmin / Amazfit / cloud) These "earn their place" — pursue only while tractable, defer/drop if they threaten WHOOP stability or diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index db9858818e..9f3e576929 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -12,6 +12,7 @@ - **[open_oura-feat]** - Th0rgal/open_oura `docs/ring-features.md` (feature gating). - **[relue]** - relue/oura_ring_reverse `docs/.../heartbeat_replication_guide.md` and `heartbeat_complete_flow.md` (no-license; Ring 3 live-HR). - **[oura-rs]** - Th0rgal/open_oura `crates/oura-protocol/src/events.rs` (no-license Rust clean-room decoder; facts cited only, no code copied). Its event tags marked `"_status": "unvalidated"` are treated the same as our Tier B - plausible, not ground-truth-confirmed. +- **[open_oura-act]** - Th0rgal/open_oura `crates/oura-cli/src/activity_model.rs` (no-license; facts cited only). The activity classifier's input assembly reads four SEPARATE event tags — `met`←`0x50`, motion←`0x47`, temp←`0x46`, `hr_bpm`←`0x80` — establishing which tag each signal comes from (notably HR from the `0x80` IBI record, not `0x50`). > **CONFLICT NOTE (resolution rule):** The relue archive file `event_data_definition.md` describes events as **protobuf varint** records (e.g. `0x55` SLEEP_HR with field tags). This contradicts the **byte-for-byte verified TLV framing** in [open_ring] and [ringverse]. The TLV/bit-packed model from [open_ring]/[ringverse] is authoritative for our decoders; the protobuf description is treated as unverified/likely AI-fabricated and is NOT used. Where a layout is only attested by a single no-license, AI-generated doc, it is marked **(UNVERIFIED)** and our decoder must gate it behind a fixture test before trusting it. @@ -324,7 +325,21 @@ physiological (300–2000 ms). (Up to 7 samples per 14-byte record.) [oura-rs] **Validated (decode fix):** the earlier reading treated the pair as a little-endian u16 masked to bits 0–10, placing the high byte in the LOW bits — a bit-order error (real-capture within-record jitter 583 ms). The high-byte-first layout with the `quality == 1` gate yields a clean beat train (45 ms jitter) and keeps more -good beats. Matches `open_oura`'s `parse_api_green_ibi_quality_event`. +good beats. Matches `open_oura`'s `parse_api_green_ibi_quality_event`. (Same class of fix as `0x60`, §6.1.) + +**IBI → HR (NOOP research, Tier-B):** each accepted sample is a per-beat interval, so an instantaneous HR +follows as `60000 / IBI_ms`. open_oura feeds this record's per-minute HR (`hr_bpm`) into its activity +classifier (`oura-cli/src/activity_model.rs`, alongside `met`←`0x50`, motion←`0x47`, temp←`0x46`) — i.e. HR +comes from THIS record, not from the `0x50` MET record. NOOP already decodes these IBIs for HRV; a diagnostic +sidecar (`oura-ibihr-.jsonl`, records tagged by source event `0x80`/`0x60`/`0x6E`/`0x44`) also +reconstructs an HR history from the banked stream for offline study — NEVER scored. **Result (2026-07-16, +first full overnight):** the earlier "sparse + ~15 % impossible-HR" daytime reading was largely the DECODE +BUG above (wrong bit layout), not a ring limitation. With the corrected layout, one full night decoded to +**94 % minute coverage, ~10 % beat-to-beat artifact, a clean ~56 bpm resting level with a real nocturnal dip +that tracks the reconstructed hypnogram** — i.e. usable as an overnight HR/HRV source. Daytime/activity is +sparser (~43 % coverage — wrist motion thins the banked beats) but the surviving beats are clean and HR +tracks effort (rest ≈ 59 → moderate ≈ 100 bpm). Still Tier-B, never scored; promotion to +`restingHr`/`avgHrv` is gated on multi-night validation against a reference. [open_oura-act] ### 6.5 SpO2 per-sample - `0x6F` `spo2_event` (5–18 B, 1 s spacing) - Byte 6: bits `[7:4]` = SpO2 base (<<7); bits `[3:0]` = status flag. [ringverse] @@ -371,6 +386,18 @@ good beats. Matches `open_oura`'s `parse_api_green_ibi_quality_event`. - **`0x6B` `motion_period`** (19–31 B): 12-bit period `((b6<<8)|(b6>>6)) & 0xFFF`; byte6 bits`[5:4]`=leading-symbol count; then 2-bit codes, 4 per byte (MSB-first). MOTION_STATE enum: `0 NO_MOTION, 1 RESTLESS, 2 TOSSING, 3 ACTIVE`. [ringverse][open_ring] - **`0x50` activity_info / `0x51`,`0x52` activity_summary**: activity category + intensity (MET-class). Layout **(UNVERIFIED - partial)**; [ringverse] notes real_steps/activity_info have unresolved constants. Gate on fixtures. [ringverse] - **`0x50` decode formula (PR #960 investigation, live Gen 3, 2026-07-02) [oura-rs]:** byte0 = a `state` code (activity-category, meaning unconfirmed); every following byte = one MET sample, `met = byte × 0.1` for `byte < 0x80`, else `met = 12.8 + (byte − 128) × 0.2` (two-slope: 0.1-MET resolution to 12.7, 0.2 steps above). **Plausible against six real Gen 3 captures** across two sessions - a full day from steady resting (0.9–1.1 MET) through a vigorous-activity burst (7.4 MET), everything physiologically sane, nothing negative or absurd - but **NOT ground-truth-validated** against the Oura app's own MET/step numbers. Stays Tier B: NOOP decodes it (`OuraDecoders.decodeActivityInfo` → `OuraEvent.activityInfo`, both platforms) but gates it behind `allowTierB`, logs it for investigation only, and never folds it into `OuraStreamMapping`/scoring - and NEVER derives a step count from it. `0x51`/`0x52` activity_summary stay fully undecoded (raw Tier-B bytes only). + - **`0x50` MET cross-device validation (NOOP, 2026-07-15, live Gen 3):** the MET series TRACKS real activity + intensity - three separate walks read mean ≈ 3.4–4.1 MET (p50 ≈ 4.4) against a sleep floor of ≈ 0.9 MET, + and per-minute MET vs a Suunto `.fit` speed profile correlates **r = 0.89** (one walk, 13 min); the walk's + Suunto Energy 196 kJ ≈ 47 kcal matched the recorded workout. It UNDERREADS water (swims read near-rest - + optical/motion degraded). The stream is SPARSE with RING-SIDE cadence gaps (~86 % minute coverage on a + choppy day; ~6–19 min holes that recur DURING an unbroken drain, so they are the ring's own logging + cadence, not a decode drop) - so any daily active-minute total derived from it UNDERCOUNTS. This + corroborates the "plausible, tracks activity" read while keeping it Tier B: still not a step count, still + not ground-truth-validated against Oura's own numbers, still never scored. NOOP has no MET field in its + HR/strain data model, so `0x50` remains a diagnostic JSONL corpus (`oura-activity-.jsonl`); the ring's + path into NOOP activity is HR (live push + banked IBI, §6.4), never MET. open_oura consumes this same + `0x50` `met` as one input to its activity classifier (`activity_model.rs`). [open_oura-act] - **Real Steps (feature `0x0B`) server gating [open_oura-feat]:** real_steps is behind the server flag `activity/real_steps` (default **false**; `FeatureDefinitions.ActivityRealSteps`, Gen 3+), the same server-flag-off pattern as SpO2 (§7.1). This explains `0x7E`/`0x7F` never once appearing across the PR #960 live sessions - the ring isn't sending them, it is not a NOOP decode gap. `0x50` itself is an always-on base stream (not feature-gated), matching it appearing in every session. - **`0x7E`/`0x7F` real_steps_features 1/2** (18 B each): bit-packed step features merged across the paired events. **(UNVERIFIED - partial)** [ringverse]