Live HR/HRV + real-time dashboard, a Rust C-ABI core, and a native iOS app - #3
Live HR/HRV + real-time dashboard, a Rust C-ABI core, and a native iOS app#3martinappberg wants to merge 15 commits into
Conversation
…e iOS app Reverse-engineering / client: - Discover the working live-HR mechanism on Ring 4 (fw 2.12): the CONNECTED_LIVE push stream does not exist; instead SetNotification(0x3f) + daytime-HR CONNECTED_LIVE forces the green-LED measurement, which records 0x80 green_ibi_quality events (hr_bpm + per-beat IBI) that are drained incrementally. - oura-link: add request_until (bounded, stream-safe single-response) and drain_events_live (stream-safe incremental drain that tolerates the ~50 Hz ACM stream); rewrite `live-hr` onto the force-measure + 0x80 drain path; add a `measure` diagnostic. - oura-cli: new `live` web dashboard (HR/HRV from IBIs, motion, SpO2, battery) over SSE, with a single-writer poll loop. oura-ffi: new Rust staticlib exposing a small C ABI (AES auth + event decoders) for reuse on other platforms. iOS app (ios/): native SwiftUI client over CoreBluetooth, reusing the Rust core via an XCFramework. Tabs: Today / Live / Sleep / Settings. Features: scan/connect, app-auth, native Pair flow (SetAuthKey) + key import, live HR/HRV/motion, history sync + on-device hypnogram, a connection guide (place ring on charger pad), and silent auto-reconnect + auto-sync via a no-timeout pending connect. Docs: CLAUDE.md (feature map, live-HR findings, smart-alarm plan, iOS notes) and ios/README.md. Personal identifiers kept out; key.hex and oura.db are gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…paired rings The connect guide was popping up on launch even with a stored key, because auto-reconnect required a saved peripheral id (only written by a prior connect). Now: if a key exists we always reconnect silently — pending-connect to the known peripheral if we have its id, otherwise a quiet background scan (no timeout, no modal, no failure alert) that saves the id on first connect. The onboarding guide appears only when there is no key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Branding: gapped-gradient "ring + heartbeat" logo as an SVG (assets/), an app icon rendered from it (ios/tools/render-icon.swift → Assets.xcassets), and a matching SwiftUI RingLogo used in headers/guide. Shared Brand palette. - Redesign all tabs (Today/Live/Sleep/Settings) with a cohesive dark Oura-style design system: circular ScoreRing gauges, metric tiles with sparklines, a polished hypnogram + stage distribution, status banner, app header. - Cached history: persist synced events to disk (HealthStore) and load on launch, so real data shows immediately; the displayed model is only swapped in when a sync *completes* (never mid-sync) + a "last synced" timestamp. - Factory reset: SetAuthKey's counterpart (0x1a) with a destructive confirmation in Settings → clears the local key/peripheral/history and returns to pairing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mental sync - Create the central manager with a restore identifier and implement willRestoreState, so iOS preserves our BLE state and relaunches the app into the background for connection events (state preservation & restoration). On power-on we resume the restored peripheral (re-discover services → auth → sync). - Add incremental, background-safe sync (autoSyncIncremental): drains only events newer than a persisted cursor, merges/dedupes into the cached set, and runs inside a UIApplication background task. Auto-connect/restoration paths use it; the manual "Sync now" still does a full re-sync. This mirrors how the Oura app stays synced opportunistically in the background (it is not continuous live polling — see CLAUDE.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 092b07ddc2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// random 16-byte key, install it with `SetAuthKey`, store it in the Keychain, | ||
| /// then authenticate. No key typing — pairing *creates* the key. | ||
| func pairNewRing() async { | ||
| if writeChar == nil { connect(); await waitUntilReady() } |
There was a problem hiding this comment.
Abort pairing when no write characteristic is ready
When the scan/connect path times out, waitUntilReady() resumes even though writeChar is still nil, but this function continues into pairing and saves a freshly generated key. In that scenario requestUntil writes nothing and times out, leaving KeyStore.keyBytes() non-nil for a key that was never installed; the guide then switches to “Connect” and future auth attempts use the wrong key. Please return unless the link is actually ready after the wait, before generating/saving the key.
Useful? React with 👍 / 👎.
|
|
||
| /// Full re-sync from scratch (manual "Sync now"). Replaces the cached history. | ||
| func syncHistory() async { | ||
| guard state == .ready, !syncing else { return } |
There was a problem hiding this comment.
Gate manual sync while live polling is active
If the user taps “Sync now” while live mode is running, runLive() is already issuing drainEventsLive/battery requests on the same BLE characteristic, but this guard still allows a second history drain because the state remains .ready. Both drains register listeners that accept the same event frames and first 0x11 summary they see, so concurrent GetEvent requests can advance cursors from mixed batches and persist incomplete or incorrect history. Stop live mode first or include !liveActive in this guard.
Useful? React with 👍 / 👎.
| dbg("disconnected\(error.map { ": \($0.localizedDescription)" } ?? "")") | ||
| self.writeChar = nil | ||
| self.connecting = false | ||
| self.liveActive = false |
There was a problem hiding this comment.
Cancel live polling on unexpected disconnect
When the ring disconnects while live mode is active, this only flips the published flag; the liveTask loop checks only Task.isCancelled and the ACM listener is left installed. After auto-reconnect, that stale task continues issuing event drains/battery reads in the background even though the UI says live is off, which can race with auto-sync or a newly started live session. Cancel the task and remove the live listener here, rather than only setting liveActive.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 545bef04a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _ = KeyStore.saveHex(key.map { String(format: "%02x", $0) }.joined()) | ||
|
|
||
| guard let resp = await requestUntil(Req.setAuthKey(key), tag: 0x25, ext: nil, timeout: 3.0), | ||
| resp.payload.first == 0x00 else { | ||
| publish { self.state = .failed("SetAuthKey rejected"); self.status = "Pair failed — is the ring factory-reset?" } | ||
| return |
There was a problem hiding this comment.
When SetAuthKey is rejected (for example, the ring is not factory-reset), this flow has already stored a newly generated random key in the Keychain and then returns without removing it. Subsequent launches/buttons see KeyStore.keyBytes() != nil, so the app switches to the Connect/auto-reconnect path with a key that was never installed on the ring, effectively hiding the normal Pair flow until the user manually overwrites the key.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dbc6ed1f31
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _ = KeyStore.saveHex(key.map { String(format: "%02x", $0) }.joined()) | ||
|
|
||
| guard let resp = await requestUntil(Req.setAuthKey(key), tag: 0x25, ext: nil, timeout: 3.0), | ||
| resp.payload.first == 0x00 else { | ||
| KeyStore.clear() |
There was a problem hiding this comment.
Preserve generated pairing keys on uncertain install
On a flaky pairing attempt, this can still lose the only copy of the generated key. KeyStore.saveHex returns false on a Keychain write failure but the code proceeds to install that random key, and any SetAuthKey timeout or missed 0x25 response clears the saved key even though the ring may already have accepted it. In those cases subsequent auth is impossible without resetting the ring; abort if the key was not saved, and keep it unless you have definitive evidence the ring did not install it.
Useful? React with 👍 / 👎.
| let v = nums(e.json["temps_c"]).filter { $0 > 20 && $0 < 45 } | ||
| if let m = v.last { temp.append(.init(ts: e.timestamp, value: m)) } | ||
| case 0x6f, 0x70, 0x77: // spo2 | ||
| if let s = (e.json["spo2_percent"] as? NSNumber)?.doubleValue, s > 50 { |
There was a problem hiding this comment.
Read SpO2 samples from the decoded array
For decoded SpO2 events, decode_spo2 emits spo2_percent as an array of sample values, not a scalar NSNumber. With real 0x6f sync events this cast fails and the Today SpO2 metric stays empty even though the data is present; use the existing nums(...) helper and take/filter the latest sample.
Useful? React with 👍 / 👎.
| const d=m.d||{};const v=d.spo2_percent??d.spo2??d.pct; | ||
| if(v>50){$('sp').textContent=v;spC.push(v);$('spsub').textContent='measured';} |
There was a problem hiding this comment.
Normalize live SpO2 arrays before comparing
When the live drain forwards a decoded 0x6f SpO2 event, d.spo2_percent is the array produced by the event decoder, not a single number. If the event contains more than one sample, v > 50 coerces the array to NaN, so the dashboard never updates SpO2 despite receiving data; pick the last numeric element before comparing and pushing it.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ecca95e058
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func factoryReset() async { | ||
| guard state == .ready else { return } | ||
| dbg("factory reset requested") | ||
| _ = await requestUntil(Req.factoryReset, tag: 0x1b, ext: nil, timeout: 2.0) |
There was a problem hiding this comment.
Require reset acknowledgement before deleting the key
If the factory-reset request times out or the BLE link drops before the 0x1b response, this code still clears the Keychain and local ring state even though the ring may not have reset and may still require the old key. That leaves the app unable to authenticate to the same ring unless the user has an external backup of the key. Only clear local credentials after a valid reset acknowledgement, or surface an uncertain state like the pairing flow does.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcc18db1d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ibiBuffer.append(v) | ||
| } | ||
| while ibiBuffer.count > 40 { ibiBuffer.removeFirst() } |
There was a problem hiding this comment.
Keep HRV buffering on one executor
When the user stops live mode or the ring disconnects while a live drain is processing HR events, this append/remove runs from the live Task, but stopLive() and clearLiveAfterDisconnect() mutate the same ibiBuffer on bleQueue. Since Swift Array is not thread-safe, that Stop/disconnect-during-batch path can race and crash or corrupt the HRV buffer; keep all ibiBuffer access on one queue/executor or make it local to the live task.
Useful? React with 👍 / 👎.
| spo2.append(.init(ts: e.timestamp, value: s)) | ||
| } | ||
| case 0x4b, 0x4e, 0x5a: // sleep phases (hypnogram) | ||
| if let ph = e.json["phases"] as? [String] { hypnogram.append(contentsOf: ph) } |
There was a problem hiding this comment.
Limit sleep charts to the latest night
When the cache contains sleep-phase events from more than one night, this appends every phase sequence into one hypnogram, while SleepView and stageCounts present it as a single sleep. After syncing multiple nights, the epoch count and stage distribution become a concatenation across nights rather than the latest sleep; group phases by sleep period or retain only the newest hypnogram before rendering.
Useful? React with 👍 / 👎.
| .await; | ||
| // Baseline drain (no UI output) to find the newest event timestamp. | ||
| if let Ok(out) = client | ||
| .drain_events_live(0, Duration::from_millis(2500), |_| {}) |
There was a problem hiding this comment.
Avoid draining full history before starting live mode
On a ring with days or weeks of unsynced history, pressing Start takes the full sync path from cursor 0 before arming ACM. That can spend a long time downloading/decoding old events while the UI already says live, and if the baseline drain times out it leaves hr_cursor behind the actual newest event so old HR records can be shown as live; use a persisted/high-water cursor or a true current-time cursor instead of draining from zero on each live start.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 311175221f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| } | ||
| // Link is usable once the write characteristic is known. | ||
| if writeChar != nil { |
There was a problem hiding this comment.
Wait for notify setup before authenticating
CoreBluetooth enables notifications asynchronously; this marks the link ready and resumes auth immediately after calling setNotifyValue, before any didUpdateNotificationStateFor confirms the notify characteristic is active. On a fresh connection where the CCCD write is still pending, runAuth() can send the nonce request and miss its notification response, causing spurious auth/connect timeouts. Gate readiness on the notify characteristic being subscribed.
Useful? React with 👍 / 👎.
| guard batch.gotSummary else { | ||
| dbg("GetEvent timed out before summary at cursor \(start)") | ||
| break |
There was a problem hiding this comment.
Avoid saving partial histories after drain timeouts
When this timeout path is hit during syncHistory, the function returns the current cursor without any failure signal, and syncHistory immediately saves whatever was collected over the existing cache. In weak BLE/range conditions a manual Sync can therefore replace a complete cache with an empty or partial history while reporting success. Please propagate a failure/completion flag instead of breaking here.
Useful? React with 👍 / 👎.
| } else { | ||
| dbg("live: no feature-status response") | ||
| } | ||
| var cursor = await drainEventsLive(cursor: 0) { _ in } // baseline |
There was a problem hiding this comment.
Avoid draining full history before live starts
For users who have not synced recently, this baseline starts from cursor 0 and pulls the entire on-ring history before arming ACM or entering the live loop. Since the UI has already been put in “Live — measuring…”, Start live can sit for a long time with the green LED enabled but no motion/HR updates. Use the stored syncCursor, a latest high-water mark, or a bounded recent baseline instead.
Useful? React with 👍 / 👎.
|
@codex review |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 05d8d6d466
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let start_cursor = Store::open(&cli.db) | ||
| .ok() | ||
| .and_then(|store| store.cursor(&serial).ok()) | ||
| .unwrap_or(0); |
There was a problem hiding this comment.
Baseline live mode before emitting events
When oura live is started before any successful sync for this serial (or when the DB cannot be opened), this fallback passes cursor 0 into live::run; the live poll loop then drains from the beginning and emits every decoded historical 0x80/SpO2/temp event to the dashboard as if it were live. On rings with existing history this can show stale HR values and spend a long time draining old data before current measurements; baseline to the current high-water mark or require a valid cursor before streaming UI samples.
Useful? React with 👍 / 👎.
| publish { self.syncing = false; self.status = "Sync timed out - try again closer to the ring" } | ||
| return | ||
| } | ||
| UserDefaults.standard.set(Int(drain.cursor), forKey: "syncCursor") |
There was a problem hiding this comment.
Persist the sync cursor after saving events
In the incremental iOS sync path, the durable cursor is advanced before the freshly drained events are merged and written to HealthStore. If the app is killed here, or if HealthStore.save fails (it currently ignores write errors), the next auto-sync starts from the advanced cursor and skips those unsaved events until a manual full resync from zero is performed; persist the cursor only after the cache write succeeds or make the update atomic.
Useful? React with 👍 / 👎.
| var seen = Set(all.map { "\($0.tag)-\($0.timestamp)" }) | ||
| for e in fresh where seen.insert("\(e.tag)-\(e.timestamp)").inserted { all.append(e) } |
There was a problem hiding this comment.
Don't collapse events by tag and timestamp only
This merge treats tag-timestamp as the event identity, but history packets can differ by body even when they share the same tag and ring decisecond (the Rust store de-dupes on serial, tag, ring_timestamp, body). In that case an incremental iOS sync drops all but the first packet from the cache and derived HealthData; include the body/decoded JSON in the identity or store the raw body alongside cached events.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ac5f18b2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a232611b08
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .frame(maxWidth: .infinity).padding(.vertical, 8) | ||
| } | ||
| .buttonStyle(.borderedProminent).tint(ring.liveActive ? Brand.dim : Brand.hr) | ||
| .disabled(ring.state != .ready) |
There was a problem hiding this comment.
Disable live start while sync is running
When auto-sync or a manual history sync is already running, ring.state remains .ready, so this button can still start live mode. startLive() also only checks state/liveActive, which lets the live task and sync task run drainEventsLive concurrently on the same BLE link; their GetEvent writes and listeners can consume each other's event/summary frames, producing stale live readings or advancing the saved sync cursor from a corrupted drain. Gate this on !ring.syncing as well, preferably in both the view and startLive().
Useful? React with 👍 / 👎.
| if let arr = ev.json["hr_bpm"] as? [Any] { | ||
| let bpms = arr.compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 > 30 && $0 < 240 } |
There was a problem hiding this comment.
Derive live BPM from IBI when hr_bpm is empty
For 0x80 live events where the decoder provides plausible ibi_ms but leaves hr_bpm empty because the quality bits are not 1, this branch skips the BPM update entirely. The live path already treats the quality byte as unreliable for HRV by filtering IBIs physiologically, so users can receive valid beat intervals while the live HR gauge stays blank; compute a BPM fallback from the last plausible IBI when hr_bpm has no value.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9619e7009
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .await; | ||
| // Keep the live cursor across Stop/Start cycles so the page does | ||
| // not replay HR events recorded by the previous live session. | ||
| hr_cursor = hr_cursor.max(start_cursor); |
There was a problem hiding this comment.
Fast-forward before streaming live events
When oura live is started after the DB cursor is behind the ring (for example the last oura sync was hours ago, or a previous live session recorded HR events that were never stored), this only resets hr_cursor to that old start_cursor. The next drain_events_live call forwards all 0x80/temp/SpO2 events since that cursor over SSE, so the dashboard can display stale historical HR as “live” before it catches up; do a discard-only baseline drain on the rising edge or persist the live cursor before forwarding events.
Useful? React with 👍 / 👎.
| dbg("live: no feature-status response") | ||
| } | ||
| let savedCursor = UInt32(truncatingIfNeeded: UserDefaults.standard.integer(forKey: "syncCursor")) | ||
| var cursor = max(liveCursor ?? savedCursor, savedCursor) |
There was a problem hiding this comment.
Baseline the iOS live cursor before display
When the Live tab starts with syncCursor from an earlier sync (or after an app restart), this cursor can be behind the current ring history. The loop immediately drains from it and passes every queued 0x80 event to handleLiveEvent, so old HR/HRV samples can be shown as live measurements; baseline-drain to the current high-water mark or persist a separate live cursor before updating the UI.
Useful? React with 👍 / 👎.
Per the deep-research pass (docs/research/2026-07-07-raw-signal-applications.md, finding Th0rgal#3): LF/HF, LFnu, HFnu are the same scalar re-encoded, and LF power is not a cardiac sympathetic index despite widespread misuse (Billman 2013 x2, Eckberg 1997). ADR-011 records the decision that oura-mobile's HRV surface exposes only time-domain metrics and forbids the specific misleading terms in any user-bound string. Regression: two new tests in derived.rs — hrv_dto_has_no_frequency_domain_fields (structural: HrvSummaryDto can't grow LF/HF/LFnu/HFnu/VLF/spectral fields) and hrv_surface_stays_honest (grep-scans the whole crate for forbidden terms, skipping lines that carry the intentional ADR-011-OK marker). CI now catches any drift. Cleanup while here: removed dead SyncController.hrv path (nothing displayed it since meaning-first shipped), and updated the stale 'resting RMSSD' comment on OuraText.hero. 155 workspace tests, flutter analyze clean.
|
This is awesome! @martinappberg, do you think it's possible to add syncing to Apple HealthKit? Especially sleep stages & heartrate. |
Overview
This adds, on top of the existing reverse-engineering work:
oura live),oura-ffi) that re-exposes the tested protocol pieces for other platforms,ios/) — a SwiftUI client over CoreBluetooth that reuses the Rust core and reads the ring with no Oura account.Everything was developed and tested against an Oura Ring 4 (
ORE_06, firmware 2.12.0) paired with a client-generated key, on iPhone (iOS 26).Reverse-engineering: how live HR actually works
The decompiled app suggests
SetFeatureMode(DAYTIME_HR, CONNECTED_LIVE)makes the ring push an IBI stream (0x2f/ sub-tag0x28). On real firmware it doesn't — the command is ACK'd and the ring enters the measuring state, but no push frames ever arrive (confirmed on Ring 4, consistent with the earlier Ring 3 Horizon notes). The latest-value poll also never carries an IBI on this firmware.The path that does work (and that the app's "measure pulse" uses):
SetNotification(0x3f)— the handshake step that lets the ring emit async notifications.SetFeatureMode(DAYTIME_HR, CONNECTED_LIVE)— forces the green-LED measurement.0x80green_ibi_quality_events into the history stream ({hr_bpm, ibi_ms[7], quality}), roughly one burst every few seconds.So "live" HR is rapid event-sync, not a raw stream. Example reading (resting):
0x80 {"hr_bpm":[73,71,78,74,71,73],"ibi_ms":[828,820,834,763,802,845,818]}→ ~73 bpm. (Thequalitybyte proved unreliable for gating, so HRV filters IBIs by physiological range instead.)Rust client & protocol (
crates/)oura-linkOuraClient::request_until— a bounded, single-response request that ignores unrelated frames (the quiet-windowtransacthangs during a high-rate stream).OuraClient::drain_events_live— a stream-safe incremental event drain that tolerates the ~50 Hz accelerometer stream (waits for the0x11summary per batch, ignores ACM/control frames).oura-clilive— a self-contained real-time web dashboard (HR/HRV from IBIs, motion, SpO2, battery) over SSE, with a single-writer poll loop.live-hr— rewritten onto the force-measure +0x80-drain path (the previous push-stream path could never produce a reading on this firmware).measure— a diagnostic that exercises the HR paths (the tool used to find the mechanism above).New crate:
oura-ffiA
staticlibexposing a minimal C ABI over the genuinely hard, byte-level pieces — AES-128/ECB auth (oura_encrypt_nonce) and the event-body decoders (oura_decode_event/oura_event_name). Nothing async crosses the boundary; transport/framing/orchestration stay native to each consumer.Native iOS app (
ios/)A native SwiftUI client that reuses the Rust core via an XCFramework:
transact,requestUntil,drainEventsLive) mirror the Rust client.SetAuthKey(factory-reset ring), stored in the Keychain; plus an "import existing key" path for a ring already paired via the CLI.0x80-drain path) with a circular gauge + sparklines.sleep_phase_*events) rendered with a stage-distribution breakdown — no cloud needed.willRestoreState) so iOS relaunches the app for connection events; incremental auto-sync runs inside a background task. This mirrors the app's opportunistic background sync (not continuous polling, which iOS doesn't allow).assets/open-oura-logo.svg), an app icon rendered from it, and a matching SwiftUI vector.Build:
./ios/build-rust.sh(XCFramework) →xcodegen generate→ set your ownDEVELOPMENT_TEAM→ run on a device (the Simulator has no Bluetooth). Seeios/README.md.Documentation
CLAUDE.md— feature map (live vs. sync-history vs. cloud), the live-HR findings, the C-ABI core, and the iOS app/architecture + connecting gotchas.ios/README.md— build/run/first-use, signing, and the BLE caveats learned on device.Notes
key.hexandoura.dbare gitignored; signing config is generic (no team id committed).🤖 Generated with Claude Code