Skip to content

Live HR/HRV + real-time dashboard, a Rust C-ABI core, and a native iOS app - #3

Open
martinappberg wants to merge 15 commits into
Th0rgal:mainfrom
martinappberg:ios-native-app
Open

Live HR/HRV + real-time dashboard, a Rust C-ABI core, and a native iOS app#3
martinappberg wants to merge 15 commits into
Th0rgal:mainfrom
martinappberg:ios-native-app

Conversation

@martinappberg

Copy link
Copy Markdown

Overview

This adds, on top of the existing reverse-engineering work:

  1. The live heart-rate mechanism for the ring (figured out empirically and verified on hardware),
  2. A cloud-free real-time health dashboard (oura live),
  3. A small C-ABI core (oura-ffi) that re-exposes the tested protocol pieces for other platforms,
  4. A native iOS app (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-tag 0x28). 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):

  1. SetNotification(0x3f) — the handshake step that lets the ring emit async notifications.
  2. SetFeatureMode(DAYTIME_HR, CONNECTED_LIVE) — forces the green-LED measurement.
  3. The ring records 0x80 green_ibi_quality_events into the history stream ({hr_bpm, ibi_ms[7], quality}), roughly one burst every few seconds.
  4. Incrementally drain those events and decode them → live BPM + per-beat IBI (→ HRV/RMSSD).

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. (The quality byte proved unreliable for gating, so HRV filters IBIs by physiological range instead.)


Rust client & protocol (crates/)

  • oura-link
    • OuraClient::request_until — a bounded, single-response request that ignores unrelated frames (the quiet-window transact hangs during a high-rate stream).
    • OuraClient::drain_events_live — a stream-safe incremental event drain that tolerates the ~50 Hz accelerometer stream (waits for the 0x11 summary per batch, ignores ACM/control frames).
  • oura-cli
    • live — 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-ffi

A staticlib exposing 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:

SwiftUI (Today / Live / Sleep / Settings)
  → OuraRing (CoreBluetooth + protocol orchestration, Swift)
  → OuraFFI.xcframework  →  crates/oura-ffi (AES auth + event decoders)
  • Connect & auth over CoreBluetooth; request/response primitives (transact, requestUntil, drainEventsLive) mirror the Rust client.
  • Pairing — a native flow that generates a 16-byte key and installs it with SetAuthKey (factory-reset ring), stored in the Keychain; plus an "import existing key" path for a ring already paired via the CLI.
  • Live — real-time HR / HRV / motion / battery (the 0x80-drain path) with a circular gauge + sparklines.
  • History sync — full + incremental (from a persisted cursor); cached to disk so real data shows instantly on launch and the displayed model is only swapped in once a sync completes.
  • Sleep — the on-device hypnogram (sleep_phase_* events) rendered with a stage-distribution breakdown — no cloud needed.
  • Connection guide — onboarding that explains the ring must be on its charging pad (or worn) to be connectable, with live connection state.
  • Auto-reconnect — a no-timeout pending connect that silently reconnects when the ring wakes.
  • Background — Core Bluetooth state preservation & restoration (restore identifier + 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).
  • Factory reset — gated behind a destructive confirmation; clears local key/peripheral/history and returns to pairing.
  • Branding — a gapped gradient "ring + heartbeat" logo (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 own DEVELOPMENT_TEAM → run on a device (the Simulator has no Bluetooth). See ios/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

  • No secrets in the tree or history — key.hex and oura.db are gitignored; signing config is generic (no team id committed).
  • Firmware flashing remains blocked (encrypted, device-resident key) and is not attempted here.

🤖 Generated with Claude Code

martinappberg and others added 5 commits June 25, 2026 12:42
…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>
@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
/// 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() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated

/// Full re-sync from scratch (manual "Sync now"). Replaces the cached history.
func syncHistory() async {
guard state == .ready, !syncing else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
dbg("disconnected\(error.map { ": \($0.localizedDescription)" } ?? "")")
self.writeChar = nil
self.connecting = false
self.liveActive = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment on lines +343 to +348
_ = 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear rejected pairing keys

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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift
Comment thread ios/OpenOura/BLE/OuraRing.swift
@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment on lines +343 to +347
_ = 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/Core/HealthData.swift Outdated
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread crates/oura-cli/src/live.html Outdated
Comment on lines +157 to +158
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';}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
func factoryReset() async {
guard state == .ready else { return }
dbg("factory reset requested")
_ = await requestUntil(Req.factoryReset, tag: 0x1b, ext: nil, timeout: 2.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment on lines +594 to +596
ibiBuffer.append(v)
}
while ibiBuffer.count > 40 { ibiBuffer.removeFirst() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/Core/HealthData.swift Outdated
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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread crates/oura-cli/src/live.rs Outdated
.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), |_| {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
}
}
// Link is usable once the write characteristic is known.
if writeChar != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment on lines +307 to +309
guard batch.gotSummary else {
dbg("GetEvent timed out before summary at cursor \(start)")
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
} else {
dbg("live: no feature-status response")
}
var cursor = await drainEventsLive(cursor: 0) { _ in } // baseline

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/oura-cli/src/main.rs Outdated
let start_cursor = Store::open(&cli.db)
.ok()
.and_then(|store| store.cursor(&serial).ok())
.unwrap_or(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
publish { self.syncing = false; self.status = "Sync timed out - try again closer to the ring" }
return
}
UserDefaults.standard.set(Int(drain.cursor), forKey: "syncCursor")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
Comment on lines +683 to +684
var seen = Set(all.map { "\($0.tag)-\($0.timestamp)" })
for e in fresh where seen.insert("\(e.tag)-\(e.timestamp)").inserted { all.append(e) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/oura-cli/src/live.rs Outdated
Comment thread ios/OpenOura/BLE/OuraRing.swift Outdated
@Th0rgal

Th0rgal commented Jul 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +622 to +623
if let arr = ev.json["hr_bpm"] as? [Any] {
let bpms = arr.compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 > 30 && $0 < 240 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Th0rgal

Th0rgal commented Jul 9, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

foodlbs added a commit to foodlbs/open_oura that referenced this pull request Jul 11, 2026
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.
@WeeJeWel

Copy link
Copy Markdown

This is awesome!

@martinappberg, do you think it's possible to add syncing to Apple HealthKit? Especially sleep stages & heartrate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants