diff --git a/.gitignore b/.gitignore
index a5a90c1f..a2d4ef6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,8 @@ reverse/android/jadx-main/
# Auth keys generated by `oura pair`.
*.key
+*.hex
+key.hex
# Personal data exports and local working notes (kept off the repo).
*.csv
@@ -38,3 +40,9 @@ notes/
# mitmproxy-captured tokens (local only)
tools/oura_tokens.txt
+
+# iOS build artifacts (generated by xcodegen / build-rust.sh)
+ios/OpenOura.xcodeproj/
+ios/OuraFFI/OuraFFI.xcframework/
+ios/build/
+ios/DerivedData/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..1546e998
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,247 @@
+# CLAUDE.md — open_oura
+
+Guidance for Claude Code (and humans) working in this repo. Read this first, then
+the docs it points to. The deep protocol reference lives in `docs/`; this file is
+the orientation map, the **feature inventory** (what we can actually read from a
+ring), and the **smart-alarm / iPhone-app** plan that motivates current work.
+
+## What this project is
+
+A **cloud-free** Oura ring client. We reverse-engineered the ring's BLE protocol
+and talk to it directly — pair, authenticate, sync history events, read live-ish
+signals — with no Oura account. See `README.md` for the pitch and `docs/` for the
+protocol internals.
+
+## Build / run
+
+```bash
+cargo build --release
+./target/release/oura scan # find rings
+./target/release/oura --name "Oura ..." --key-file key.hex pair # factory-reset ring only
+./target/release/oura --key-file key.hex info # firmware/serial/battery/caps
+./target/release/oura --key-file key.hex sync # drain history events -> oura.db
+./target/release/oura --key-file key.hex live # NEW: real-time health dashboard
+./target/release/oura --key-file key.hex viz # 3D motion visualizer (accel)
+```
+
+Auth key is a 16-byte hex file (`key.hex`, gitignored). It is installed once on a
+**factory-reset** ring (`pair`) and re-sent each connection. macOS: grant the
+terminal Bluetooth permission.
+
+## Crate layout
+
+`crates/` is split by concern (see `docs/architecture.md`):
+- `oura-protocol` — packet framing, request builders, event-body decoders (`events.rs`), auth.
+- `oura-link` — BLE transport (`ble.rs`), the high-level `OuraClient` (`client.rs`).
+- `oura-analysis` — ported on-device metric algorithms (HRV, temperature, sleep, …).
+- `oura-store` — SQLite persistence (events + scalar `readings`).
+- `oura-cli` — the `oura` binary; web UIs (`live.rs`, `viz.rs`, `game.rs`) share `motion_server.rs` plumbing.
+
+## The device on hand (important for live behavior)
+
+`info` reports `Hardware: ORE_06`, `Firmware: 2.12.0`. Per `docs/firmware-update.md`,
+the **`oreo` codename = Gen 4 / Ring 4**, so `ORE_*` is a Ring 4. The repo's other
+test devices were a Ring 3 Horizon (`BLB_03`, fw 3.x) and a Ring 5. **Pair, auth,
+battery, history-event sync, and the accelerometer stream all work on this Ring 4**
+— the GATT layout, framing, and auth flow are shared across generations. Live HR is
+where firmware behavior diverges (below). A factory reset wipes the auth key and
+user data but does **not** change firmware; new firmware only comes via Oura's
+signed OTA (needs the official app/cloud — `docs/firmware-update.md`).
+
+## Feature inventory — what a ring actually gives us
+
+Three layers (full detail: `docs/data-recovery-map.md`):
+
+### 1. Live / real-time over BLE (only while connected)
+
+| Signal | Mechanism | Status on this Ring 4 |
+| --- | --- | --- |
+| **Accelerometer** | `SetRealtime(ACM)` → ~50 Hz x/y/z indications (tag `0x33`) | ✅ **Works** — true stream (powers `viz`, `game`, `live`) |
+| **Heart rate + HRV** | force-measure → ring records `0x80` events → drain them | ✅ **Works** — see below |
+| **SpO2** | enable SpO2 + drain `0x6f`-family events | ◐ needs long stillness; rarely fires |
+| **Skin temp** | recorded as `0x46`/`0x69`/`0x75` during measurement | ◐ occasional |
+| **Battery / charging** | poll `0x0c` | ✅ reliable (works on charger) |
+
+**Key empirical finding — how live HR actually works (the "no beats" answer).**
+The decompiled app suggests `SetFeatureMode(DAYTIME_HR, CONNECTED_LIVE)` makes the
+ring *push* IBI notifications (`0x2f`/sub-tag `0x28`). **It does not.** Confirmed
+empirically on this Ring 4 (fw 2.12.0):
+
+- `CONNECTED_LIVE` is ACK'd (`2f03230200`) and forces `state=idle → measuring`, but
+ **no** `0x2f`/`0x28` push frames ever arrive — even with notifications enabled.
+- `GetFeatureLatestValues(DAYTIME_HR)` returns `result=0 state=2` but the IBI field
+ stays `00 00` on this firmware (the Ring 3 Horizon populated it; 2.12.0 doesn't).
+
+The path that **works** (and is what the app's "Measure pulse" does):
+
+1. `SetNotification(0x3f)` — the load-bearing handshake step our CLI was skipping;
+ without it the ring won't emit async notifications (`sync` works without it only
+ because event-drain is a pull, and ACM works because it's explicitly armed).
+2. `SetFeatureMode(DAYTIME_HR, CONNECTED_LIVE)` — forces the green-LED measurement.
+3. The ring **records** `0x80 green_ibi_quality_event`s into the history stream
+ (~one every 3–8 s), each carrying `{hr_bpm:[…], ibi_ms:[7 beats], quality:[…]}`.
+4. **Incrementally drain** those events (`OuraClient::drain_events_live`) and decode
+ them → live bpm + per-beat IBI (→ **HRV/RMSSD**). "Live" = rapid event-sync, not
+ a raw push stream.
+
+Confirmed reading (resting): `0x80 {"hr_bpm":[73,71,78,74,71,73],
+"ibi_ms":[828,820,834,763,802,845,818],"quality":[0,1,1,1,1,1,1]}` → ~73 bpm. Note
+the `quality` byte is unreliable for gating (clean resting beats showed `0/1`,
+noisy moving beats `2/3`); filter IBIs by physiological range instead (400–1300 ms).
+
+`oura live-hr` and `oura live` both use this path. The throwaway `oura measure`
+command is the diagnostic that nailed it (before/after event-diff).
+
+### 2. History events (synced after the fact — the real data) — `oura sync`
+
+This is where the rich data lives. The ring records and summarizes on-device; we
+drain it incrementally (`docs/sync-orchestration.md`). Decoders: `events.rs`.
+
+**Raw sample events:** `ibi`/`ibi_and_amplitude` (`0x44`/`0x60`/`0x71`, per-beat
+IBI → HR + HRV), `temp` (`0x46`/`0x69`/`0x75`, skin °C), `motion` (`0x47`/`0x6b`),
+`spo2` (`0x6f`/`0x70`/`0x77`), `raw_ppg`, `on_demand_meas` (`0x62`, spot
+HR/HRV/breath/temp). **HRV** as 5-min avg RMSSD + HR (`0x5d`, `hrv_event`).
+
+**Ring-computed summaries:** `sleep_summary_1..4` (`0x49`/`0x4c`/`0x4f`/`0x58` —
+bedtime, stage durations, lowest HR), **`sleep_phase_*`** (`0x4b`/`0x4e`/`0x5a` —
+the **hypnogram**: 2-bit DEEP/LIGHT/REM/AWAKE per epoch), `activity_information`
+(`0x50`, 13 MET-level bins + steps), `wear`/`state_change`.
+
+So **sleep staging and MET binning happen on the ring** and sync down as events.
+
+### 3. RData bulk raw (`0x03`) — research opt-in
+
+Full-rate raw PPG/ACM/gyro/temp via a flash session. Heavy, gated, mandatory
+teardown. Not used in normal pulls. `oura rdata state|stop|clear`.
+
+### What we *cannot* reproduce cloud-free
+
+The 0–100 **scores** (Readiness/Sleep/Activity/Stress) and workout
+auto-classification. Correction from later RE (`docs/algorithms/README.md`): the
+scores are actually computed **on-phone** by the native `ecore` engine, not the
+cloud — and are being ported into `oura-analysis` (calibration-fit where the
+`.rodata` tables won't read back). The genuine blocker is the **sleep hypnogram
+model** (`SleepNet`, an encrypted PyTorch `.pt.enc` whose AES-GCM key is
+**server-delivered** — `docs/algorithms/sleepnet.md`). But note: the **ring already
+emits its own hypnogram** as `sleep_phase_*` events, so we get stages without
+SleepNet — just not Oura's exact phone-side restaging.
+
+## The `oura live` dashboard (current work)
+
+`crates/oura-cli/src/live.rs` + `live.html`. A self-contained web health dashboard
+(no CDN). One BLE connection; **Start** enters live mode, **Stop** (or closing the
+tab) returns the ring to normal (`AUTOMATIC` modes, realtime off).
+
+Architecture worth knowing before editing:
+- **One writer task** (`spawn_poll_loop`) owns *all* protocol writes so they never
+ race. HTTP `/start`/`/stop` only flip an `AtomicBool`; the loop acts on the edge.
+- On Start it does `SetNotification(0x3f)` + force daytime-HR `CONNECTED_LIVE`,
+ baseline-drains to position an event cursor at "now", then arms the ACM stream.
+- A **parser task** turns raw accel notifications into `accel` JSON over SSE.
+- HR/temp/SpO2 come from `OuraClient::drain_events_live` (new) — a **stream-safe**
+ incremental event drain (bounded per-batch wait, ignores accel frames) that pulls
+ freshly-recorded `0x80`/temp/SpO2 events while the ~50 Hz accel stream is live.
+ (The quiet-window `transact`/`request` would hang forever during that stream;
+ `request_until` is the bounded single-response variant used for battery.)
+- The page draws hand-rolled canvas charts for HR / motion / temp / SpO2 / battery,
+ computes **HRV (RMSSD)** from the streamed IBIs, and shows live **restlessness %**.
+
+## Long-term goal: a Sleep-Cycle-style smart alarm, in our own app
+
+The user's target: a "wake in your optimal window" alarm (e.g. wake me between
+08:00–08:30 at the lightest point of my sleep). Feasibility from the feature map:
+
+**We have the right signals — and richer than a phone-only app.** Sleep-Cycle-class
+apps stage sleep almost entirely from **accelerometer movement** (phone on the
+mattress). We get, from the ring, all of: per-beat **HR** + **HRV** (autonomic tone
+— HR drops and HRV rises in deep sleep, both shift before/at wake), **motion/MAD**
+(restlessness, the classic wake signal), **skin temperature**, and the ring's own
+**hypnogram** (`sleep_phase_*`). That's a strong multi-signal basis for detecting
+light-sleep / near-wake windows.
+
+**The architecture question is *when* the signals are available:**
+- During sleep the ring is **not connected** to the phone — it records to flash and
+ syncs on the next connection. So a naive "read live HR at 08:00 and decide" won't
+ work: live HR is unreliable anyway (above), and the ring isn't streaming overnight.
+- **Two viable designs:**
+ 1. **Wake-window polling:** a few minutes before the earliest alarm time, the app
+ connects to the ring and **syncs the recent history events** (they're already
+ recorded), reconstructs the last ~30–60 min of HR/HRV/motion/stages, and picks
+ the lightest moment to fire the alarm. This is the realistic path — it uses the
+ reliable history channel, not the flaky live channel.
+ 2. **Continuous-ish motion during the window:** in the final window, also arm the
+ **accelerometer stream** (which *does* work live) and watch movement in real
+ time as the immediate trigger, blended with the synced stage history.
+- We do **not** need Oura's cloud or the SleepNet model: the ring emits its own
+ stages, and we have the raw signals to run our own light/deep classifier if we
+ want independence from the firmware's staging.
+
+**On-demand fresh HR/HRV is proven** (the live-HR work above): in the wake window
+the app can connect, force a green-LED measurement, and within ~10–20 s pull fresh
+`0x80` events → current HR + HRV — *plus* the live accelerometer for movement. So at
+decision time we have real-time HR, HRV and motion, not just stale history. That's a
+strong trigger basis.
+
+**Open research questions (probe these with `oura live` + `oura sync`):**
+- How fresh is the synced history near "now"? What's the event latency/granularity
+ for HR/HRV/motion in the last few minutes before a connect?
+- Does arming the accelerometer stream while worn-and-asleep stay stable for the
+ ~30-min window without waking the user / draining battery?
+- How long does a green-LED measurement take to lock a stable beat overnight (skin
+ contact differs from daytime)? Tune the force-measure → first-`0x80` latency.
+- Validate our own light-vs-deep classifier against the ring's `sleep_phase_*`.
+
+## iPhone app — `ios/` (built)
+
+A native SwiftUI app exists under `ios/` (see `ios/README.md`). Architecture:
+
+- **`crates/oura-ffi`** — a Rust `staticlib` exposing a tiny C ABI over the tested
+ core: `oura_encrypt_nonce` (AES auth) + `oura_decode_event`/`oura_event_name`
+ (the `events.rs` decoders). Built into `OuraFFI.xcframework` by `ios/build-rust.sh`
+ (device `aarch64-apple-ios` + sim `aarch64-apple-ios-sim`). Nothing async crosses
+ the boundary.
+- **Swift** (`ios/OpenOura`) does the rest natively: `OuraProtocol.swift` (GATT
+ UUIDs, `tag|len|payload` framing, request builders), `OuraRing.swift`
+ (CoreBluetooth + the request/response primitives — `transact`, `requestUntil`,
+ `drainEventsLive` — auth, pair, live mode, history sync), SwiftUI tabs
+ (Today/Live/Sleep/Settings), Keychain key store.
+- **Pairing**: `OuraRing.pairNewRing()` mirrors `oura pair` — generate a random
+ 16-byte key, `SetAuthKey` (factory-reset ring only), store in Keychain. The paste
+ path is a secondary "import existing key" for a ring already paired via the CLI.
+- **Project**: generated by `xcodegen` from `ios/project.yml` (set your own
+ `DEVELOPMENT_TEAM` — a free Personal Team works, a paid team avoids the 7-day
+ expiry). Compile-check without signing: `xcodebuild -project
+ ios/OpenOura.xcodeproj -scheme OpenOura -sdk iphoneos26.5 build
+ CODE_SIGNING_ALLOWED=NO`. Device deploy needs the iOS platform component installed
+ (Xcode → Settings → Components) + `-allowProvisioningUpdates`.
+- **Auto-reconnect/sync**: `OuraRing.autoReconnect()` uses a no-timeout CoreBluetooth
+ pending connect on the saved peripheral id, so the app silently reconnects when the
+ ring wakes (and auto-syncs); toggles in Settings.
+- **Background**: the central manager has a restore identifier + `willRestoreState`
+ (CB state preservation/restoration) so iOS relaunches the app in the background for
+ connection events; auto-sync is **incremental** (drains from a persisted cursor,
+ merges into the on-disk cache) and runs inside a `UIApplication` background task.
+ This mirrors Oura's opportunistic background sync — not continuous live polling.
+- The Simulator has **no Bluetooth** — ring features only work on a real device.
+- **Connecting gotchas** (verified on device): the ring must be **awake** to accept a
+ connection — off-charger-and-not-worn it deep-sleeps and `connect()` hangs with no
+ `didConnect` (iOS sees a stray advert but can't grab a connectable window). On the
+ **charging pad** (or worn) it connects immediately. Also BLE is one-central: a Mac
+ with the ring bonded auto-reconnects and steals the slot — `blueutil -p 0` on the
+ Mac (or "Forget" the ring) while testing the phone. The app surfaces these via a
+ `ConnectGuideView` onboarding sheet (place ring on pad → Connect, with live state)
+ and a connect timeout that fails loudly instead of hanging.
+- iOS BLE background modes (`bluetooth-central`, already in Info.plist) allow a
+ timed wake-window connect for the smart alarm.
+
+## Conventions / gotchas
+
+- Prefer passive, read-only requests. reset / DFU / factory-reset / flight-mode are
+ danger-gated — never send during normal use.
+- Multi-byte ints are little-endian; extended ops ride outer tag `0x2f` with the
+ first payload byte as the ext op.
+- During a live accel stream, **don't** use the quiet-window `request`/`transact` —
+ use `request_until` (bounded, stream-safe).
+- Never commit keys or captures (`key.hex`, `captures/`, `reverse/` are gitignored).
+- The store retains raw event bodies, so new decoders apply retroactively via
+ `oura redecode` / `Store::redecode` (no re-sync needed).
diff --git a/Cargo.lock b/Cargo.lock
index aaf2154e..fd8c3343 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -774,6 +774,14 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "oura-ffi"
+version = "0.1.0"
+dependencies = [
+ "oura-protocol",
+ "serde_json",
+]
+
[[package]]
name = "oura-link"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 67f2a326..fc0a529e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
"crates/oura-analysis",
"crates/oura-store",
"crates/oura-cli",
+ "crates/oura-ffi",
]
[workspace.package]
diff --git a/assets/open-oura-logo.svg b/assets/open-oura-logo.svg
new file mode 100644
index 00000000..0cd570a1
--- /dev/null
+++ b/assets/open-oura-logo.svg
@@ -0,0 +1,20 @@
+
diff --git a/crates/oura-cli/src/live.html b/crates/oura-cli/src/live.html
new file mode 100644
index 00000000..0fcf84b2
--- /dev/null
+++ b/crates/oura-cli/src/live.html
@@ -0,0 +1,189 @@
+
+
+
+Oura — live health dashboard
+
+
+
+
Oura · live health dashboard
+ —
+
+ idle
+
+
+
+
+
+
+
Heart rate
+
--bpm
+
HRV -- ms · 0 beats
+
press Start, then wait ~10–20 s for the first reading…
+
+
+
+
+
Motion
+
--g
+
0 Hz · restless 0%
+
accelerometer — live stream
+
+
+
+
+
Skin temperature
+
--°C
+
recorded during measurement
+
+
+
+
+
Blood oxygen
+
--% SpO₂
+
needs long stillness to measure
+
+
+
+
+
Battery
+
--%
+
—
+
+
+
+
+
+ How this works: pressing Start enables notifications and forces the green-LED
+ HR measurement; the ring records 0x80 HR events (bpm + per-beat IBI +
+ quality) which we sync every ~2 s — that's your live HR, and the IBIs give
+ HRV (RMSSD). Motion is a true ~50 Hz accelerometer stream. Skin temp
+ appears when the ring records it; SpO₂ needs long stillness. Press Stop to
+ return the ring to its normal background mode.
+
+
+
+
diff --git a/crates/oura-cli/src/live.rs b/crates/oura-cli/src/live.rs
new file mode 100644
index 00000000..d2d97564
--- /dev/null
+++ b/crates/oura-cli/src/live.rs
@@ -0,0 +1,312 @@
+//! Real-time **health dashboard** (`oura live`).
+//!
+//! "Live mode" holds one BLE connection and pushes every signal the ring will
+//! give in real time to a self-contained web page (no CDN/external scripts):
+//!
+//! - **Motion** — the accelerometer is the only true high-rate BLE stream
+//! (~50 Hz), armed with `SetRealtime(ACM)`. Rock solid; the load-bearing signal
+//! for movement/restlessness.
+//! - **Heart rate** — this firmware never pushes an IBI stream, so we hold daytime
+//! HR in `CONNECTED_LIVE` (forcing the optical sensor on) and *poll*
+//! `GetFeatureLatestValues`. A value appears only when the ring locks a beat
+//! (intermittent by design); the dashboard shows ring state meanwhile.
+//! - **SpO2** — polled the same way (needs long stillness to produce a value).
+//! - **Battery / charging** — polled on a slow timer; reliable even on the charger.
+//!
+//! Pressing **Stop** (or closing the tab) returns the ring to normal: realtime
+//! off, features back to `AUTOMATIC` (the ring's own periodic background mode).
+
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+use tokio::sync::broadcast;
+
+use oura_link::ble::BleTransport;
+use oura_link::client::AcmSample;
+use oura_link::transport::Transport;
+use oura_link::OuraClient;
+use oura_protocol::protocol::{self, feature, feature_mode, req_set_feature_mode, req_set_notification};
+
+type Client = Arc>;
+
+/// Serve the dashboard at `127.0.0.1:port`. `minutes` is how long each realtime
+/// arming lasts before the poll loop re-arms it (the ring auto-stops otherwise).
+pub async fn run(client: OuraClient, port: u16, minutes: u16, start_cursor: u32) -> Result<()> {
+ let client: Client = Arc::new(client);
+ let (tx, _) = broadcast::channel::(1024);
+ let live = Arc::new(AtomicBool::new(false));
+ let clients = Arc::new(AtomicUsize::new(0));
+
+ spawn_parser(&client, &tx);
+ spawn_poll_loop(client.clone(), tx.clone(), live.clone(), minutes, start_cursor);
+
+ let listener = TcpListener::bind(("127.0.0.1", port)).await?;
+ println!("Ready — open http://127.0.0.1:{port} (press Start in the page)");
+
+ loop {
+ tokio::select! {
+ _ = tokio::signal::ctrl_c() => {
+ live.store(false, Ordering::SeqCst);
+ restore_normal(&client).await;
+ println!("\nStopped live mode, exiting.");
+ break;
+ }
+ accept = listener.accept() => {
+ if let Ok((sock, _)) = accept {
+ let rx = tx.subscribe();
+ let c = client.clone();
+ let lv = live.clone();
+ let cl = clients.clone();
+ tokio::spawn(async move {
+ let _ = handle(sock, rx, c, lv, cl, port).await;
+ });
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Background task: raw ring notifications -> typed JSON messages for the page.
+fn spawn_parser(client: &Client, tx: &broadcast::Sender) {
+ let mut rx = client.transport().subscribe();
+ let tx = tx.clone();
+ tokio::spawn(async move {
+ loop {
+ match rx.recv().await {
+ Ok(frame) => {
+ // Accelerometer indications (the high-rate live stream).
+ for s in AcmSample::parse_frame(&frame) {
+ let _ = tx.send(format!(
+ "{{\"t\":\"accel\",\"x\":{},\"y\":{},\"z\":{}}}",
+ s.x, s.y, s.z
+ ));
+ }
+ }
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(_) => break,
+ }
+ }
+ });
+}
+
+/// The single writer task. All protocol writes happen here so they never race;
+/// the HTTP handlers only flip the `live` flag.
+fn spawn_poll_loop(
+ client: Client,
+ tx: broadcast::Sender,
+ live: Arc,
+ minutes: u16,
+ start_cursor: u32,
+) {
+ tokio::spawn(async move {
+ let mut was_live = false;
+ let mut armed_at: Option = None;
+ let mut hr_cursor: u32 = start_cursor;
+ let mut tick: u64 = 0;
+ let rearm_after = Duration::from_secs((minutes.max(1) as u64) * 60 - 20);
+
+ loop {
+ let now_live = live.load(Ordering::SeqCst);
+
+ // Rising edge: enter live mode. Enable async notifications + force the
+ // green-LED HR measurement, then position the event cursor at "now" so
+ // we only stream HR events recorded from here on.
+ if now_live && !was_live {
+ let _ = tx.send("{\"t\":\"status\",\"live\":true}".into());
+ let _ = client.transport().write(&req_set_notification(0x3f)).await;
+ let _ = client
+ .transport()
+ .write(&req_set_feature_mode(feature::DAYTIME_HR, feature_mode::CONNECTED_LIVE))
+ .await;
+ let _ = client
+ .transport()
+ .write(&req_set_feature_mode(feature::SPO2, feature_mode::AUTOMATIC))
+ .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);
+ arm_acm(&client, minutes).await;
+ armed_at = Some(Instant::now());
+ }
+ // Falling edge: back to normal.
+ if !now_live && was_live {
+ restore_normal(&client).await;
+ armed_at = None;
+ let _ = tx.send("{\"t\":\"status\",\"live\":false}".into());
+ }
+ was_live = now_live;
+
+ if !now_live {
+ tokio::time::sleep(Duration::from_millis(400)).await;
+ continue;
+ }
+
+ // Re-arm the accelerometer before its timer lapses.
+ if armed_at.map(|t| t.elapsed() > rearm_after).unwrap_or(true) {
+ arm_acm(&client, minutes).await;
+ armed_at = Some(Instant::now());
+ }
+
+ // Drain freshly-recorded events (stream-safe) and forward HR / SpO2.
+ // 0x80 green_ibi_quality_event carries {hr_bpm, ibi_ms, quality}.
+ if let Ok(out) = client
+ .drain_events_live(hr_cursor, Duration::from_millis(1500), |ev| match ev.tag {
+ 0x80 => {
+ if let Some(d) = &ev.decoded {
+ let _ = tx.send(format!("{{\"t\":\"hr80\",\"d\":{d}}}"));
+ }
+ }
+ 0x6f | 0x70 | 0x77 => {
+ if let Some(d) = &ev.decoded {
+ let _ = tx.send(format!("{{\"t\":\"spo2e\",\"d\":{d}}}"));
+ }
+ }
+ 0x46 | 0x69 | 0x75 => {
+ if let Some(d) = &ev.decoded {
+ let _ = tx.send(format!("{{\"t\":\"temp\",\"d\":{d}}}"));
+ }
+ }
+ _ => {}
+ })
+ .await
+ {
+ hr_cursor = out.next_cursor;
+ }
+
+ // Battery roughly every ~15s.
+ if tick % 6 == 0 {
+ if let Ok(Some(b)) = client.battery_live(Duration::from_millis(700)).await {
+ let charging = if b.charging_progress > 0 { "true" } else { "false" };
+ let _ = tx.send(format!(
+ "{{\"t\":\"batt\",\"pct\":{},\"charging\":{charging}}}",
+ b.percent
+ ));
+ }
+ }
+
+ tick += 1;
+ tokio::time::sleep(Duration::from_millis(2000)).await;
+ }
+ });
+}
+
+async fn arm_acm(client: &Client, minutes: u16) {
+ let _ = client
+ .transport()
+ .write(&protocol::req_set_realtime(protocol::realtime::ACM, minutes, 0))
+ .await;
+}
+
+/// Return the ring to its normal background state: realtime off, features auto.
+async fn restore_normal(client: &Client) {
+ let _ = client.transport().write(&protocol::req_realtime_off()).await;
+ let _ = client
+ .transport()
+ .write(&req_set_feature_mode(feature::DAYTIME_HR, feature_mode::AUTOMATIC))
+ .await;
+}
+
+fn header<'a>(req: &'a str, name: &str) -> Option<&'a str> {
+ req.lines().find_map(|l| {
+ let (k, v) = l.split_once(':')?;
+ k.trim().eq_ignore_ascii_case(name).then(|| v.trim())
+ })
+}
+
+async fn handle(
+ mut sock: TcpStream,
+ mut rx: broadcast::Receiver,
+ _client: Client,
+ live: Arc,
+ clients: Arc,
+ port: u16,
+) -> Result<()> {
+ let mut buf = [0u8; 2048];
+ let n = sock.read(&mut buf).await?;
+ let req = String::from_utf8_lossy(&buf[..n]);
+ let path = req.split_whitespace().nth(1).unwrap_or("/");
+
+ // Same loopback + CSRF defences as the motion server.
+ let host_ok = header(&req, "host")
+ .is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"));
+ if !host_ok {
+ return forbidden(&mut sock).await;
+ }
+ if matches!(path, "/start" | "/stop") {
+ if header(&req, "x-oura-viz").is_none() {
+ return forbidden(&mut sock).await;
+ }
+ let origin_ok = header(&req, "origin").is_none_or(|o| {
+ o == format!("http://127.0.0.1:{port}") || o == format!("http://localhost:{port}")
+ });
+ if !origin_ok {
+ return forbidden(&mut sock).await;
+ }
+ }
+
+ match path {
+ "/stream" => {
+ sock.write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\
+ Cache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n",
+ )
+ .await?;
+ clients.fetch_add(1, Ordering::SeqCst);
+ loop {
+ match rx.recv().await {
+ Ok(line) => {
+ if sock.write_all(format!("data: {line}\n\n").as_bytes()).await.is_err() {
+ break;
+ }
+ }
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(_) => break,
+ }
+ }
+ // Last viewer gone: leave live mode so we stop draining the battery.
+ if clients.fetch_sub(1, Ordering::SeqCst) == 1 {
+ live.store(false, Ordering::SeqCst);
+ }
+ }
+ "/start" => {
+ live.store(true, Ordering::SeqCst);
+ ok(&mut sock, "started").await?;
+ }
+ "/stop" => {
+ live.store(false, Ordering::SeqCst);
+ ok(&mut sock, "stopped").await?;
+ }
+ _ => {
+ let resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\
+ Cache-Control: no-store\r\nContent-Length: {}\r\n\r\n{}",
+ INDEX_HTML.len(),
+ INDEX_HTML
+ );
+ sock.write_all(resp.as_bytes()).await?;
+ }
+ }
+ Ok(())
+}
+
+async fn ok(sock: &mut TcpStream, msg: &str) -> Result<()> {
+ let resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
+ msg.len(),
+ msg
+ );
+ sock.write_all(resp.as_bytes()).await?;
+ Ok(())
+}
+
+async fn forbidden(sock: &mut TcpStream) -> Result<()> {
+ sock.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n").await?;
+ Ok(())
+}
+
+const INDEX_HTML: &str = include_str!("live.html");
diff --git a/crates/oura-cli/src/main.rs b/crates/oura-cli/src/main.rs
index e20510d6..d1e78320 100644
--- a/crates/oura-cli/src/main.rs
+++ b/crates/oura-cli/src/main.rs
@@ -9,10 +9,12 @@ use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use oura_link::ble::{self, BleTransport};
+use oura_link::transport::Transport;
use oura_store::storage::Store;
use oura_link::OuraClient;
mod game;
+mod live;
mod motion_server;
mod viz;
@@ -70,6 +72,13 @@ enum Command {
#[arg(long)]
raw: bool,
},
+ /// Diagnostic: actively probe the on-demand "measure pulse" path. Arms the
+ /// realtime ON_DEMAND bit (and exercise HR) and dumps raw frames so we can see
+ /// what the ring emits during an active measurement. Wear the ring, hold still.
+ Measure {
+ #[arg(long, default_value_t = 20)]
+ seconds: u64,
+ },
/// Show stored event counts from the database (offline).
Events,
/// Re-run decoders over already-stored raw event bodies (offline).
@@ -96,6 +105,16 @@ enum Command {
#[arg(long, default_value_t = 5)]
minutes: u16,
},
+ /// Real-time health dashboard (web UI): HR, motion, SpO2 and battery streamed
+ /// live while worn. "Start" arms live mode; "Stop" returns the ring to normal.
+ Live {
+ /// Local HTTP port to serve the dashboard on.
+ #[arg(long, default_value_t = 8090)]
+ port: u16,
+ /// Minutes the ring streams per arming (auto re-armed while live).
+ #[arg(long, default_value_t = 5)]
+ minutes: u16,
+ },
/// Tilt-controlled asteroid game (web UI) — steer a ship by tilting the ring.
Game {
/// Local HTTP port to serve the game on.
@@ -235,6 +254,7 @@ async fn main() -> Result<()> {
Command::Sync { sync_time } => cmd_sync(&cli, &key, *sync_time).await,
Command::Latest => cmd_latest(&cli, &key).await,
Command::LiveHr { seconds, raw } => cmd_live_hr(&cli, &key, *seconds, *raw).await,
+ Command::Measure { seconds } => cmd_measure(&cli, &key, *seconds).await,
Command::Accel { seconds } => cmd_accel(&cli, &key, *seconds).await,
Command::SleepAnalyze { force } => cmd_sleep_analyze(&cli, &key, *force).await,
Command::Viz { port, minutes } => {
@@ -242,6 +262,28 @@ async fn main() -> Result<()> {
maybe_auth(&client, &key).await?;
viz::run(client, *port, *minutes).await
}
+ Command::Live { port, minutes } => {
+ let client = connect(&cli).await?;
+ maybe_auth(&client, &key).await?;
+ let serial = client.serial().await.unwrap_or_else(|_| "unknown".into());
+ let store = Store::open(&cli.db).with_context(|| {
+ format!(
+ "live mode needs a readable DB cursor; run `oura sync` first or fix {}",
+ cli.db.display()
+ )
+ })?;
+ let saved_cursor = store.cursor(&serial)?;
+ let start_cursor = if saved_cursor > 0 {
+ saved_cursor
+ } else {
+ store.event_high_water_cursor(&serial)?.ok_or_else(|| {
+ anyhow!(
+ "live mode needs a baseline for serial {serial}; run `oura sync` once before `oura live`"
+ )
+ })?
+ };
+ live::run(client, *port, *minutes, start_cursor).await
+ }
Command::Game { port, minutes } => {
let client = connect(&cli).await?;
maybe_auth(&client, &key).await?;
@@ -640,24 +682,187 @@ async fn cmd_live_hr(cli: &Cli, key: &Option<[u8; 16]>, seconds: u64, raw: bool)
let store = Store::open(&cli.db).ok();
println!("Streaming live heart rate for {seconds}s (Ctrl-C to stop early)...");
+
+ // This firmware never pushes an IBI stream. The path that works (and that the
+ // app uses): enable notifications, force daytime HR into CONNECTED_LIVE so the
+ // ring measures via the green LED and *records* `0x80` green_ibi_quality events,
+ // then incrementally drain those events. Each carries hr_bpm + per-beat IBI.
+ use oura_protocol::protocol::{feature, feature_mode, req_set_feature_mode, req_set_notification};
+ let _ = client.transport().write(&req_set_notification(0x3f)).await;
+ let _ = client
+ .transport()
+ .write(&req_set_feature_mode(feature::DAYTIME_HR, feature_mode::CONNECTED_LIVE))
+ .await;
+ // Position the cursor at the newest existing event so we only show fresh beats.
+ let mut cursor = client
+ .drain_events_live(0, Duration::from_millis(2500), |_| {})
+ .await
+ .map(|o| o.next_cursor)
+ .unwrap_or(0);
+
let mut count = 0u32;
+ let deadline = std::time::Instant::now() + Duration::from_secs(seconds);
+ while std::time::Instant::now() < deadline {
+ if let Ok(out) = client
+ .drain_events_live(cursor, Duration::from_millis(1500), |ev| {
+ if ev.tag == 0x80 {
+ if let Some(d) = &ev.decoded {
+ if raw {
+ println!(" 0x80: {d}");
+ }
+ if let Some(bpm) = d
+ .get("hr_bpm")
+ .and_then(|a| a.as_array())
+ .and_then(|a| a.iter().rev().find_map(|x| x.as_u64()))
+ {
+ count += 1;
+ println!(" {bpm} bpm");
+ if let Some(store) = &store {
+ let _ = store.insert_reading(&serial, "heart_rate_live", bpm as f64, "bpm");
+ }
+ }
+ }
+ }
+ })
+ .await
+ {
+ cursor = out.next_cursor;
+ }
+ tokio::time::sleep(Duration::from_millis(1500)).await;
+ }
+ let _ = client
+ .transport()
+ .write(&req_set_feature_mode(feature::DAYTIME_HR, feature_mode::AUTOMATIC))
+ .await;
+
+ if count == 0 {
+ println!(
+ "No HR captured. Keep the ring snug + still + within ~2 m of the Mac, and give\n\
+ it 10–20 s to lock a beat (the green-LED measurement is contact-sensitive)."
+ );
+ }
+ let _ = client.transport().disconnect().await;
+ Ok(())
+}
+
+/// Probe the active "measure pulse" path. Tests the strongest hypotheses first
+/// (exercise HR returns a direct bpm), dumping full payloads so we can read the
+/// firmware's actual field layout. Every write is timeout-guarded so a BLE range
+/// drop can't wedge the process. Keep the ring snug + close to the Mac + still.
+async fn cmd_measure(cli: &Cli, key: &Option<[u8; 16]>, seconds: u64) -> Result<()> {
+ use oura_protocol::protocol::{
+ feature, feature_mode, realtime, req_feature_latest, req_realtime_off, req_set_feature_mode,
+ req_set_realtime,
+ };
+ let _ = (realtime::ON_DEMAND, feature::EXERCISE_HR, req_set_realtime, req_realtime_off, req_feature_latest);
+ let client = connect(cli).await?;
+ maybe_auth(&client, key).await?;
+
+ // HR-bearing event tags: if a forced measurement records data, these appear.
+ // 0x80 = green_ibi_quality_event (the Ring 4/5 green-LED HR; decodes to hr_bpm).
+ let is_hr_tag = |t: u8| matches!(t, 0x44 | 0x55 | 0x5d | 0x60 | 0x62 | 0x63 | 0x71 | 0x80);
+
+ println!("Keep the ring snug, close to the Mac, and still.\n");
+
+ // 1) Baseline: what events already exist (so we can spot the fresh ones).
+ println!("── baseline drain (recording existing events) ...");
+ let mut before: std::collections::HashSet<(u8, u32)> = std::collections::HashSet::new();
+ let mut max_before: u32 = 0;
client
- .live_heart_rate(Duration::from_secs(seconds), raw, |s| {
- count += 1;
- println!(" {} bpm (IBI {} ms)", s.bpm, s.ibi_ms);
- if let Some(store) = &store {
- let _ = store.insert_reading(&serial, "heart_rate_live", s.bpm as f64, "bpm");
+ .drain_events(0, |ev| {
+ before.insert((ev.tag, ev.timestamp));
+ max_before = max_before.max(ev.timestamp);
+ })
+ .await?;
+ println!(" baseline: {} events, newest ring_ts={max_before}", before.len());
+
+ // 2) Force a measurement: enable async notifications + daytime HR live mode.
+ println!("\n── enabling notifications + daytime HR CONNECTED_LIVE; measuring {}s ...", seconds * 2);
+ guarded_write(&client, &oura_protocol::protocol::req_set_notification(0x3f)).await;
+ guarded_write(&client, &req_set_feature_mode(feature::DAYTIME_HR, feature_mode::CONNECTED_LIVE)).await;
+ tokio::time::sleep(Duration::from_secs(seconds * 2)).await;
+ guarded_write(&client, &req_set_feature_mode(feature::DAYTIME_HR, feature_mode::AUTOMATIC)).await;
+
+ // 3) Drain again from the previous high-water mark; report only fresh events.
+ println!("\n── post-measurement drain (looking for fresh HR data) ...");
+ let mut fresh: Vec<(u8, u32, Option)> = Vec::new();
+ let mut fresh_other: std::collections::BTreeMap = std::collections::BTreeMap::new();
+ client
+ .drain_events(max_before.saturating_sub(1), |ev| {
+ if before.contains(&(ev.tag, ev.timestamp)) {
+ return;
+ }
+ if is_hr_tag(ev.tag) {
+ fresh.push((ev.tag, ev.timestamp, ev.decoded.clone()));
+ } else {
+ *fresh_other.entry(ev.tag).or_default() += 1;
}
})
.await?;
+ guarded_write(&client, &oura_protocol::protocol::req_set_notification(0x00)).await;
- if count == 0 {
- println!("No beats captured. Make sure the ring is worn.");
+ println!("\n── RESULT");
+ if fresh.is_empty() {
+ println!(" No fresh HR-bearing events recorded during the measurement window.");
+ } else {
+ println!(" {} fresh HR-bearing events:", fresh.len());
+ for (tag, ts, dec) in fresh.iter().take(20) {
+ let j = dec.as_ref().map(|v| v.to_string()).unwrap_or_else(|| "".into());
+ println!(" {:#04x} {} ts={ts}: {j}", tag, oura_protocol::events::event_name(*tag));
+ }
+ }
+ if !fresh_other.is_empty() {
+ println!(" other fresh events:");
+ for (tag, n) in &fresh_other {
+ println!(" {:#04x} {} ×{n}", tag, oura_protocol::events::event_name(*tag));
+ }
}
+ let _ = poll_feature; // (poll path still available; not used in this test)
let _ = client.transport().disconnect().await;
Ok(())
}
+/// Timeout-guarded write: a wedged BLE link (e.g. out of range) fails fast instead
+/// of hanging the process forever.
+async fn guarded_write(client: &OuraClient, bytes: &[u8]) {
+ let _ = tokio::time::timeout(Duration::from_secs(2), client.transport().write(bytes)).await;
+}
+
+/// Poll one feature's latest values for `seconds`, dumping the full payload and the
+/// parsed bpm/SpO2 each tick. Increments `got` for every reading that yields a bpm.
+async fn poll_feature(
+ client: &OuraClient,
+ feature_id: u8,
+ seconds: u64,
+ got: &mut u32,
+) {
+ use oura_protocol::protocol::req_feature_latest;
+ let deadline = std::time::Instant::now() + Duration::from_secs(seconds);
+ while std::time::Instant::now() < deadline {
+ if let Ok(Some(p)) = client
+ .request_until(&req_feature_latest(feature_id), 0x2f, Some(0x25), Duration::from_millis(900))
+ .await
+ {
+ let result = p.payload.get(2).copied().unwrap_or(255);
+ let status = p.payload.get(3).copied().unwrap_or(255);
+ let state = p.payload.get(4).copied().unwrap_or(255);
+ let v = oura_link::client::parse_latest_values(feature_id, p.payload.get(7..).unwrap_or(&[]));
+ println!(
+ " [{}] result={result} status={status} state={state} bpm={:?} spo2={:?}",
+ hex::encode(&p.payload),
+ v.bpm,
+ v.spo2_percent
+ );
+ if v.bpm.is_some() {
+ *got += 1;
+ }
+ } else {
+ println!(" (no response — link slow or out of range)");
+ }
+ tokio::time::sleep(Duration::from_millis(1500)).await;
+ }
+}
+
async fn cmd_sleep_analyze(cli: &Cli, key: &Option<[u8; 16]>, force: bool) -> Result<()> {
let client = connect(cli).await?;
maybe_auth(&client, key).await?;
diff --git a/crates/oura-ffi/Cargo.toml b/crates/oura-ffi/Cargo.toml
new file mode 100644
index 00000000..bcdb4c14
--- /dev/null
+++ b/crates/oura-ffi/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "oura-ffi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+# Static library for linking into the iOS app (and any other C ABI consumer).
+[lib]
+name = "ouraffi"
+crate-type = ["staticlib"]
+
+[dependencies]
+oura-protocol = { path = "../oura-protocol" }
+serde_json = "1"
diff --git a/crates/oura-ffi/src/lib.rs b/crates/oura-ffi/src/lib.rs
new file mode 100644
index 00000000..e7a52811
--- /dev/null
+++ b/crates/oura-ffi/src/lib.rs
@@ -0,0 +1,74 @@
+//! C ABI over the tested Oura protocol core, for linking into the iOS app.
+//!
+//! We expose only the byte-level pieces that are genuinely hard to reproduce —
+//! the AES auth and the event-body decoders — as pure, synchronous functions.
+//! BLE transport, packet framing (trivial `tag|len|payload`), the request
+//! builders (tiny byte arrays), and the connect/sync orchestration are all done
+//! natively in Swift, so nothing async crosses the FFI boundary.
+
+use std::ffi::{c_char, CString};
+use std::slice;
+
+use oura_protocol::auth::encrypt_nonce;
+use oura_protocol::events::{decode_event_body, event_name};
+
+/// Encrypt a ring auth nonce (AES-128/ECB/PKCS7) into `out` (must hold 16 bytes).
+/// `key` must be exactly 16 bytes; `nonce` is typically 15. Returns 0 on success,
+/// negative on bad arguments.
+#[no_mangle]
+pub extern "C" fn oura_encrypt_nonce(
+ key: *const u8,
+ key_len: usize,
+ nonce: *const u8,
+ nonce_len: usize,
+ out: *mut u8,
+) -> i32 {
+ if key.is_null() || nonce.is_null() || out.is_null() || key_len != 16 {
+ return -1;
+ }
+ // SAFETY: caller guarantees the pointers are valid for the given lengths.
+ let key_slice = unsafe { slice::from_raw_parts(key, 16) };
+ let nonce_slice = unsafe { slice::from_raw_parts(nonce, nonce_len) };
+ let mut k = [0u8; 16];
+ k.copy_from_slice(key_slice);
+ let res = encrypt_nonce(&k, nonce_slice);
+ unsafe { std::ptr::copy_nonoverlapping(res.as_ptr(), out, 16) };
+ 0
+}
+
+/// Decode an event body for `tag` into a JSON C string, or null if the tag has no
+/// decoder / the body is malformed. The returned string is owned by the caller and
+/// must be released with [`oura_string_free`].
+#[no_mangle]
+pub extern "C" fn oura_decode_event(tag: u8, body: *const u8, body_len: usize) -> *mut c_char {
+ let body: &[u8] = if body.is_null() || body_len == 0 {
+ &[]
+ } else {
+ // SAFETY: caller guarantees `body` is valid for `body_len` bytes.
+ unsafe { slice::from_raw_parts(body, body_len) }
+ };
+ match decode_event_body(tag, body) {
+ Some(v) => CString::new(v.to_string())
+ .map(|s| s.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+/// Human-readable event name for `tag` (owned C string; release with
+/// [`oura_string_free`]).
+#[no_mangle]
+pub extern "C" fn oura_event_name(tag: u8) -> *mut c_char {
+ CString::new(event_name(tag))
+ .map(|s| s.into_raw())
+ .unwrap_or(std::ptr::null_mut())
+}
+
+/// Release a C string previously returned by this library.
+#[no_mangle]
+pub extern "C" fn oura_string_free(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ // SAFETY: `ptr` came from `CString::into_raw` in this library.
+ unsafe { drop(CString::from_raw(ptr)) };
+ }
+}
diff --git a/crates/oura-link/src/client.rs b/crates/oura-link/src/client.rs
index ad30dfb8..ff84eed6 100644
--- a/crates/oura-link/src/client.rs
+++ b/crates/oura-link/src/client.rs
@@ -111,6 +111,82 @@ impl OuraClient {
Ok(frames.iter().filter_map(|f| Packet::parse(f)).collect())
}
+ /// Write `bytes` and return the first response packet matching `tag` (and
+ /// `ext_tag`, when given), ignoring everything else, with a hard `timeout`.
+ ///
+ /// Unlike [`Self::request`] this does *not* wait for a quiet window, so it is
+ /// safe to poll while a high-rate stream (e.g. live accelerometer) is active —
+ /// the quiet-window collector would otherwise never see the link go idle.
+ /// Returns `None` if no matching frame arrives before the timeout.
+ pub async fn request_until(
+ &self,
+ bytes: &[u8],
+ tag: u8,
+ ext_tag: Option,
+ timeout: Duration,
+ ) -> Result