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> { + let mut rx = self.transport.subscribe(); + while rx.try_recv().is_ok() {} + self.transport.write(bytes).await?; + + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(frame)) => { + if let Some(p) = Packet::parse(&frame) { + if p.tag == tag && (ext_tag.is_none() || p.ext_tag() == ext_tag) { + return Ok(Some(p)); + } + } + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, + _ => return Ok(None), + } + } + } + + /// Stream-safe poll of a feature's latest cached values (see + /// [`Self::feature_latest`]). Uses [`Self::request_until`] so it can run while + /// the live accelerometer stream is active. Returns defaults if no response. + pub async fn feature_latest_live( + &self, + feature_id: u8, + timeout: Duration, + ) -> Result { + match self + .request_until(&protocol::req_feature_latest(feature_id), 0x2f, Some(0x25), timeout) + .await? + { + Some(p) => Ok(parse_latest_values(feature_id, p.payload.get(7..).unwrap_or(&[]))), + None => Ok(LatestValues::default()), + } + } + + /// Stream-safe poll of battery state (see [`Self::battery`]). + pub async fn battery_live(&self, timeout: Duration) -> Result> { + Ok(self + .request_until(&protocol::req_battery(), 0x0d, None, timeout) + .await? + .and_then(|p| Battery::parse(&p))) + } + + /// Stream-safe poll of a feature's status (see [`Self::feature_status`]). + pub async fn feature_status_live( + &self, + feature_id: u8, + timeout: Duration, + ) -> Result> { + Ok(self + .request_until(&protocol::req_feature_status(feature_id), 0x2f, Some(0x21), timeout) + .await? + .as_ref() + .and_then(FeatureStatus::parse)) + } + fn find(packets: &[Packet], tag: u8) -> Option<&Packet> { packets.iter().find(|p| p.tag == tag) } @@ -265,6 +341,81 @@ impl OuraClient { }) } + /// Like [`Self::drain_events`] but **stream-safe**: each `GetEvent` batch is + /// collected with a bounded per-request timeout (waiting for the `0x11` summary) + /// instead of a quiet window, and accelerometer/control frames are ignored. Use + /// this to incrementally pull freshly-recorded events (e.g. live HR `0x80`) + /// while the accelerometer realtime stream is active — the quiet-window drain + /// would never see the link go idle. + pub async fn drain_events_live( + &self, + cursor: u32, + request_timeout: Duration, + mut on_event: F, + ) -> Result + where + F: FnMut(&RingEvent), + { + let mut rx = self.transport.subscribe(); + let mut start = cursor; + let mut total = 0u32; + for _ in 0..10_000 { + while rx.try_recv().is_ok() {} + self.transport + .write(&protocol::req_get_event(start, 255, -1)) + .await?; + + let mut summary: Option = None; + let mut max_ts = start; + let mut batch = Vec::new(); + let deadline = tokio::time::Instant::now() + request_timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(frame)) => { + let Some(p) = Packet::parse(&frame) else { continue }; + if p.tag == 0x11 { + summary = EventBatchSummary::parse(&p); + break; + } else if p.tag >= protocol::HISTORY_EVENT_PREFIX { + let ev = RingEvent::from_packet(&p); + max_ts = max_ts.max(ev.timestamp); + batch.push(ev); + } + // ignore ACM (0x33), control ACKs (0x07), etc. + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, + _ => break, + } + } + + let Some(summary) = summary else { + break; + }; + let batch_events = batch.len() as u32; + for ev in &batch { + on_event(ev); + } + total += batch_events; + let bytes_left = summary.bytes_left; + let next = max_ts.saturating_add(1); + let progressed = batch_events > 0 && next > start; + if progressed { + start = next; + } + if bytes_left == 0 || !progressed { + break; + } + } + Ok(SyncOutcome { + events_synced: total, + next_cursor: start, + }) + } + // --- live / latest ----------------------------------------------------- /// Read a feature's latest cached values (HR / SpO2). Reflects the last @@ -277,40 +428,7 @@ impl OuraClient { .ok_or_else(|| Error::Protocol("no feature-latest response".into()))?; // payload: [0]=0x25,[1]=feature,[2]=result,[3]=status,[4]=state, // [5..7]=counter, [7..]=feature-specific data. - let data = p.payload.get(7..).unwrap_or(&[]); - let mut out = LatestValues::default(); - match feature_id { - feature::DAYTIME_HR => { - // data[0..2] = rr-corrected IBI (ms); bpm = 60000 / ibi. - if data.len() >= 2 { - let ibi = u16::from_le_bytes([data[0], data[1]]); - out.bpm = bpm_from_ibi(ibi); - } - } - feature::EXERCISE_HR => { - // data[4] = last HR value (bpm). - if let Some(&bpm) = data.get(4) { - if bpm > 0 { - out.bpm = Some(bpm as u16); - } - } - } - feature::SPO2 => { - // data[3] = SpO2 %, data[4] = HR bpm. - if let Some(&spo2) = data.get(3) { - if spo2 > 0 { - out.spo2_percent = Some(spo2); - } - } - if let Some(&bpm) = data.get(4) { - if bpm > 0 { - out.bpm = Some(bpm as u16); - } - } - } - _ => {} - } - Ok(out) + Ok(parse_latest_values(feature_id, p.payload.get(7..).unwrap_or(&[]))) } /// Trigger the ring's sleep analysis. Returns the `0x29` status byte. @@ -505,6 +623,44 @@ fn parse_acm_frame(frame: &[u8]) -> Vec { out } +/// Decode the feature-specific data tail of a `GetFeatureLatestValues` (`0x25`) +/// response into [`LatestValues`]. `data` is the payload after the 7-byte header. +pub fn parse_latest_values(feature_id: u8, data: &[u8]) -> LatestValues { + let mut out = LatestValues::default(); + match feature_id { + feature::DAYTIME_HR => { + // data[0..2] = rr-corrected IBI (ms); bpm = 60000 / ibi. + if data.len() >= 2 { + let ibi = u16::from_le_bytes([data[0], data[1]]); + out.bpm = bpm_from_ibi(ibi); + } + } + feature::EXERCISE_HR => { + // data[4] = last HR value (bpm). + if let Some(&bpm) = data.get(4) { + if bpm > 0 { + out.bpm = Some(bpm as u16); + } + } + } + feature::SPO2 => { + // data[3] = SpO2 %, data[4] = HR bpm. + if let Some(&spo2) = data.get(3) { + if spo2 > 0 { + out.spo2_percent = Some(spo2); + } + } + if let Some(&bpm) = data.get(4) { + if bpm > 0 { + out.bpm = Some(bpm as u16); + } + } + } + _ => {} + } + out +} + /// Compute bpm from an inter-beat interval, ignoring implausible values. fn bpm_from_ibi(ibi_ms: u16) -> Option { if (300..=2000).contains(&ibi_ms) { diff --git a/crates/oura-store/src/storage.rs b/crates/oura-store/src/storage.rs index 1cb02542..df3617df 100644 --- a/crates/oura-store/src/storage.rs +++ b/crates/oura-store/src/storage.rs @@ -9,8 +9,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection, OptionalExtension}; -use oura_protocol::device::{Battery, DeviceInfo}; use crate::error::Result; +use oura_protocol::device::{Battery, DeviceInfo}; use oura_protocol::events::RingEvent; const SCHEMA: &str = r#" @@ -121,6 +121,16 @@ impl Store { Ok(v.unwrap_or(0) as u32) } + /// One past the highest stored event timestamp for this serial, if any. + pub fn event_high_water_cursor(&self, serial: &str) -> Result> { + let v: Option = self.conn.query_row( + "SELECT MAX(ring_timestamp) + 1 FROM events WHERE serial = ?1", + params![serial], + |r| r.get(0), + )?; + Ok(v.map(|cursor| cursor as u32)) + } + /// Persist the next sync cursor. pub fn set_cursor(&self, serial: &str, cursor: u32) -> Result<()> { self.conn.execute( diff --git a/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..27a4f381 --- /dev/null +++ b/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 00000000..51015e93 Binary files /dev/null and b/ios/OpenOura/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/OpenOura/Assets.xcassets/Contents.json b/ios/OpenOura/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/OpenOura/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OpenOura/BLE/OuraProtocol.swift b/ios/OpenOura/BLE/OuraProtocol.swift new file mode 100644 index 00000000..5397e062 --- /dev/null +++ b/ios/OpenOura/BLE/OuraProtocol.swift @@ -0,0 +1,96 @@ +import CoreBluetooth +import Foundation + +/// GATT UUIDs and the `tag | len | payload` packet framing + request builders, +/// mirroring `crates/oura-protocol`. Little-endian throughout; extended ops ride +/// outer tag 0x2f with the first payload byte as the extended op. +enum OuraGATT { + static let service = CBUUID(string: "98ED0001-A541-11E4-B6A0-0002A5D5C51B") + static let notify = CBUUID(string: "98ED0003-A541-11E4-B6A0-0002A5D5C51B") + static let write = CBUUID(string: "98ED0002-A541-11E4-B6A0-0002A5D5C51B") +} + +enum Feature { + static let daytimeHR: UInt8 = 0x02 + static let exerciseHR: UInt8 = 0x03 + static let spo2: UInt8 = 0x04 + static let restingHR: UInt8 = 0x08 +} + +enum FeatureMode { + static let off: UInt8 = 0x00 + static let automatic: UInt8 = 0x01 + static let requested: UInt8 = 0x02 + static let connectedLive: UInt8 = 0x03 +} + +enum Realtime { + static let acm: UInt32 = 0x20 + static let onDemand: UInt32 = 0x200 + static let acmResponseTag: UInt8 = 0x33 +} + +let HISTORY_EVENT_PREFIX: UInt8 = 0x41 + +/// A decoded protocol frame. +struct Packet { + let tag: UInt8 + let payload: [UInt8] + + /// Parse a notification frame leniently (matches the Rust `Packet::parse`). + static func parse(_ frame: Data) -> Packet? { + let b = [UInt8](frame) + guard b.count >= 2 else { return nil } + let tag = b[0] + let len = Int(b[1]) + let end = min(2 + len, b.count) + return Packet(tag: tag, payload: Array(b[2.. Data { + Data([tag, UInt8(payload.count)] + payload) +} + +private func le32(_ v: UInt32) -> [UInt8] { [UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 24) & 0xff)] } +private func le16(_ v: UInt16) -> [UInt8] { [UInt8(v & 0xff), UInt8((v >> 8) & 0xff)] } + +/// Request builders (each returns the bytes to write). +enum Req { + static let firmware = Data([0x08, 0x03, 0x00, 0x00, 0x00]) + static let battery = packet(0x0c, []) + static let authNonce = Data([0x2f, 0x01, 0x2b]) + static let serial = Data([0x18, 0x03, 0x08, 0x00, 0x10]) + static let hardware = Data([0x18, 0x03, 0x18, 0x00, 0x10]) + static let realtimeOff = packet(0x06, [0, 0, 0, 0]) + + static func authenticate(_ enc: Data) -> Data { packet(0x2f, [0x2d] + [UInt8](enc)) } + /// Install a 16-byte auth key (only valid on a factory-reset ring). + static func setAuthKey(_ key: Data) -> Data { packet(0x24, [UInt8](key)) } + /// Factory-reset the ring (wipes its auth key + user data). Returns tag 0x1b. + static let factoryReset = Data([0x1a, 0x00]) + static func capabilities(_ page: UInt8) -> Data { Data([0x2f, 0x02, 0x01, page]) } + static func setNotification(_ flags: UInt8) -> Data { packet(0x1c, [flags]) } + static func featureStatus(_ f: UInt8) -> Data { Data([0x2f, 0x02, 0x20, f]) } + static func featureLatest(_ f: UInt8) -> Data { Data([0x2f, 0x02, 0x24, f]) } + static func setFeatureMode(_ f: UInt8, _ mode: UInt8) -> Data { Data([0x2f, 0x03, 0x22, f, mode]) } + + static func syncTime(_ unix: UInt64, tzHalfHours: UInt8) -> Data { + var p = [UInt8]() + for i in 0..<8 { p.append(UInt8((unix >> (8 * UInt64(i))) & 0xff)) } + p.append(tzHalfHours) + return packet(0x12, p) + } + + static func getEvent(start: UInt32, maxEvents: UInt8, flags: Int32) -> Data { + packet(0x10, le32(start) + [maxEvents] + le32(UInt32(bitPattern: flags))) + } + + static func setRealtime(bitmask: UInt32, minutes: UInt16, delay: UInt8) -> Data { + packet(0x06, le32(bitmask) + le16(minutes) + [delay]) + } +} diff --git a/ios/OpenOura/BLE/OuraRing.swift b/ios/OpenOura/BLE/OuraRing.swift new file mode 100644 index 00000000..706c4bc0 --- /dev/null +++ b/ios/OpenOura/BLE/OuraRing.swift @@ -0,0 +1,980 @@ +import Combine +import CoreBluetooth +import Foundation +import UIKit +import os + +/// One decoded history event (timestamp in ring deciseconds + decoded JSON). +struct DecodedEvent: Identifiable { + let tag: UInt8 + let timestamp: UInt32 + let name: String + let json: [String: Any] + let identity: String + + var id: String { identity } + + init(tag: UInt8, timestamp: UInt32, name: String, json: [String: Any], identity: String? = nil) { + self.tag = tag + self.timestamp = timestamp + self.name = name + self.json = json + self.identity = identity ?? "\(tag)-\(timestamp)-\(DecodedEvent.jsonIdentity(json))" + } + + private static func jsonIdentity(_ json: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]), + let s = String(data: data, encoding: .utf8) else { return "{}" } + return s + } +} + +enum ConnState: Equatable { + case idle, scanning, connecting, authenticating, ready, failed(String) +} + +private final class KeepAwakeToken: @unchecked Sendable { + var bgTask: UIBackgroundTaskIdentifier = .invalid + var ended = false +} + +/// CoreBluetooth client + protocol orchestration for an Oura ring. Mirrors the +/// Rust `OuraClient`: request/response with a quiet-window collector, a bounded +/// single-response wait, and a stream-safe incremental event drain. +final class OuraRing: NSObject, ObservableObject { + // Published UI state (always mutated on the main queue). + @Published var state: ConnState = .idle + @Published var status: String = "Not connected" + @Published var firmware: String? + @Published var serial: String? + @Published var hardware: String? + @Published var batteryPercent: Int? + @Published var charging = false + + @Published var liveActive = false + @Published var liveHR: Int? + @Published var liveHRV: Int? + @Published var motionG: Double? + @Published var restlessness: Int? + @Published var hrSeries: [Double] = [] + @Published var hrvSeries: [Double] = [] + @Published var motionSeries: [Double] = [] + + @Published var syncing = false + @Published var events: [DecodedEvent] = [] + @Published var health = HealthData() + @Published var lastSync: Date? = HealthStore.lastSync + @Published var alert: String? // surfaced as a user-facing alert on failure + @Published var showConnectGuide = false // drives the onboarding/connect sheet + + /// Auto-reconnect to the known ring (and auto-sync) without manual taps. + @Published var autoConnect: Bool = (UserDefaults.standard.object(forKey: "autoConnect") as? Bool) ?? true { + didSet { UserDefaults.standard.set(autoConnect, forKey: "autoConnect") } + } + @Published var autoSync: Bool = (UserDefaults.standard.object(forKey: "autoSync") as? Bool) ?? true { + didSet { UserDefaults.standard.set(autoSync, forKey: "autoSync") } + } + + /// Whether we can silently reconnect on launch (we have a key) — only a ring + /// with no key stored needs the onboarding guide. + var canAutoReconnect: Bool { autoConnect && KeyStore.keyBytes() != nil } + private var wantsAutoReconnect = false + private var autoScanning = false + private var userDisconnecting = false + + private let bleQueue = DispatchQueue(label: "com.openoura.ble") + private let log = Logger(subsystem: "com.openoura.app", category: "ble") + private var central: CBCentralManager! + private var peripheral: CBPeripheral? + private var writeChar: CBCharacteristic? + private var notifyReady = false + private var pendingConnect = false + private var connecting = false // a connect attempt is in flight (bleQueue) + private var scanTimer: DispatchSourceTimer? + private var connectTimer: DispatchSourceTimer? + private var readyTimer: DispatchSourceTimer? + + // Frame fan-out: every inbound notification is delivered to all listeners + // (touched only on bleQueue). Transactions register temporary listeners. + private var listeners: [UUID: (Data) -> Void] = [:] + private var readyContinuation: CheckedContinuation? + + // Live-mode rolling buffers (bleQueue). + private var ibiBuffer: [Double] = [] + private var liveListenerID: UUID? + private var liveTask: Task? + private var liveCursor: UInt32? + private var restMoves = 0, restWin = 0 + @MainActor private var keepAwakeCount = 0 + + override init() { + super.init() + // A restore identifier lets iOS preserve our BLE state and relaunch the app + // into the background for connection/notification events (state restoration). + central = CBCentralManager(delegate: self, queue: bleQueue, options: [ + CBCentralManagerOptionRestoreIdentifierKey: "com.openoura.central" + ]) + } + + // MARK: - Publishing helper + private func publish(_ block: @escaping () -> Void) { DispatchQueue.main.async(execute: block) } + + /// Diagnostic log: to the unified log (Console.app) and stdout (captured by + /// `devicectl ... process launch --console`). + private func dbg(_ msg: String) { + log.info("\(msg, privacy: .public)") + print("[ble] \(msg)") + } + + private static func centralStateName(_ state: CBManagerState) -> String { + switch state { + case .unknown: return "unknown" + case .resetting: return "resetting" + case .unsupported: return "unsupported" + case .unauthorized: return "unauthorized" + case .poweredOff: return "poweredOff" + case .poweredOn: return "poweredOn" + @unknown default: return "unknown(\(state.rawValue))" + } + } + + private static func peripheralStateName(_ state: CBPeripheralState) -> String { + switch state { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .disconnecting: return "disconnecting" + @unknown default: return "unknown(\(state.rawValue))" + } + } + + @MainActor private func beginKeepAwake(_ name: String) -> KeepAwakeToken { + keepAwakeCount += 1 + UIApplication.shared.isIdleTimerDisabled = true + let token = KeepAwakeToken() + token.bgTask = UIApplication.shared.beginBackgroundTask(withName: name) { + Task { @MainActor in + guard !token.ended, token.bgTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(token.bgTask) + token.ended = true + token.bgTask = .invalid + } + } + return token + } + + @MainActor private func endKeepAwake(_ token: KeepAwakeToken) { + if !token.ended, token.bgTask != .invalid { + UIApplication.shared.endBackgroundTask(token.bgTask) + token.ended = true + token.bgTask = .invalid + } + keepAwakeCount = max(0, keepAwakeCount - 1) + if keepAwakeCount == 0 { UIApplication.shared.isIdleTimerDisabled = false } + } + + private func writeRaw(_ data: Data) { + guard let p = peripheral, let c = writeChar else { return } + p.writeValue(data, for: c, type: .withResponse) + } + + private func finishReadyIfPossible() { + guard writeChar != nil, notifyReady else { return } + dbg("link ready (write characteristic + notifications)") + connecting = false + scanTimer?.cancel(); connectTimer?.cancel(); readyTimer?.cancel() + if let c = readyContinuation { + // A manual authenticateAndLoad() is awaiting — let it drive auth. + readyContinuation = nil; c.resume() + } else { + // Auto-reconnect / restoration path: authenticate + incremental sync. + Task { if await self.runAuth(), self.autoSync { await self.autoSyncIncremental() } } + } + } + + // MARK: - Connect / scan + func connect() { + bleQueue.async { + // Re-entrancy guard: ignore taps while a connect attempt is in flight or + // we're already linked (prevents the double-scan + leaked continuation). + guard !self.connecting, self.writeChar == nil else { + self.dbg("connect ignored (already connecting/linked)") + return + } + self.connecting = true + self.pendingConnect = true + self.publish { self.state = .scanning; self.status = "Scanning for ring…" } + self.startScanIfReady() + } + } + + /// Begin scanning once Bluetooth is powered on (called on bleQueue, also from + /// `centralManagerDidUpdateState` so a not-yet-ready radio doesn't drop the request). + private func startScanIfReady() { + guard pendingConnect else { return } + switch central.state { + case .poweredOn: + pendingConnect = false + dbg("scanning for ring (service \(OuraGATT.service.uuidString))") + central.scanForPeripherals(withServices: [OuraGATT.service]) + scanTimer?.cancel() + let t = DispatchSource.makeTimerSource(queue: bleQueue) + t.schedule(deadline: .now() + 15) + t.setEventHandler { [weak self] in self?.scanTimedOut() } + t.resume(); scanTimer = t + case .poweredOff: + failConnect("Bluetooth off", "Turn on Bluetooth") + case .unauthorized: + failConnect("No BT permission", "Allow Bluetooth in iOS Settings → Open Oura") + case .unsupported: + failConnect("BLE unsupported", "BLE unsupported on this device") + case .unknown, .resetting: + dbg("bluetooth state \(Self.centralStateName(self.central.state)) — waiting") + @unknown default: break + } + } + + private func scanTimedOut() { + guard writeChar == nil else { return } + central.stopScan() + dbg("scan timed out — ring not found") + failConnect("Ring not found", "Ring not found — wear it, keep it close, and quit other BLE apps") + } + + /// Abort an in-flight connect attempt: tear down timers, cancel the (possibly + /// hung) CoreBluetooth connection, surface a user alert, and resume any waiter. + /// Safe to call from anywhere — always runs its mutations on bleQueue. + private func failConnect(_ short: String, _ status: String) { + bleQueue.async { + self.dbg("connect failed: \(short)") + self.connecting = false; self.pendingConnect = false; self.autoScanning = false + self.scanTimer?.cancel(); self.connectTimer?.cancel() + self.central.stopScan() + if let p = self.peripheral { self.central.cancelPeripheralConnection(p) } + self.publish { self.state = .failed(short); self.status = status; self.alert = status } + if let c = self.readyContinuation { self.readyContinuation = nil; self.readyTimer?.cancel(); c.resume() } + } + } + + func disconnect() { + stopLive() + bleQueue.async { + self.userDisconnecting = true + self.wantsAutoReconnect = false + self.connecting = false + if let p = self.peripheral { self.central.cancelPeripheralConnection(p) } + } + publish { self.state = .idle; self.status = "Disconnected" } + } + + /// Suspend until the link is ready (write characteristic + notifications), or until + /// `timeout` elapses (so a failed scan/connect never hangs the caller). + private func waitUntilReady(timeout: TimeInterval = 20) async { + await withCheckedContinuation { (c: CheckedContinuation) in + bleQueue.async { + if self.writeChar != nil && self.notifyReady { c.resume(); return } + self.readyContinuation = c + let t = DispatchSource.makeTimerSource(queue: self.bleQueue) + t.schedule(deadline: .now() + timeout) + t.setEventHandler { [weak self] in + guard let self, let cont = self.readyContinuation else { return } + self.readyContinuation = nil; cont.resume() + } + t.resume(); self.readyTimer = t + } + } + } + + // MARK: - Request/response primitives (all dispatch onto bleQueue) + + /// Write `req` and collect frames until the link is quiet for `quiet` seconds. + private func transact(_ req: Data, quiet: TimeInterval = 1.2) async -> [Packet] { + await withCheckedContinuation { cont in + bleQueue.async { + var frames: [Packet] = [] + let id = UUID() + var timer: DispatchSourceTimer? + var finished = false + func finish() { + if finished { return }; finished = true + self.listeners.removeValue(forKey: id); timer?.cancel() + cont.resume(returning: frames) + } + func arm() { + timer?.cancel() + let t = DispatchSource.makeTimerSource(queue: self.bleQueue) + t.schedule(deadline: .now() + quiet) + t.setEventHandler(handler: finish) + t.resume(); timer = t + } + self.listeners[id] = { data in + if let p = Packet.parse(data) { frames.append(p) } + arm() + } + self.writeRaw(req); arm() + } + } + } + + /// Write `req`; return the first frame matching `tag` (+ `ext`), else nil after `timeout`. + private func requestUntil(_ req: Data, tag: UInt8, ext: UInt8?, timeout: TimeInterval) async -> Packet? { + await withCheckedContinuation { cont in + bleQueue.async { + let id = UUID() + var done = false + let timer = DispatchSource.makeTimerSource(queue: self.bleQueue) + func finish(_ p: Packet?) { + if done { return }; done = true + self.listeners.removeValue(forKey: id); timer.cancel() + cont.resume(returning: p) + } + timer.schedule(deadline: .now() + timeout) + timer.setEventHandler { finish(nil) } + timer.resume() + self.listeners[id] = { data in + guard let p = Packet.parse(data) else { return } + if p.tag == tag && (ext == nil || p.extTag == ext) { finish(p) } + } + self.writeRaw(req) + } + } + } + + /// One `GetEvent` batch: collect event frames until the `0x11` summary or timeout. + private func getEventBatch(start: UInt32, timeout: TimeInterval) async -> (events: [Packet], bytesLeft: UInt32?, maxTs: UInt32, gotSummary: Bool) { + await withCheckedContinuation { cont in + bleQueue.async { + var evs: [Packet] = [] + var maxTs = start + var bytesLeft: UInt32? + var gotSummary = false + let id = UUID() + var done = false + let timer = DispatchSource.makeTimerSource(queue: self.bleQueue) + func finish() { + if done { return }; done = true + self.listeners.removeValue(forKey: id); timer.cancel() + cont.resume(returning: (evs, bytesLeft, maxTs, gotSummary)) + } + timer.schedule(deadline: .now() + timeout) + timer.setEventHandler(handler: finish) + timer.resume() + self.listeners[id] = { data in + guard let p = Packet.parse(data) else { return } + if p.tag == 0x11 { + gotSummary = true + if p.payload.count >= 6 { + bytesLeft = UInt32(p.payload[2]) | UInt32(p.payload[3]) << 8 + | UInt32(p.payload[4]) << 16 | UInt32(p.payload[5]) << 24 + } + finish() + } else if p.tag >= HISTORY_EVENT_PREFIX { + evs.append(p) + if p.payload.count >= 4 { + let ts = UInt32(p.payload[0]) | UInt32(p.payload[1]) << 8 + | UInt32(p.payload[2]) << 16 | UInt32(p.payload[3]) << 24 + maxTs = max(maxTs, ts) + } + } + } + self.writeRaw(Req.getEvent(start: start, maxEvents: 255, flags: -1)) + } + } + } + + /// Stream-safe incremental drain from `cursor`; calls `onEvent` per event. + /// Returns the next cursor plus whether the drain completed without timeout. + @discardableResult + private func drainEventsLive(cursor: UInt32, onEvent: (DecodedEvent) -> Void) async -> (cursor: UInt32, complete: Bool) { + var start = cursor + for _ in 0..<10_000 { + let batch = await getEventBatch(start: start, timeout: 1.5) + guard batch.gotSummary else { + dbg("GetEvent timed out before summary at cursor \(start)") + return (start, false) + } + guard let bytesLeft = batch.bytesLeft else { + dbg("GetEvent summary missing bytes-left at cursor \(start)") + return (start, false) + } + for p in batch.events { + guard p.payload.count >= 4 else { continue } + let ts = UInt32(p.payload[0]) | UInt32(p.payload[1]) << 8 + | UInt32(p.payload[2]) << 16 | UInt32(p.payload[3]) << 24 + let body = Data(p.payload[4...]) + let json = OuraCore.decodeEvent(tag: p.tag, body: body) ?? [:] + let identity = "\(p.tag)-\(ts)-\(body.base64EncodedString())" + onEvent(DecodedEvent(tag: p.tag, timestamp: ts, name: OuraCore.eventName(tag: p.tag), json: json, identity: identity)) + } + guard batch.maxTs < UInt32.max else { + dbg("GetEvent cursor overflow at \(batch.maxTs)") + return (start, false) + } + let next = batch.maxTs + 1 + let progressed = !batch.events.isEmpty && next > start + if progressed { start = next } + if bytesLeft == 0 { return (start, true) } + if !progressed { + dbg("GetEvent stalled at cursor \(start) with \(bytesLeft) bytes left") + return (start, false) + } + } + dbg("GetEvent drain hit batch limit at cursor \(start)") + return (start, false) + } + + // MARK: - High-level flows + + /// Pair a factory-reset ring exactly like the real app: connect, generate a + /// 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 { + let activity = await beginKeepAwake("oura-pair") + defer { Task { await self.endKeepAwake(activity) } } + if writeChar == nil || !notifyReady { + if writeChar == nil { connect() } + await waitUntilReady() + } + guard writeChar != nil, notifyReady else { + dbg("pairNewRing: link never became ready") + failConnect("Couldn't connect", "Couldn't connect to the ring. Keep it close and make sure it isn't connected elsewhere, then try again.") + return + } + publish { self.state = .authenticating; self.status = "Pairing…" } + + var keyBytes = [UInt8](repeating: 0, count: 16) + guard SecRandomCopyBytes(kSecRandomDefault, 16, &keyBytes) == errSecSuccess else { + publish { self.state = .failed("RNG error") } + return + } + let key = Data(keyBytes) + // Persist before installing, so a crash mid-pair never loses the only copy + // of a key that may already be live on the ring. + guard KeyStore.saveHex(key.map { String(format: "%02x", $0) }.joined()) else { + publish { self.state = .failed("Keychain error"); self.status = "Pair failed - couldn't save key" } + return + } + + guard let resp = await requestUntil(Req.setAuthKey(key), tag: 0x25, ext: nil, timeout: 3.0) else { + publish { self.state = .failed("SetAuthKey timed out"); self.status = "Pair uncertain - reconnect to verify" } + return + } + guard resp.payload.first == 0x00 else { + KeyStore.clear() + publish { self.state = .failed("SetAuthKey rejected"); self.status = "Pair failed - is the ring factory-reset?" } + return + } + publish { self.status = "Key installed — authenticating…" } + await authenticateAndLoad() + } + + /// Connect (if needed) via the interactive scan flow, authenticate, then + /// auto-sync if enabled. Used by the guide's Connect button. + func authenticateAndLoad() async { + let activity = await beginKeepAwake("oura-connect-sync") + defer { Task { await self.endKeepAwake(activity) } } + if writeChar == nil { + connect() + await waitUntilReady() + } + guard writeChar != nil else { + dbg("authenticateAndLoad: link never became ready") + // Make sure the UI doesn't stay stuck in "Connecting…": surface failure. + if case .failed = state {} else { + failConnect("Couldn't connect", "Couldn't connect to the ring. Keep it close and make sure it isn't connected elsewhere, then try again.") + } + return + } + if await runAuth(), autoSync { await autoSyncIncremental() } + } + + /// Authenticate over an already-established link, read metadata. Returns success. + @discardableResult + private func runAuth() async -> Bool { + guard let key = KeyStore.keyBytes() else { + publish { self.state = .failed("No auth key set"); self.status = "Set the ring key in Settings" } + return false + } + publish { self.state = .authenticating; self.status = "Authenticating…" } + dbg("requesting auth nonce") + + guard let noncePkt = await requestUntil(Req.authNonce, tag: 0x2f, ext: 0x2c, timeout: 2.0), + noncePkt.payload.count > 1 else { + dbg("no nonce response") + publish { self.state = .failed("No nonce"); self.status = "Auth failed (no nonce)"; self.alert = "The ring didn't respond to the auth handshake. Try Connect again." } + return false + } + dbg("nonce received (\(noncePkt.payload.count - 1) bytes)") + let nonce = Data(noncePkt.payload[1...]) + guard let enc = OuraCore.encryptNonce(key: key, nonce: nonce) else { + publish { self.state = .failed("Crypto error") } + return false + } + let authPkt = await requestUntil(Req.authenticate(enc), tag: 0x2f, ext: 0x2e, timeout: 2.0) + guard let authPkt, authPkt.payload.count > 1, authPkt.payload[1] == 0x00 else { + let code = authPkt?.payload.count ?? 0 > 1 ? authPkt!.payload[1] : 0xff + dbg("auth rejected (state byte \(code))") + publish { self.state = .failed("Auth rejected"); self.status = "Auth rejected — wrong key? (\(code))"; self.alert = "The ring rejected the key (code \(code)). Make sure the imported key matches the one the ring was paired with." } + return false + } + dbg("authenticated OK") + publish { self.state = .ready; self.status = "Connected"; self.alert = nil } + await readDeviceInfo() + await readBattery() + return true + } + + // MARK: - Auto-reconnect (pending connect: connects whenever the ring wakes) + + /// Silently (re)connect to the known ring without scanning or a timeout — iOS + /// completes the connection whenever the ring next becomes available (on its + /// charger or worn). On link-up we authenticate and, if enabled, sync. + func autoReconnect() { + bleQueue.async { + self.wantsAutoReconnect = true + self.startAutoReconnectIfReady() + } + } + + private func startAutoReconnectIfReady() { + guard wantsAutoReconnect, autoConnect, !connecting, writeChar == nil, + central.state == .poweredOn, KeyStore.keyBytes() != nil + else { return } + connecting = true + publish { if self.state != .ready { self.state = .connecting }; self.status = "Waiting for ring…" } + if let idStr = UserDefaults.standard.string(forKey: "ringPeripheralID"), + let uuid = UUID(uuidString: idStr), + let p = central.retrievePeripherals(withIdentifiers: [uuid]).first { + // Known ring: pending connect, no timeout — completes when it wakes. + peripheral = p + p.delegate = self + dbg("auto-reconnect: pending connect to known ring") + central.connect(p) + } else { + // First reconnect on this install: scan silently (no timeout, no modal, + // no failure alert). When the ring advertises we connect + save its id. + autoScanning = true + dbg("auto-reconnect: scanning silently (no known ring id yet)") + central.scanForPeripherals(withServices: [OuraGATT.service]) + } + } + + func readDeviceInfo() async { + let pkts = await transact(Req.firmware, quiet: 1.0) + if let p = pkts.first(where: { $0.tag == 0x09 }), p.payload.count >= 18 { + let fw = "\(p.payload[3]).\(p.payload[4]).\(p.payload[5])" + publish { self.firmware = fw } + } + if let s = await productString(Req.serial) { publish { self.serial = s } } + if let h = await productString(Req.hardware) { publish { self.hardware = h } } + } + + private func productString(_ req: Data) async -> String? { + let pkts = await transact(req, quiet: 0.8) + guard let p = pkts.first(where: { $0.tag == 0x19 }), p.payload.first == 0 else { return nil } + let bytes = Array(p.payload[1...]).prefix { $0 != 0 } + return String(bytes: bytes, encoding: .utf8) + } + + func readBattery() async { + if let p = await requestUntil(Req.battery, tag: 0x0d, ext: nil, timeout: 1.0), p.payload.count >= 3 { + let pct = Int(p.payload[0]); let chg = p.payload[1] > 0 + publish { self.batteryPercent = pct; self.charging = chg } + } + } + + // MARK: - Live mode (HR / HRV / motion / battery) + + func startLive() { + dbg("startLive tapped (state ready=\(state == .ready), already live=\(liveActive))") + guard state == .ready, !liveActive else { return } + publish { self.liveActive = true; self.status = "Live — measuring…" } + liveTask = Task { await self.runLive() } + } + + func stopLive() { + liveTask?.cancel(); liveTask = nil + bleQueue.async { + if let id = self.liveListenerID { self.listeners.removeValue(forKey: id); self.liveListenerID = nil } + self.ibiBuffer.removeAll() + self.writeRaw(Req.realtimeOff) + self.writeRaw(Req.setFeatureMode(Feature.daytimeHR, FeatureMode.automatic)) + } + publish { self.liveActive = false; self.status = "Connected"; self.motionG = nil } + } + + private func clearLiveAfterDisconnect() { + liveTask?.cancel(); liveTask = nil + if let id = liveListenerID { listeners.removeValue(forKey: id); liveListenerID = nil } + ibiBuffer.removeAll() + restWin = 0; restMoves = 0 + publish { self.liveActive = false; self.motionG = nil } + } + + private func runLive() async { + dbg("live: enabling notifications + daytime-HR CONNECTED_LIVE") + await sendAndWait(Req.setNotification(0x3f)) + await sendAndWait(Req.setFeatureMode(Feature.daytimeHR, FeatureMode.connectedLive)) + // Confirm the ring actually entered the measuring state (green LED on). + if let st = await feature_status(Feature.daytimeHR) { + dbg("live: daytime-HR mode=\(st.mode) state=\(st.state) status=\(st.status) (state 2 = measuring)") + } else { + dbg("live: no feature-status response") + } + let savedCursor = UInt32(truncatingIfNeeded: UserDefaults.standard.integer(forKey: "syncCursor")) + var cursor = max(liveCursor ?? savedCursor, savedCursor) + liveCursor = cursor + dbg("live: starting cursor=\(cursor)") + installLiveACMListener() + await sendAndWait(Req.setRealtime(bitmask: Realtime.acm, minutes: 5, delay: 0)) + dbg("live: ACM armed; entering poll loop") + + var tick = 0 + while !Task.isCancelled { + if tick > 0 && tick % 120 == 0 { + await sendAndWait(Req.setRealtime(bitmask: Realtime.acm, minutes: 5, delay: 0)) + dbg("live: ACM re-armed") + } + var n = 0, hr80 = 0 + let drain = await drainEventsLive(cursor: cursor) { [weak self] ev in + n += 1; if ev.tag == 0x80 { hr80 += 1 } + self?.handleLiveEvent(ev) + } + cursor = drain.cursor + if drain.complete { liveCursor = cursor } + dbg("live: tick \(tick) drained \(n) events (\(hr80)×0x80) cursor=\(cursor)") + if tick % 6 == 0 { await readBattery() } + tick += 1 + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + + /// Read a feature's status (mode/state/status) — used to confirm measuring. + private func feature_status(_ f: UInt8) async -> (mode: UInt8, status: UInt8, state: UInt8)? { + guard let p = await requestUntil(Req.featureStatus(f), tag: 0x2f, ext: 0x21, timeout: 1.0), + p.payload.count >= 6 else { return nil } + return (p.payload[2], p.payload[3], p.payload[4]) + } + + private func sendAndWait(_ req: Data) async { + bleQueue.async { self.writeRaw(req) } + try? await Task.sleep(nanoseconds: 120_000_000) + } + + private func installLiveACMListener() { + bleQueue.async { + let id = UUID() + self.liveListenerID = id + self.listeners[id] = { [weak self] data in self?.handleACMFrame(data) } + } + } + + private func handleACMFrame(_ data: Data) { + let b = [UInt8](data) + guard b.count >= 10, b[0] == Realtime.acmResponseTag else { return } + func s(_ o: Int) -> Int16 { Int16(bitPattern: UInt16(b[o]) | UInt16(b[o + 1]) << 8) } + let x = Double(s(4)), y = Double(s(6)), z = Double(s(8)) + let g = (x * x + y * y + z * z).squareRoot() / 1024.0 + restWin += 1 + if abs(g - 1.0) > 0.06 { restMoves += 1 } + let rest = restWin > 0 ? Int(Double(restMoves) / Double(restWin) * 100) : 0 + publish { + self.motionG = g + self.motionSeries.append(g); if self.motionSeries.count > 300 { self.motionSeries.removeFirst() } + self.restlessness = rest + } + if restWin > 50 { restWin = 0; restMoves = 0 } + } + + private func handleLiveEvent(_ ev: DecodedEvent) { + guard ev.tag == 0x80 else { return } + // bpm: last plausible value in hr_bpm + if let arr = ev.json["hr_bpm"] as? [Any] { + let bpms = arr.compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 > 30 && $0 < 240 } + if let bpm = bpms.last { + publish { + self.liveHR = bpm + self.hrSeries.append(Double(bpm)); if self.hrSeries.count > 240 { self.hrSeries.removeFirst() } + } + } + } + // HRV (RMSSD) from physiologically plausible IBIs + if let arr = ev.json["ibi_ms"] as? [Any] { + let ibis = arr.compactMap { ($0 as? NSNumber)?.doubleValue }.filter { $0 >= 400 && $0 <= 1300 } + guard !ibis.isEmpty else { return } + bleQueue.async { [weak self] in + guard let self else { return } + self.ibiBuffer.append(contentsOf: ibis) + while self.ibiBuffer.count > 40 { self.ibiBuffer.removeFirst() } + if let hrv = Self.rmssd(self.ibiBuffer) { + self.publish { + self.liveHRV = hrv + self.hrvSeries.append(Double(hrv)); if self.hrvSeries.count > 120 { self.hrvSeries.removeFirst() } + } + } + } + } + } + + static func rmssd(_ ibi: [Double]) -> Int? { + guard ibi.count >= 3 else { return nil } + var s = 0.0, n = 0 + for i in 1.. 250 { continue } + s += d * d; n += 1 + } + return n >= 2 ? Int((s / Double(n)).squareRoot().rounded()) : nil + } + + // MARK: - Full history sync (Today/Sleep/Activity tabs) + + /// Full re-sync from scratch (manual "Sync now"). Replaces the cached history. + func syncHistory() async { + guard state == .ready, !syncing, !liveActive else { return } + let activity = await beginKeepAwake("oura-full-sync") + defer { Task { await self.endKeepAwake(activity) } } + publish { self.syncing = true; self.status = "Syncing…" } + // Drain into a scratch buffer; the displayed model is only swapped in once + // the sync *completes*, so the UI never shows a half-synced state. + var collected: [DecodedEvent] = [] + let drain = await drainEventsLive(cursor: 0) { ev in collected.append(ev) } + guard drain.complete else { + publish { self.syncing = false; self.status = "Sync timed out - try again closer to the ring" } + return + } + let model = HealthData(events: collected) + guard HealthStore.save(collected) else { + publish { self.syncing = false; self.status = "Sync failed - couldn't save history" } + return + } + UserDefaults.standard.set(Int(drain.cursor), forKey: "syncCursor") + liveCursor = drain.cursor + publish { + self.events = collected + self.health = model + self.syncing = false + self.lastSync = HealthStore.lastSync + self.status = "Synced" + } + } + + /// Incremental, background-safe sync: drain only events newer than the saved + /// cursor, merge into the cached set, persist. Wrapped in a background task so + /// it can finish when the app was woken in the background by state restoration. + func autoSyncIncremental() async { + guard state == .ready, !syncing, !liveActive else { return } + let activity = await beginKeepAwake("oura-incremental-sync") + defer { Task { await self.endKeepAwake(activity) } } + publish { self.syncing = true; self.status = "Syncing…" } + let start = UInt32(truncatingIfNeeded: UserDefaults.standard.integer(forKey: "syncCursor")) + var fresh: [DecodedEvent] = [] + let drain = await drainEventsLive(cursor: start) { fresh.append($0) } + guard drain.complete else { + publish { self.syncing = false; self.status = "Sync timed out - try again closer to the ring" } + return + } + var all = HealthStore.load() + var seen = Set(all.map { $0.identity }) + for e in fresh where seen.insert(e.identity).inserted { all.append(e) } + if all.count > 100_000 { all = Array(all.suffix(100_000)) } + let model = HealthData(events: all) + guard HealthStore.save(all) else { + publish { self.syncing = false; self.status = "Sync failed - couldn't save history" } + return + } + UserDefaults.standard.set(Int(drain.cursor), forKey: "syncCursor") + liveCursor = max(liveCursor ?? drain.cursor, drain.cursor) + dbg("incremental sync: +\(fresh.count) events (cursor \(start)→\(drain.cursor)), \(all.count) total") + publish { + self.events = all + self.health = model + self.syncing = false + self.lastSync = HealthStore.lastSync + self.status = fresh.isEmpty ? "Already synced" : "Synced" + } + } + + /// Load the last synced history from disk so the UI has real data immediately. + func loadCachedHistory() { + let cached = HealthStore.load() + guard !cached.isEmpty else { return } + let model = HealthData(events: cached) + publish { self.events = cached; self.health = model } + } + + /// Factory-reset the ring (wipes its key + data) and clear all local state so + /// the user can pair fresh. Danger — gated behind a confirmation in the UI. + func factoryReset() async { + guard state == .ready else { return } + dbg("factory reset requested") + guard let resp = await requestUntil(Req.factoryReset, tag: 0x1b, ext: nil, timeout: 2.0), + resp.payload.first == 0x00 else { + publish { + self.state = .failed("Factory reset uncertain") + self.status = "Factory reset uncertain - reconnect before clearing local state" + } + return + } + KeyStore.clear() + UserDefaults.standard.removeObject(forKey: "ringPeripheralID") + UserDefaults.standard.removeObject(forKey: "syncCursor") + liveCursor = nil + HealthStore.clear() + bleQueue.async { + self.userDisconnecting = true + self.wantsAutoReconnect = false + if let p = self.peripheral { self.central.cancelPeripheralConnection(p) } + } + publish { + self.state = .idle + self.status = "Ring factory-reset — pair again" + self.firmware = nil; self.serial = nil; self.hardware = nil + self.batteryPercent = nil; self.events = []; self.health = HealthData(); self.lastSync = nil + self.showConnectGuide = true + } + } +} + +// MARK: - CoreBluetooth delegates +extension OuraRing: CBCentralManagerDelegate, CBPeripheralDelegate { + /// iOS relaunched us (possibly into the background) and is handing back the BLE + /// objects it preserved. Re-acquire the peripheral; we resume once powered on. + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + let ps = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + dbg("willRestoreState: \(ps.count) peripheral(s)") + if let p = ps.first { + peripheral = p + p.delegate = self + wantsAutoReconnect = true + connecting = true // resumed in didUpdateState once .poweredOn + } + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + dbg("central state -> \(Self.centralStateName(central.state))") + // Resume a restored peripheral (state restoration) before anything else. + if central.state == .poweredOn, let p = peripheral, connecting, writeChar == nil { + if p.state == .connected { + dbg("resume: restored peripheral connected — discovering services") + p.discoverServices([OuraGATT.service]) + } else { + dbg("resume: reconnecting restored peripheral") + central.connect(p) + } + return + } + startScanIfReady() // resume a pending manual scan + startAutoReconnectIfReady() // resume a pending auto-reconnect + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + dbg("discovered \(peripheral.name ?? "ring") rssi \(RSSI)") + let knownID = UserDefaults.standard.string(forKey: "ringPeripheralID") + let knownRing = knownID == peripheral.identifier.uuidString + if !autoScanning, !knownRing, RSSI.intValue < -85 { + dbg("ignoring weak unknown ring advertisement rssi \(RSSI)") + return + } + central.stopScan() + scanTimer?.cancel() + self.peripheral = peripheral + peripheral.delegate = self + publish { if self.state != .ready { self.state = .connecting }; self.status = "Connecting…" } + central.connect(peripheral) + if autoScanning { + // Silent background reconnect — no timeout, no failure alert. + autoScanning = false + dbg("auto-reconnect: connecting (no timeout)") + return + } + // Manual scan path: CoreBluetooth connect() has no timeout of its own — bound + // it so a ring held by another central (e.g. a paired Mac) can't hang us. + connectTimer?.cancel() + let t = DispatchSource.makeTimerSource(queue: bleQueue) + t.schedule(deadline: .now() + 30) + t.setEventHandler { [weak self] in + guard let self, self.writeChar == nil else { return } + let st = self.peripheral.map { Self.peripheralStateName($0.state) } ?? "none" + self.dbg("connect timeout (peripheral.state=\(st))") + self.failConnect("Couldn't connect", "Couldn't connect to the ring. Wear it (or put it on the charger) and keep the phone right next to it, then try again.") + } + t.resume(); connectTimer = t + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + dbg("connected — discovering services") + // Remember this ring so future launches can silently auto-reconnect. + UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: "ringPeripheralID") + peripheral.discoverServices([OuraGATT.service]) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + failConnect("Connect failed", "Connect failed: \(error?.localizedDescription ?? "unknown")") + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + dbg("disconnected\(error.map { ": \($0.localizedDescription)" } ?? "")") + self.writeChar = nil + self.notifyReady = false + self.connecting = false + self.clearLiveAfterDisconnect() + // Unless the user asked to disconnect, immediately re-arm a pending connect + // so we silently reconnect when the ring next wakes (charger/worn). + if !userDisconnecting, autoConnect { + wantsAutoReconnect = true + publish { self.state = .connecting; self.status = "Waiting for ring…" } + startAutoReconnectIfReady() + } else { + publish { if self.state != .idle { self.state = .idle }; self.status = "Disconnected" } + } + userDisconnecting = false + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { dbg("discoverServices error: \(error.localizedDescription)") } + guard let svc = peripheral.services?.first(where: { $0.uuid == OuraGATT.service }) else { + dbg("Oura service not found among \(peripheral.services?.count ?? 0) services") + return + } + dbg("service found — discovering characteristics") + peripheral.discoverCharacteristics(nil, for: svc) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + notifyReady = false + for c in service.characteristics ?? [] { + if c.uuid == OuraGATT.write { writeChar = c } + if c.properties.contains(.notify) || c.properties.contains(.indicate) { + if c.isNotifying { + notifyReady = true + } else { + peripheral.setNotifyValue(true, for: c) + } + } + } + if writeChar != nil, notifyReady { + finishReadyIfPossible() + } else if writeChar != nil { + dbg("write characteristic found; waiting for notifications") + } else { + dbg("write characteristic not found in service") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error { + dbg("notify setup error: \(error.localizedDescription)") + return + } + guard characteristic.isNotifying else { return } + notifyReady = true + finishReadyIfPossible() + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + for listener in listeners.values { listener(data) } + } +} diff --git a/ios/OpenOura/Core/HealthData.swift b/ios/OpenOura/Core/HealthData.swift new file mode 100644 index 00000000..244452b0 --- /dev/null +++ b/ios/OpenOura/Core/HealthData.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Derived health summaries computed from a synced batch of decoded events. +/// Mirrors what the Oura app surfaces, from the data the ring actually emits. +struct HealthData { + struct Sample: Identifiable { let id = UUID(); let ts: UInt32; let value: Double } + + var eventCounts: [(name: String, count: Int)] = [] + var hr: [Sample] = [] + var hrv: [Sample] = [] + var temp: [Sample] = [] + var spo2: [Sample] = [] + var hypnogram: [String] = [] + var totalEvents = 0 + + var latestHR: Int? { hr.last.map { Int($0.value.rounded()) } } + var latestHRV: Int? { hrv.last.map { Int($0.value.rounded()) } } + var latestTemp: Double? { temp.last?.value } + + /// Sleep-stage distribution (epoch counts) from the most recent hypnogram. + var stageCounts: [(stage: String, count: Int)] { + let order = ["deep", "rem", "light", "awake"] + var c: [String: Int] = [:] + for s in hypnogram { c[s, default: 0] += 1 } + return order.compactMap { s in c[s].map { (s, $0) } } + } + + init() {} + + init(events: [DecodedEvent]) { + totalEvents = events.count + var counts: [String: Int] = [:] + let sorted = events.sorted { $0.timestamp < $1.timestamp } + + func nums(_ any: Any?) -> [Double] { + (any as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue } ?? [] + } + + for e in sorted { + counts[e.name, default: 0] += 1 + switch e.tag { + case 0x80, 0x60: // green/ibi HR events + let v = nums(e.json["hr_bpm"]).filter { $0 > 30 && $0 < 240 } + if let m = v.last { hr.append(.init(ts: e.timestamp, value: m)) } + case 0x5d: // hrv_event: arrays of hr_bpm + rmssd_ms (per 5 min) + if let r = nums(e.json["rmssd_ms"]).last, r > 0 { hrv.append(.init(ts: e.timestamp, value: r)) } + if let h = nums(e.json["hr_bpm"]).last, h > 30 { hr.append(.init(ts: e.timestamp, value: h)) } + case 0x46, 0x69, 0x75: // temperature + 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 = nums(e.json["spo2_percent"]).filter({ $0 > 50 }).last { + spo2.append(.init(ts: e.timestamp, value: s)) + } + case 0x4b, 0x4e, 0x5a: // sleep phases (hypnogram) + if let ph = e.json["phases"] as? [String] { hypnogram = ph } + default: break + } + } + eventCounts = counts.sorted { $0.value > $1.value }.map { ($0.key, $0.value) } + } +} diff --git a/ios/OpenOura/Core/HealthStore.swift b/ios/OpenOura/Core/HealthStore.swift new file mode 100644 index 00000000..525ba8ac --- /dev/null +++ b/ios/OpenOura/Core/HealthStore.swift @@ -0,0 +1,55 @@ +import Foundation + +/// On-disk cache of the last synced history, so the app shows real data instantly +/// on launch and only refreshes it when a new sync *completes* (never mid-sync). +enum HealthStore { + private struct StoredEvent: Codable { let tag: UInt8; let ts: UInt32; let json: String; let identity: String? } + + private static let fileURL: URL = { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("oura_history.json") + }() + + static func save(_ events: [DecodedEvent]) -> Bool { + let stored = events.map { + StoredEvent(tag: $0.tag, ts: $0.timestamp, json: jsonString($0.json), identity: $0.identity) + } + do { + let data = try JSONEncoder().encode(stored) + try data.write(to: fileURL, options: [.atomic]) + } catch { + return false + } + UserDefaults.standard.set(Date(), forKey: "lastSyncDate") + return true + } + + static func load() -> [DecodedEvent] { + guard let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([StoredEvent].self, from: data) else { return [] } + return stored.map { se in + let dict = (try? JSONSerialization.jsonObject(with: Data(se.json.utf8))) as? [String: Any] ?? [:] + return DecodedEvent( + tag: se.tag, + timestamp: se.ts, + name: OuraCore.eventName(tag: se.tag), + json: dict, + identity: se.identity ?? "\(se.tag)-\(se.ts)-\(se.json)" + ) + } + } + + static func clear() { + try? FileManager.default.removeItem(at: fileURL) + UserDefaults.standard.removeObject(forKey: "lastSyncDate") + } + + static var lastSync: Date? { UserDefaults.standard.object(forKey: "lastSyncDate") as? Date } + + private static func jsonString(_ d: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: d), + let s = String(data: data, encoding: .utf8) else { return "{}" } + return s + } +} diff --git a/ios/OpenOura/Core/KeyStore.swift b/ios/OpenOura/Core/KeyStore.swift new file mode 100644 index 00000000..2f8e5315 --- /dev/null +++ b/ios/OpenOura/Core/KeyStore.swift @@ -0,0 +1,67 @@ +import Foundation +import Security + +/// Stores the 16-byte ring auth key (as hex) in the iOS Keychain. The same key +/// that `oura pair` installed on the ring is used here — paste its hex once. +enum KeyStore { + private static let service = "com.openoura.app" + private static let account = "ring-auth-key" + + static func saveHex(_ hex: String) -> Bool { + let clean = hex.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = clean.data(using: .utf8) else { return false } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + var add = query + add[kSecValueData as String] = data + return SecItemAdd(add as CFDictionary, nil) == errSecSuccess + } + + static func clear() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } + + static func loadHex() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var out: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data, let s = String(data: data, encoding: .utf8) + else { return nil } + return s + } + + /// The key as 16 raw bytes, or nil if unset/invalid. + static func keyBytes() -> Data? { + guard let hex = loadHex() else { return nil } + return dataFromHex(hex) + } + + static func dataFromHex(_ hex: String) -> Data? { + let s = hex.trimmingCharacters(in: .whitespacesAndNewlines) + guard s.count == 32 else { return nil } + var bytes = [UInt8]() + var i = s.startIndex + while i < s.endIndex { + let j = s.index(i, offsetBy: 2) + guard let b = UInt8(s[i.. Data? { + guard key.count == 16 else { return nil } + var out = [UInt8](repeating: 0, count: 16) + let rc = key.withUnsafeBytes { kp in + nonce.withUnsafeBytes { np in + oura_encrypt_nonce( + kp.bindMemory(to: UInt8.self).baseAddress, key.count, + np.bindMemory(to: UInt8.self).baseAddress, nonce.count, + &out) + } + } + return rc == 0 ? Data(out) : nil + } + + /// Decode an event body for `tag` into a parsed JSON object, or nil. + static func decodeEvent(tag: UInt8, body: Data) -> [String: Any]? { + let cstr: UnsafeMutablePointer? = body.withUnsafeBytes { bp in + oura_decode_event(tag, bp.bindMemory(to: UInt8.self).baseAddress, body.count) + } + guard let cstr else { return nil } + defer { oura_string_free(cstr) } + let json = String(cString: cstr) + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return obj + } + + /// Human-readable name for an event tag. + static func eventName(tag: UInt8) -> String { + guard let cstr = oura_event_name(tag) else { return "tag_\(tag)" } + defer { oura_string_free(cstr) } + return String(cString: cstr) + } +} diff --git a/ios/OpenOura/Info.plist b/ios/OpenOura/Info.plist new file mode 100644 index 00000000..04dc2418 --- /dev/null +++ b/ios/OpenOura/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Open Oura + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + NSBluetoothAlwaysUsageDescription + Open Oura connects to your Oura ring over Bluetooth to read heart rate, motion, and sleep data directly from the device. + NSBluetoothPeripheralUsageDescription + Open Oura connects to your Oura ring over Bluetooth. + UIBackgroundModes + + bluetooth-central + + + diff --git a/ios/OpenOura/OpenOuraApp.swift b/ios/OpenOura/OpenOuraApp.swift new file mode 100644 index 00000000..53029a87 --- /dev/null +++ b/ios/OpenOura/OpenOuraApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct OpenOuraApp: App { + @StateObject private var ring = OuraRing() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(ring) + .preferredColorScheme(.dark) + .task { + ring.loadCachedHistory() // show last-synced data immediately + // Silently reconnect to a known ring on launch; otherwise guide. + if ring.canAutoReconnect { ring.autoReconnect() } + else { ring.showConnectGuide = true } + } + } + } +} diff --git a/ios/OpenOura/Views/Components.swift b/ios/OpenOura/Views/Components.swift new file mode 100644 index 00000000..cd7c54b0 --- /dev/null +++ b/ios/OpenOura/Views/Components.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// Rounded dark card container. +struct Card: View { + var padding: CGFloat = 16 + @ViewBuilder var content: Content + var body: some View { + content + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Brand.card) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +/// App header: logo + title + last-synced subtitle. +struct AppHeader: View { + @EnvironmentObject var ring: OuraRing + let title: String + var body: some View { + HStack(spacing: 11) { + RingLogo().frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.title2.bold()) + Text(subtitle).font(.caption2).foregroundStyle(Brand.dim) + } + Spacer() + if ring.syncing { ProgressView().controlSize(.small) } + } + .padding(.horizontal, 18).padding(.top, 8) + } + private var subtitle: String { + if ring.syncing { return "Syncing…" } + if let d = ring.lastSync { return "Synced \(relativeTime(d))" } + return ring.state == .ready ? "Connected" : "Not synced yet" + } +} + +/// Circular gauge (Oura-style). Maps `value` within `range` to an arc. +struct ScoreRing: View { + let value: Double? + var range: ClosedRange = 40...120 + var color: Color + var caption: String + var unit: String + var pulse = false + @State private var animatePulse = false + + var body: some View { + let frac = value.map { min(max(($0 - range.lowerBound) / (range.upperBound - range.lowerBound), 0), 1) } ?? 0 + ZStack { + Circle().stroke(Brand.line, lineWidth: 16) + Circle() + .trim(from: 0, to: CGFloat(frac)) + .stroke(color.gradient, style: StrokeStyle(lineWidth: 16, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .shadow(color: color.opacity(0.5), radius: 8) + .animation(.easeOut(duration: 0.6), value: frac) + VStack(spacing: 0) { + Text(value.map { String(Int($0.rounded())) } ?? "—") + .font(.system(size: 52, weight: .bold, design: .rounded)) + .contentTransition(.numericText()) + Text(unit).font(.footnote).foregroundStyle(Brand.dim) + Text(caption).font(.caption2.weight(.semibold)).foregroundStyle(color).padding(.top, 2) + } + .scaleEffect(pulse && animatePulse ? 1.04 : 1.0) + .animation(pulse ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) : nil, value: animatePulse) + } + .onAppear { animatePulse = pulse } + } +} + +/// Small metric tile with optional sparkline. +struct MetricTile: View { + let title: String + let value: String + var unit: String = "" + var accent: Color + var spark: [Double]? = nil + + var body: some View { + Card { + VStack(alignment: .leading, spacing: 6) { + Text(title.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(accent) + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(value).font(.system(size: 26, weight: .bold, design: .rounded)) + if !unit.isEmpty { Text(unit).font(.caption2).foregroundStyle(Brand.dim) } + } + if let spark, spark.count > 1 { + Sparkline(values: spark, color: accent).frame(height: 26) + } else { + Spacer().frame(height: 0) + } + } + } + } +} + +/// Lightweight Path-based sparkline (no external charting dependency). +struct Sparkline: View { + var values: [Double] + var color: Color + var minY: Double? = nil + var maxY: Double? = nil + + var body: some View { + GeometryReader { geo in + let vals = values + if vals.count >= 2 { + let lo = minY ?? (vals.min() ?? 0) + let hi = maxY ?? (vals.max() ?? 1) + let range = Swift.max(hi - lo, 0.0001) + let w = geo.size.width, h = geo.size.height + let pts = vals.enumerated().map { i, v in + CGPoint(x: w * Double(i) / Double(vals.count - 1), y: h - (v - lo) / range * h) + } + ZStack { + Path { p in + p.move(to: CGPoint(x: 0, y: h)) + pts.forEach { p.addLine(to: $0) } + p.addLine(to: CGPoint(x: w, y: h)) + }.fill(LinearGradient(colors: [color.opacity(0.25), color.opacity(0)], startPoint: .top, endPoint: .bottom)) + Path { p in p.move(to: pts[0]); pts.dropFirst().forEach { p.addLine(to: $0) } } + .stroke(color, style: StrokeStyle(lineWidth: 2, lineJoin: .round)) + } + } + } + } +} + +func relativeTime(_ d: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: d, relativeTo: Date()) +} diff --git a/ios/OpenOura/Views/ConnectGuideView.swift b/ios/OpenOura/Views/ConnectGuideView.swift new file mode 100644 index 00000000..c7ca6b71 --- /dev/null +++ b/ios/OpenOura/Views/ConnectGuideView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +/// Onboarding / connection guide. Walks the user through placing the ring on its +/// charging pad next to the phone (it sleeps when off-charger and not worn, so the +/// pad is what makes it reliably connectable), then drives connect/auth with live +/// progress and a clear success/failure state. +struct ConnectGuideView: View { + @EnvironmentObject var ring: OuraRing + @Environment(\.dismiss) private var dismiss + + private var paired: Bool { KeyStore.keyBytes() != nil } + private var inProgress: Bool { + [ConnState.scanning, .connecting, .authenticating].contains(ring.state) + } + private var failed: Bool { if case .failed = ring.state { return true } else { return false } } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 28) { + illustration + .frame(height: 130) + .padding(.top, 28) + + Text(ring.state == .ready ? "Ring connected" : "Connect your ring") + .font(.title2.bold()) + + if ring.state == .ready { + connectedBody + } else { + steps + } + } + .padding(.horizontal, 24) + } + + footer + .padding(20) + .background(.ultraThinMaterial) + } + .presentationDragIndicator(.visible) + .onChange(of: ring.state) { newValue in + // Auto-dismiss shortly after a successful connection. + if newValue == .ready { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { + if ring.state == .ready { dismiss() } + } + } + } + } + + // MARK: pieces + + private var illustration: some View { + HStack(spacing: 18) { + Image(systemName: "iphone") + .font(.system(size: 60, weight: .light)) + .foregroundStyle(.white) + Image(systemName: "dot.radiowaves.left.and.right") + .font(.title) + .foregroundStyle(inProgress ? .pink : Color(white: 0.4)) + ZStack { + Circle().fill(Color(white: 0.16)).frame(width: 96, height: 96) // pad + Circle().fill(Color(white: 0.10)).frame(width: 70, height: 70) // pad inset + Circle().stroke(ring.state == .ready ? .green : .pink, lineWidth: 9) // the ring + .frame(width: 44, height: 44) + } + } + } + + private var steps: some View { + VStack(alignment: .leading, spacing: 18) { + step(1, "Place the ring on its charging pad", "Off the charger and not worn, the ring sleeps to save battery. The pad wakes it so it's reliably connectable.") + step(2, "Put the pad next to your iPhone", "Within arm's reach (about 30 cm) for a strong, stable Bluetooth link.") + step(3, paired ? "Tap Connect" : "Tap Pair ring", paired ? "We'll authenticate with your saved key automatically." : "We'll generate a key and install it on the ring (it must be factory-reset).") + } + } + + private func step(_ n: Int, _ title: String, _ detail: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Text("\(n)") + .font(.subheadline.bold()).foregroundColor(.black) + .frame(width: 26, height: 26) + .background(Circle().fill(.pink)) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.subheadline.weight(.semibold)) + Text(detail).font(.footnote).foregroundColor(.secondary) + } + Spacer(minLength: 0) + } + } + + private var connectedBody: some View { + VStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)).foregroundStyle(.green) + if let s = ring.serial { Text(s).font(.footnote.monospaced()).foregroundColor(.secondary) } + if let b = ring.batteryPercent { Text("Battery \(b)%").font(.footnote).foregroundColor(.secondary) } + } + } + + private var footer: some View { + VStack(spacing: 10) { + // Live status line. + HStack(spacing: 8) { + if inProgress { ProgressView() } + Text(ring.status) + .font(.footnote) + .foregroundColor(failed ? .red : .secondary) + } + .frame(maxWidth: .infinity) + + if ring.state == .ready { + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent).tint(.green) + .frame(maxWidth: .infinity) + } else { + Button { + Task { paired ? await ring.authenticateAndLoad() : await ring.pairNewRing() } + } label: { + Text(buttonLabel).frame(maxWidth: .infinity).padding(.vertical, 4) + } + .buttonStyle(.borderedProminent).tint(.pink) + .disabled(inProgress) + + if !paired { + Text("Already paired this ring with the CLI? Import its key in Settings instead.") + .font(.caption2).foregroundColor(.secondary).multilineTextAlignment(.center) + } + } + } + } + + private var buttonLabel: String { + if inProgress { return paired ? "Connecting…" : "Pairing…" } + if failed { return "Try again" } + return paired ? "Connect" : "Pair ring" + } +} diff --git a/ios/OpenOura/Views/LiveView.swift b/ios/OpenOura/Views/LiveView.swift new file mode 100644 index 00000000..7139132f --- /dev/null +++ b/ios/OpenOura/Views/LiveView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct LiveView: View { + @EnvironmentObject var ring: OuraRing + + var body: some View { + TabScaffold(title: "Live") { + Card { + VStack(spacing: 14) { + ScoreRing(value: ring.liveHR.map(Double.init), range: 40...140, color: Brand.hr, + caption: ring.liveActive ? "LIVE" : "HEART RATE", unit: "bpm", + pulse: ring.liveActive) + .frame(width: 190, height: 190) + .padding(.top, 6) + if ring.liveActive && ring.liveHR == nil { + Text("Measuring… keep the ring snug and still") + .font(.caption).foregroundStyle(Brand.dim) + } + } + } + .padding(.horizontal, 16) + + HStack(spacing: 12) { + MetricTile(title: "HRV (RMSSD)", value: ring.liveHRV.map { "\($0)" } ?? "—", + unit: "ms", accent: Brand.hrv, spark: ring.hrvSeries.suffix(60)) + MetricTile(title: "Motion", value: ring.motionG.map { String(format: "%.2f", $0) } ?? "—", + unit: "g", accent: Brand.motion, spark: ring.motionSeries.suffix(80)) + } + .padding(.horizontal, 16) + + Card { + VStack(alignment: .leading, spacing: 8) { + Text("HEART RATE").font(.caption2.weight(.semibold)).foregroundStyle(Brand.hr) + Sparkline(values: ring.hrSeries, color: Brand.hr).frame(height: 90) + HStack { + Label("\(ring.restlessness ?? 0)% restless", systemImage: "figure.walk.motion") + Spacer() + if let g = ring.motionG { Text("|a| \(String(format: "%.2f", g)) g") } + }.font(.caption2).foregroundStyle(Brand.dim) + } + } + .padding(.horizontal, 16) + + Button(action: { ring.liveActive ? ring.stopLive() : ring.startLive() }) { + Label(ring.liveActive ? "Stop live" : "Start live", + systemImage: ring.liveActive ? "stop.fill" : "play.fill") + .frame(maxWidth: .infinity).padding(.vertical, 8) + } + .buttonStyle(.borderedProminent).tint(ring.liveActive ? Brand.dim : Brand.hr) + .disabled(ring.state != .ready) + .padding(.horizontal, 16) + + Text("Live mode forces the green-LED measurement and streams motion. Heart rate arrives in bursts every few seconds; wear the ring snugly.") + .font(.caption2).foregroundStyle(Brand.dim) + .multilineTextAlignment(.center).padding(.horizontal, 28) + } + } +} diff --git a/ios/OpenOura/Views/RingLogo.swift b/ios/OpenOura/Views/RingLogo.swift new file mode 100644 index 00000000..65096c58 --- /dev/null +++ b/ios/OpenOura/Views/RingLogo.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// The Open Oura mark — a gapped gradient ring — drawn as a scalable vector so it +/// matches the app icon. Used in headers and the connect guide. +struct RingLogo: View { + var lineWidth: CGFloat = 0.16 // as a fraction of the frame + var animated = false + @State private var spin = false + + private let grad = AngularGradient( + colors: [Color(hex: 0xf38ba8), Color(hex: 0xcba6f7), Color(hex: 0x94e2d5), Color(hex: 0xf38ba8)], + center: .center) + + var body: some View { + GeometryReader { geo in + let s = min(geo.size.width, geo.size.height) + ZStack { + Circle() + .trim(from: 0.06, to: 0.94) // the "open" gap + .stroke(grad, style: StrokeStyle(lineWidth: s * lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90 + 22)) + .rotationEffect(.degrees(animated && spin ? 360 : 0)) + .animation(animated ? .linear(duration: 6).repeatForever(autoreverses: false) : nil, value: spin) + } + .frame(width: s, height: s) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onAppear { if animated { spin = true } } + } +} + +extension Color { + init(hex: UInt32) { + self.init(.sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255, + opacity: 1) + } +} + +/// Brand palette (Catppuccin-ish, matching the dashboard + icon). +enum Brand { + static let bg = Color(hex: 0x0b0d12) + static let card = Color(hex: 0x12151d) + static let card2 = Color(hex: 0x171b25) + static let line = Color(hex: 0x262b38) + static let text = Color(hex: 0xcdd6f4) + static let dim = Color(hex: 0x8b91a5) + static let hr = Color(hex: 0xf38ba8) + static let hrv = Color(hex: 0xcba6f7) + static let motion = Color(hex: 0x89b4fa) + static let spo2 = Color(hex: 0x94e2d5) + static let temp = Color(hex: 0xfab387) + static let battery = Color(hex: 0xa6e3a1) + static let sleepDeep = Color(hex: 0x6c7086) + static let sleepLight = Color(hex: 0x89b4fa) + static let sleepRem = Color(hex: 0xcba6f7) + static let sleepAwake = Color(hex: 0xf38ba8) +} diff --git a/ios/OpenOura/Views/RootView.swift b/ios/OpenOura/Views/RootView.swift new file mode 100644 index 00000000..7c504e33 --- /dev/null +++ b/ios/OpenOura/Views/RootView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject var ring: OuraRing + + var body: some View { + TabView { + TodayView().tabItem { Label("Today", systemImage: "sun.max.fill") } + LiveView().tabItem { Label("Live", systemImage: "waveform.path.ecg") } + SleepView().tabItem { Label("Sleep", systemImage: "bed.double.fill") } + SettingsView().tabItem { Label("Settings", systemImage: "gearshape.fill") } + } + .tint(Brand.hr) + .sheet(isPresented: $ring.showConnectGuide) { + ConnectGuideView().presentationDetents([.large]) + } + } +} + +/// Connection state banner reused across tabs. Tap to (re)open the guide. +struct StatusBanner: View { + @EnvironmentObject var ring: OuraRing + var body: some View { + Button { ring.showConnectGuide = true } label: { + HStack(spacing: 9) { + Circle().fill(color).frame(width: 8, height: 8) + Text(ring.status).font(.footnote).foregroundStyle(Brand.dim) + Spacer() + if let b = ring.batteryPercent { + Image(systemName: ring.charging ? "battery.100.bolt" : batteryIcon(b)) + .foregroundStyle(ring.charging ? Brand.battery : Brand.dim) + Text("\(b)%").font(.footnote).foregroundStyle(Brand.dim) + } else if ring.state != .ready { + Text("Connect").font(.footnote.weight(.semibold)).foregroundStyle(Brand.hr) + Image(systemName: "chevron.right").font(.caption2).foregroundStyle(Brand.hr) + } + } + .padding(.horizontal, 14).padding(.vertical, 10) + .background(Brand.card2) + .clipShape(Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 18) + } + private var color: Color { + switch ring.state { + case .ready: return Brand.battery + case .failed: return Brand.hr + case .idle: return Brand.dim + default: return Brand.temp + } + } + private func batteryIcon(_ p: Int) -> String { + p > 75 ? "battery.100" : p > 40 ? "battery.50" : "battery.25" + } +} + +/// Standard scrollable tab scaffold: dark background, header, status banner. +struct TabScaffold: View { + let title: String + @ViewBuilder var content: Content + var body: some View { + ZStack { + Brand.bg.ignoresSafeArea() + ScrollView { + VStack(spacing: 14) { + AppHeader(title: title) + StatusBanner() + content + } + .padding(.bottom, 30) + } + } + } +} diff --git a/ios/OpenOura/Views/SettingsView.swift b/ios/OpenOura/Views/SettingsView.swift new file mode 100644 index 00000000..39719754 --- /dev/null +++ b/ios/OpenOura/Views/SettingsView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var ring: OuraRing + @State private var keyHex: String = KeyStore.loadHex() ?? "" + @State private var saveMsg: String = "" + @State private var showResetConfirm = false + + private var inProgress: Bool { + [ConnState.scanning, .connecting, .authenticating].contains(ring.state) + } + + var body: some View { + NavigationStack { + Form { + Section { + HStack(spacing: 12) { + RingLogo().frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 1) { + Text("Open Oura").font(.headline) + Text(ring.status).font(.caption).foregroundStyle(Brand.dim) + } + } + } + .listRowBackground(Color.clear) + + Section("Connection") { + Button { + ring.showConnectGuide = true + } label: { Label("Connection guide", systemImage: "wave.3.right.circle") } + + if ring.state == .ready { + Button("Disconnect", role: .destructive) { ring.disconnect() } + } else if KeyStore.keyBytes() != nil { + Button(inProgress ? "Connecting…" : "Connect") { Task { await ring.authenticateAndLoad() } } + .disabled(inProgress) + } else { + Button(inProgress ? "Pairing…" : "Pair ring") { Task { await ring.pairNewRing() } } + .disabled(inProgress) + } + } + + Section("Automatic") { + Toggle("Auto-reconnect", isOn: $ring.autoConnect) + Toggle("Auto-sync on connect", isOn: $ring.autoSync) + Text("Reconnects whenever the ring wakes (charger or worn) and pulls new history — no manual connecting.") + .font(.footnote).foregroundStyle(Brand.dim) + } + + Section("Device") { + LabeledContent("Serial", value: ring.serial ?? "—") + LabeledContent("Hardware", value: ring.hardware ?? "—") + LabeledContent("Firmware", value: ring.firmware ?? "—") + LabeledContent("Battery", value: ring.batteryPercent.map { "\($0)%" } ?? "—") + if let d = ring.lastSync { LabeledContent("Last sync", value: relativeTime(d)) } + } + + Section("Advanced — import existing key") { + Text("Already paired this ring elsewhere (e.g. the CLI's `oura pair`)? Paste that 16-byte key (32 hex chars).") + .font(.footnote).foregroundStyle(Brand.dim) + TextField("e.g. 4431967d8bacc2659743142b68391d9a", text: $keyHex) + .font(.system(.footnote, design: .monospaced)) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Button("Import key") { + if KeyStore.dataFromHex(keyHex) != nil, KeyStore.saveHex(keyHex) { + saveMsg = "Saved ✓ — tap Connect" + } else { saveMsg = "Invalid — need exactly 32 hex chars" } + } + if !saveMsg.isEmpty { Text(saveMsg).font(.footnote).foregroundStyle(Brand.dim) } + } + + Section("Danger zone") { + Button(role: .destructive) { + showResetConfirm = true + } label: { Label("Factory-reset ring", systemImage: "exclamationmark.triangle.fill") } + .disabled(ring.state != .ready) + Text("Wipes the ring's auth key and on-device data so you can pair fresh. You'll set a new key by pairing again.") + .font(.footnote).foregroundStyle(Brand.dim) + } + + Section { + Text("A cloud-free client that reads your Oura ring directly over Bluetooth — heart rate, HRV, motion, temperature, and on-device sleep stages, with no Oura account.") + .font(.footnote).foregroundStyle(Brand.dim) + } header: { Text("About") } + } + .scrollContentBackground(.hidden) + .background(Brand.bg.ignoresSafeArea()) + .navigationTitle("Settings") + .confirmationDialog("Factory-reset the ring?", isPresented: $showResetConfirm, titleVisibility: .visible) { + Button("Factory reset", role: .destructive) { Task { await ring.factoryReset() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This wipes the ring's auth key and data. The ring will need to be paired again.") + } + .onChange(of: ring.state) { _ in keyHex = KeyStore.loadHex() ?? "" } + } + } +} diff --git a/ios/OpenOura/Views/SleepView.swift b/ios/OpenOura/Views/SleepView.swift new file mode 100644 index 00000000..3de988fc --- /dev/null +++ b/ios/OpenOura/Views/SleepView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct SleepView: View { + @EnvironmentObject var ring: OuraRing + + private func stageColor(_ s: String) -> Color { + switch s { + case "deep": return Brand.sleepDeep + case "light": return Brand.sleepLight + case "rem": return Brand.sleepRem + default: return Brand.sleepAwake + } + } + + var body: some View { + TabScaffold(title: "Sleep") { + if ring.health.hypnogram.isEmpty { + Card { + VStack(alignment: .leading, spacing: 8) { + Label("No sleep stages yet", systemImage: "moon.zzz.fill").font(.headline) + Text("Wear the ring overnight, then Sync. Sleep stages are computed on the ring and arrive as history events — no Oura cloud needed.") + .font(.footnote).foregroundStyle(Brand.dim) + } + } + .padding(.horizontal, 16) + } else { + Card { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("HYPNOGRAM").font(.caption2.weight(.semibold)).foregroundStyle(Brand.sleepRem) + Spacer() + Text("\(ring.health.hypnogram.count) epochs").font(.caption2).foregroundStyle(Brand.dim) + } + HypnogramView(stages: ring.health.hypnogram, color: stageColor) + .frame(height: 130) + HStack(spacing: 14) { + ForEach(["deep", "rem", "light", "awake"], id: \.self) { s in + HStack(spacing: 4) { + Circle().fill(stageColor(s)).frame(width: 8, height: 8) + Text(s.capitalized).font(.caption2).foregroundStyle(Brand.dim) + } + } + } + } + } + .padding(.horizontal, 16) + + Card { + VStack(alignment: .leading, spacing: 12) { + Text("STAGE DISTRIBUTION").font(.caption2.weight(.semibold)).foregroundStyle(Brand.sleepRem) + ForEach(ring.health.stageCounts, id: \.stage) { item in + let pct = Double(item.count) / Double(max(ring.health.hypnogram.count, 1)) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.stage.capitalized).font(.footnote) + Spacer() + Text("\(Int(pct * 100))%").font(.footnote.weight(.semibold)).foregroundStyle(Brand.dim) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Brand.line).frame(height: 8) + Capsule().fill(stageColor(item.stage)).frame(width: geo.size.width * pct, height: 8) + } + }.frame(height: 8) + } + } + } + } + .padding(.horizontal, 16) + } + + Button(action: { Task { await ring.syncHistory() } }) { + HStack { + if ring.syncing { ProgressView().tint(.white) } + Text(ring.syncing ? "Syncing…" : "Sync now") + }.frame(maxWidth: .infinity).padding(.vertical, 8) + } + .buttonStyle(.borderedProminent).tint(Brand.sleepRem) + .disabled(ring.state != .ready || ring.syncing) + .padding(.horizontal, 16) + } + } +} + +/// Stepped hypnogram (deep low → awake high). +struct HypnogramView: View { + let stages: [String] + let color: (String) -> Color + private let level: [String: Int] = ["deep": 0, "light": 1, "rem": 2, "awake": 3] + + var body: some View { + GeometryReader { geo in + let n = max(stages.count, 1) + let w = geo.size.width / CGFloat(n) + let h = geo.size.height + ForEach(Array(stages.enumerated()), id: \.offset) { i, s in + let lvl = level[s] ?? 1 + let barH = h * CGFloat(lvl + 1) / 4.0 + Rectangle() + .fill(color(s)) + .frame(width: max(w, 0.6), height: barH) + .position(x: w * (CGFloat(i) + 0.5), y: h - barH / 2) + } + } + } +} diff --git a/ios/OpenOura/Views/TodayView.swift b/ios/OpenOura/Views/TodayView.swift new file mode 100644 index 00000000..d12970a5 --- /dev/null +++ b/ios/OpenOura/Views/TodayView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct TodayView: View { + @EnvironmentObject var ring: OuraRing + + private var hr: Int? { ring.liveHR ?? ring.health.latestHR } + + var body: some View { + TabScaffold(title: "Today") { + // Hero gauge — latest heart rate (live if measuring, else last synced). + Card { + HStack(spacing: 18) { + ScoreRing(value: hr.map(Double.init), range: 40...110, color: Brand.hr, + caption: ring.liveActive ? "LIVE HR" : "HEART RATE", unit: "bpm", + pulse: ring.liveActive) + .frame(width: 150, height: 150) + VStack(alignment: .leading, spacing: 12) { + miniStat("HRV", ring.health.latestHRV.map { "\($0)" } ?? "—", "ms", Brand.hrv) + miniStat("Temp", ring.health.latestTemp.map { String(format: "%.1f", $0) } ?? "—", "°C", Brand.temp) + miniStat("Battery", ring.batteryPercent.map { "\($0)" } ?? "—", "%", Brand.battery) + } + Spacer(minLength: 0) + } + } + .padding(.horizontal, 16) + + // Trend tiles. + HStack(spacing: 12) { + MetricTile(title: "Heart rate", value: ring.health.latestHR.map { "\($0)" } ?? "—", + unit: "bpm", accent: Brand.hr, spark: ring.health.hr.suffix(60).map(\.value)) + MetricTile(title: "HRV (RMSSD)", value: ring.health.latestHRV.map { "\($0)" } ?? "—", + unit: "ms", accent: Brand.hrv, spark: ring.health.hrv.suffix(60).map(\.value)) + } + .padding(.horizontal, 16) + + HStack(spacing: 12) { + MetricTile(title: "Skin temp", value: ring.health.latestTemp.map { String(format: "%.2f", $0) } ?? "—", + unit: "°C", accent: Brand.temp, spark: ring.health.temp.suffix(60).map(\.value)) + MetricTile(title: "Blood oxygen", value: ring.health.spo2.last.map { "\(Int($0.value))" } ?? "—", + unit: "%", accent: Brand.spo2, spark: ring.health.spo2.suffix(60).map(\.value)) + } + .padding(.horizontal, 16) + + // Device + sync. + Card { + VStack(spacing: 10) { + infoRow("Device", ring.serial ?? "—") + Divider().overlay(Brand.line) + infoRow("Hardware", ring.hardware ?? "—") + Divider().overlay(Brand.line) + infoRow("Firmware", ring.firmware ?? "—") + Divider().overlay(Brand.line) + infoRow("Synced events", "\(ring.health.totalEvents)") + } + } + .padding(.horizontal, 16) + + Button(action: { Task { await ring.syncHistory() } }) { + HStack { + if ring.syncing { ProgressView().tint(.white) } + Text(ring.syncing ? "Syncing…" : "Sync now") + }.frame(maxWidth: .infinity).padding(.vertical, 8) + } + .buttonStyle(.borderedProminent).tint(Brand.hr) + .disabled(ring.state != .ready || ring.syncing) + .padding(.horizontal, 16) + } + } + + private func miniStat(_ k: String, _ v: String, _ u: String, _ c: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(k.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(c) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(v).font(.system(size: 20, weight: .bold, design: .rounded)) + Text(u).font(.caption2).foregroundStyle(Brand.dim) + } + } + } + private func infoRow(_ k: String, _ v: String) -> some View { + HStack { Text(k).foregroundStyle(Brand.dim); Spacer(); Text(v) }.font(.footnote) + } +} diff --git a/ios/OuraFFI/include/module.modulemap b/ios/OuraFFI/include/module.modulemap new file mode 100644 index 00000000..cb53f36c --- /dev/null +++ b/ios/OuraFFI/include/module.modulemap @@ -0,0 +1,4 @@ +module OuraFFI { + header "ouraffi.h" + export * +} diff --git a/ios/OuraFFI/include/ouraffi.h b/ios/OuraFFI/include/ouraffi.h new file mode 100644 index 00000000..39de76ae --- /dev/null +++ b/ios/OuraFFI/include/ouraffi.h @@ -0,0 +1,22 @@ +#ifndef OURA_FFI_H +#define OURA_FFI_H + +#include +#include + +/* Encrypt a ring auth nonce (AES-128/ECB/PKCS7) into `out` (16 bytes). + * `key` must be 16 bytes; `nonce` typically 15. Returns 0 on success. */ +int32_t oura_encrypt_nonce(const uint8_t *key, size_t key_len, + const uint8_t *nonce, size_t nonce_len, + uint8_t *out); + +/* Decode an event body to a JSON string (or NULL). Free with oura_string_free. */ +char *oura_decode_event(uint8_t tag, const uint8_t *body, size_t body_len); + +/* Event name for a tag (owned string). Free with oura_string_free. */ +char *oura_event_name(uint8_t tag); + +/* Release a string returned by this library. */ +void oura_string_free(char *ptr); + +#endif /* OURA_FFI_H */ diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000..e926696d --- /dev/null +++ b/ios/README.md @@ -0,0 +1,79 @@ +# Open Oura — native iOS app + +A native SwiftUI app that talks to the Oura ring directly over Bluetooth (no Oura +cloud), reusing the project's Rust protocol core. Tabs: **Today** (resting HR / HRV +/ temp / battery + history), **Live** (real-time HR / HRV / motion), **Sleep** +(on-device hypnogram + stage distribution), **Settings** (key + connection). + +## Architecture + +``` +SwiftUI views ──► OuraRing (CoreBluetooth + protocol orchestration, Swift) + │ request/response, auth, live drain (OuraProtocol.swift) + ▼ + OuraFFI.xcframework ──► crates/oura-ffi (Rust staticlib) + (C ABI: AES auth + event decoders) reuses oura-protocol +``` + +The genuinely hard, byte-level parts — AES-128/ECB/PKCS7 auth and the event-body +decoders — are the tested Rust from `crates/oura-protocol`, exposed over a tiny C +ABI (`crates/oura-ffi`). BLE transport, packet framing, request builders, and the +connect/sync/live orchestration are native Swift. Nothing async crosses the FFI +boundary. + +## Build the Rust core (run after any Rust change) + +```bash +./ios/build-rust.sh # builds OuraFFI.xcframework (device + simulator) +``` + +## Generate & open the Xcode project + +```bash +brew install xcodegen # one-time +cd ios && xcodegen generate +open OpenOura.xcodeproj +``` + +Set `DEVELOPMENT_TEAM` in `project.yml` to your Apple Developer Team ID (Xcode → +Settings → Accounts → your team). A free "Personal Team" works for development; a +paid team avoids the 7-day app expiry. Find your id with: +`security find-identity -v -p codesigning` or in Xcode's Signing settings. + +## Run on a device + +The iOS Simulator has **no Bluetooth**, so the ring features only work on a real +device. Build/install from the CLI: + +```bash +xcodebuild -project ios/OpenOura.xcodeproj -scheme OpenOura \ + -destination 'id=' -allowProvisioningUpdates \ + build +``` + +…or just press ⌘R in Xcode with your iPhone selected. + +## First use + +The app shows a **connection guide** on launch (`ConnectGuideView`) — reopen it any +time by tapping the status banner or Settings → "Connection guide". + +1. Pair once (either path): + - **In-app:** factory-reset the ring, then **Pair ring** (generates + installs a + key, stores it in the Keychain) — the real-app flow. + - **Import:** if already paired via the CLI (`oura pair`), Settings → "Import + existing key", paste `cat key.hex`. +2. **Put the ring on its charging pad, next to the phone**, then **Connect**. +3. Use **Live** (wear the ring) / **Sync history**. + +### Connecting gotchas (learned the hard way) + +- **The ring must be awake to connect.** Off the charger and not worn, it drops into + deep-sleep advertising, so iOS sees a stray advert ("discovered") but `connect()` + hangs with no `didConnect`. **On the charging pad (or worn) it's actively + connectable.** This is the #1 cause of "connect doesn't work". +- **One central at a time.** If this Mac has the ring bonded, macOS auto-reconnects + it the moment it advertises, stealing the slot from the phone. For phone testing, + turn the Mac's Bluetooth off (`blueutil -p 0`) or "Forget" the ring on the Mac. + Re-enable with `blueutil -p 1` to use the CLI again. +- Quit the CLI `live`/`viz` before connecting from the phone, and vice-versa. diff --git a/ios/build-rust.sh b/ios/build-rust.sh new file mode 100755 index 00000000..83ebc2ee --- /dev/null +++ b/ios/build-rust.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build the Rust core (oura-ffi) for iOS device + simulator and package it as an +# XCFramework that the Xcode project links. Re-run after changing any Rust code. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DEVICE_TARGET="aarch64-apple-ios" +SIM_TARGET="aarch64-apple-ios-sim" # Apple-Silicon simulator +LIB="libouraffi.a" +HEADERS="$ROOT/ios/OuraFFI/include" +OUT="$ROOT/ios/OuraFFI/OuraFFI.xcframework" + +echo "▸ building Rust core for $DEVICE_TARGET and $SIM_TARGET ..." +cargo build --release -p oura-ffi --target "$DEVICE_TARGET" +cargo build --release -p oura-ffi --target "$SIM_TARGET" + +rm -rf "$OUT" +echo "▸ creating XCFramework ..." +xcodebuild -create-xcframework \ + -library "$ROOT/target/$DEVICE_TARGET/release/$LIB" -headers "$HEADERS" \ + -library "$ROOT/target/$SIM_TARGET/release/$LIB" -headers "$HEADERS" \ + -output "$OUT" + +echo "✓ $OUT" diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 00000000..1b5bbead --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,33 @@ +name: OpenOura +options: + bundleIdPrefix: com.openoura + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true + +settings: + base: + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.0" + DEVELOPMENT_TEAM: "" # set to your Apple Developer Team ID (a free Personal Team works) + CODE_SIGN_STYLE: Automatic + +targets: + OpenOura: + type: application + platform: iOS + sources: + - path: OpenOura + dependencies: + - framework: OuraFFI/OuraFFI.xcframework + embed: false # static library — linked, not embedded + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.openoura.app + INFOPLIST_FILE: OpenOura/Info.plist + GENERATE_INFOPLIST_FILE: NO + TARGETED_DEVICE_FAMILY: "1" + ENABLE_USER_SCRIPT_SANDBOXING: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + OTHER_LDFLAGS: ["-lc++"] diff --git a/ios/tools/render-icon.swift b/ios/tools/render-icon.swift new file mode 100644 index 00000000..ecfa0e37 --- /dev/null +++ b/ios/tools/render-icon.swift @@ -0,0 +1,53 @@ +// Renders the Open Oura app icon (gapped gradient ring + heartbeat tick) to a PNG. +// Run: swift ios/tools/render-icon.swift (macOS / CoreGraphics) +import AppKit +import CoreGraphics + +let size = 1024.0 +let cs = CGColorSpaceCreateDeviceRGB() +let ctx = CGContext(data: nil, width: Int(size), height: Int(size), bitsPerComponent: 8, + bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + +// Background: radial dark gradient. +let bg = CGGradient(colorsSpace: cs, + colors: [CGColor(red: 0.106, green: 0.122, blue: 0.169, alpha: 1), + CGColor(red: 0.043, green: 0.051, blue: 0.071, alpha: 1)] as CFArray, + locations: [0, 1])! +ctx.drawRadialGradient(bg, startCenter: CGPoint(x: size/2, y: size*0.58), startRadius: 0, + endCenter: CGPoint(x: size/2, y: size*0.58), endRadius: size*0.85, + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) + +// Gapped ring (gap at top). CG is y-up: top = +90°. Sweep the long way leaving a gap. +let center = CGPoint(x: size/2, y: size/2) +let radius = 300.0, lineWidth = 118.0 +let gap = 46.0 * .pi / 180.0 +let arc = CGMutablePath() +arc.addArc(center: center, radius: radius, + startAngle: .pi/2 + gap/2, endAngle: .pi/2 - gap/2 + 2 * .pi, clockwise: false) +let outline = arc.copy(strokingWithWidth: lineWidth, lineCap: .round, lineJoin: .round, miterLimit: 10) +ctx.saveGState() +ctx.addPath(outline); ctx.clip() +let ring = CGGradient(colorsSpace: cs, + colors: [CGColor(red: 0.953, green: 0.545, blue: 0.659, alpha: 1), + CGColor(red: 0.796, green: 0.651, blue: 0.969, alpha: 1), + CGColor(red: 0.580, green: 0.886, blue: 0.835, alpha: 1)] as CFArray, + locations: [0, 0.5, 1])! +ctx.drawLinearGradient(ring, start: CGPoint(x: size*0.18, y: size*0.85), + end: CGPoint(x: size*0.82, y: size*0.15), options: []) +ctx.restoreGState() + +// Heartbeat tick across the lower ring (drawn in bg colour to "cut" through). +ctx.setStrokeColor(CGColor(red: 0.043, green: 0.051, blue: 0.071, alpha: 1)) +ctx.setLineWidth(26); ctx.setLineCap(.round); ctx.setLineJoin(.round) +let pts = [(392.0, 424.0), (458, 424), (484, 476), (518, 372), (544, 476), (610, 476), (632, 424)] +ctx.beginPath() +ctx.move(to: CGPoint(x: pts[0].0, y: pts[0].1)) +for p in pts.dropFirst() { ctx.addLine(to: CGPoint(x: p.0, y: p.1)) } +ctx.strokePath() + +let img = ctx.makeImage()! +let rep = NSBitmapImageRep(cgImage: img) +let png = rep.representation(using: .png, properties: [:])! +let out = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "icon-1024.png" +try! png.write(to: URL(fileURLWithPath: out)) +print("wrote \(out)")