From 9d3b133592781a87323a14d1b5968ca9bc50c6a0 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Tue, 21 Jul 2026 06:09:15 -0700 Subject: [PATCH 1/6] Add Colmi R11 CRP ("Da Rings") ring driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the CRP ("crrepa"/CRPsmart) `fdda`-profile ring family from the Android app (PR #36, foureight84/PulseLoopAndroid) to iOS. This is the second firmware sold as "R11 / SMART_RING": it speaks a proprietary `fdda` profile — not the Colmi/QRing Nordic-UART one `ColmiDriver` speaks — so a CRP ring driven by the Colmi/jring driver finds none of its characteristics and hangs the connect (issue #29). Official app is Moyoung "Da Rings" (com.moyoung.ring); framing/ command layouts are faithful to `decompiled-moyoung-official/`. New driver family (CRPProtocol/CRPDecoder/CRPDriver/CRPCoordinator/CRPSyncEngine): - `FD DA 10 ` framing, 9th length bit on byte[2]. - fdd1 current-steps push, 2a37 HR stream (0x0400-marker gated), fdd3 framed replies reassembled across notifications; battery via standard 180f/2a19. - Connect handshake sets clock (vendor GMT+8 quirk) + user info; live/manual HR, find-device. Sleep/SpO2/HRV/stress/temperature/history deferred until their reply layouts are confirmed against hardware, so the UI hides them. Wiring: new `.crp` RingDeviceType (+ displayName, .limited support level), `colmiR11CRP` catalog card ("Colmi R11 (Da Rings app)", reusing the yawell-r11 art), CRPCoordinator registered. Reverse-port adaptation: Android re-routes the driver post-connect once the `fdda` service is discovered. iOS has no post-connect driver swap and instead resolves ambiguous SMART_RING/Colmi firmware by the user's carousel pick at pairing (exactly as it separates QRing vs SmartHealth Colmi), so the CRP driver is reached by picking the "Colmi R11 (Da Rings app)" card (preferredFamily = .crp), not by an auto-reroute. Tests: CRPProtocol/Decoder/SyncEngine oracles ported from the Android unit tests; PairingMatchingTests gains CRP coverage. Full suite green (71 tests). --- PulseLoop/RingProtocol/CRPCoordinator.swift | 48 +++++ PulseLoop/RingProtocol/CRPDecoder.swift | 95 ++++++++++ PulseLoop/RingProtocol/CRPDriver.swift | 57 ++++++ PulseLoop/RingProtocol/CRPProtocol.swift | 168 ++++++++++++++++++ PulseLoop/RingProtocol/CRPSyncEngine.swift | 84 +++++++++ PulseLoop/RingProtocol/RingBLEClient.swift | 5 + PulseLoop/Views/Settings/DeviceHeroCard.swift | 3 + PulseLoop/Wearables/WearableCoordinator.swift | 7 + PulseLoop/Wearables/WearableModel.swift | 18 +- PulseLoopTests/CRPDecoderTests.swift | 90 ++++++++++ PulseLoopTests/CRPProtocolTests.swift | 68 +++++++ PulseLoopTests/CRPSyncEngineTests.swift | 56 ++++++ PulseLoopTests/PairingMatchingTests.swift | 55 +++++- 13 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 PulseLoop/RingProtocol/CRPCoordinator.swift create mode 100644 PulseLoop/RingProtocol/CRPDecoder.swift create mode 100644 PulseLoop/RingProtocol/CRPDriver.swift create mode 100644 PulseLoop/RingProtocol/CRPProtocol.swift create mode 100644 PulseLoop/RingProtocol/CRPSyncEngine.swift create mode 100644 PulseLoopTests/CRPDecoderTests.swift create mode 100644 PulseLoopTests/CRPProtocolTests.swift create mode 100644 PulseLoopTests/CRPSyncEngineTests.swift diff --git a/PulseLoop/RingProtocol/CRPCoordinator.swift b/PulseLoop/RingProtocol/CRPCoordinator.swift new file mode 100644 index 0000000..b3c5552 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -0,0 +1,48 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for the CRP ("crrepa"/CRPsmart) `fdda`-profile family — official app Moyoung +/// "Da Rings" (`com.moyoung.ring`). Declares what `CRPDriver` can decode and how the ring is +/// recognised. See `CRPProtocol` and `decompiled-moyoung-official/`. +/// +/// **Recognition / reachability.** The family's authoritative signal is the advertised `fdda` +/// service, matched below for completeness. In practice the CRP Colmi R11 advertises the generic name +/// `SMART_RING` with **no** service UUID pre-connect, so nothing matches it at scan and it falls back +/// to jring. The Android app re-routes to this driver once discovery reveals `fdda` post-connect; +/// iOS has no such post-connect driver swap, and instead — exactly as it separates the QRing vs +/// SmartHealth Colmi firmwares — relies on the user picking the "Colmi R11 (Da Rings app)" card +/// (`WearableModel.colmiR11CRP`), which routes `preferredFamily = .crp` to this coordinator up front. +/// +/// **Bonding.** Unlike the Colmi-UART R11, the CRP ring connects GATT-only — the vendor app performs +/// no OS bond in its connect path (bonding there is a separate opt-in HID/camera feature). iOS's +/// CoreBluetooth has no explicit bond step in the connect path anyway, so there is nothing to gate. +@MainActor +final class CRPCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .crp + + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + // Only the family-exclusive `fdda` service claims a CRP ring at scan. The CRP R11 doesn't + // advertise it, so this is effectively never hit pre-connect — the user's carousel pick is + // the real entry point (see the class doc). Kept so a ring that *does* advertise `fdda` lands + // here rather than on the jring fallback. + advertisement.serviceUUIDs.contains(CRPUUIDs.serviceCBUUID) + } + + /// v1 baseline — only capabilities backed by a decode path confirmed from the decompile: + /// current-steps push (`fdd1`), the standard HR stream (`2a37`) with its start/stop command, its + /// spot reading, the standard battery read, and find-device. Sleep / SpO2 / HRV / stress / + /// temperature and history sync are deferred until their CRP reply layouts are confirmed against + /// hardware — deliberately not promised here so the product UI hides them. + let capabilities: Set = [ + .steps, .realtimeSteps, + .heartRate, .realtimeHeartRate, .manualHeartRate, + .battery, + .findDevice, + ] + + let iconSystemName = "circle.circle.fill" + + func makeDriver(writer: RingCommandWriter) -> WearableDriver { CRPDriver(writer: writer) } +} diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift new file mode 100644 index 0000000..f815828 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -0,0 +1,95 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Reassembles CRP command replies (`fdd3`) that span multiple BLE notifications. A logical frame +/// starts with `FD DA …` and its declared total length (`CRPProtocol.frameLength`) tells us when it +/// is complete. Mirrors the vendor's `g1/a.k()`. One assembler instance per connection — a fresh +/// `CRPDriver` is built on every connect, so state always starts clean. +final class CRPFrameAssembler { + private var buffer: [UInt8] = [] + private var expected = 0 + + /// Feed one notification chunk. Returns the complete frame when the last chunk lands, else nil. + func append(_ chunk: Data) -> Data? { + if chunk.isEmpty { return nil } + if CRPProtocol.isFrameStart(chunk) { + expected = CRPProtocol.frameLength(chunk) + buffer = [] + } + // A continuation chunk with no in-progress frame is noise — drop it. + if expected <= 0 { return nil } + buffer.append(contentsOf: chunk) + if buffer.count >= expected { + let frame = buffer.count == expected ? buffer : Array(buffer.prefix(expected)) + buffer = [] + expected = 0 + return Data(frame) + } + return nil + } +} + +/// Decodes CRP notifications into `RingDecodedEvent`s. Routing is by source characteristic (the +/// `from` UUID `CRPDriver.ingest` passes through), matching the vendor's `g1/a.a(characteristic)` +/// dispatch: +/// - `fdd1` → raw current-steps triples (no CRP header) +/// - `2a37` → standard HR-measurement stream +/// - `fdd3` → framed `FD DA …` command replies (already reassembled by `CRPFrameAssembler`) +/// +/// Unverified-against-hardware layouts are decoded conservatively: anything whose byte layout isn't +/// confirmed from the decompile is emitted as `.commandAck` rather than fabricating a metric value. +/// Extend `decodeFramedReply` as more command replies are confirmed. +enum CRPDecoder { + + static func decode(_ data: Data, from characteristic: CBUUID, now: Date = Date()) -> [RingDecodedEvent] { + switch characteristic { + case CRPUUIDs.stepsNotifyCBUUID: + return decodeCurrentSteps(data, now: now) + case CRPUUIDs.heartRateMeasureCBUUID: + return decodeHeartRateMeasure(data, now: now) + default: + return CRPProtocol.isFrameStart(data) ? decodeFramedReply(data, now: now) : [] + } + } + + /// `fdd1` push — little-endian 3-byte triples: [steps][distance][calories]. From `e1/k.b`. + /// distance is metres, calories kcal (vendor units). + private static func decodeCurrentSteps(_ data: Data, now: Date) -> [RingDecodedEvent] { + let b = [UInt8](data) + if b.isEmpty || b.count % 3 != 0 { return [] } + let steps = le3(b, 0) + let distance = b.count >= 6 ? le3(b, 3) : 0 + let calories = b.count >= 9 ? le3(b, 6) : 0 + return [.activityUpdate(timestamp: now, steps: steps, + distanceMeters: Double(distance), calories: Double(calories))] + } + + /// Standard HR characteristic (`2a37`). From `g1/a.B`: bpm at byte[1], validated by the `0x0400` + /// marker at bytes[2..3] (little-endian: byte[3] high). + private static func decodeHeartRateMeasure(_ data: Data, now: Date) -> [RingDecodedEvent] { + let b = [UInt8](data) + if b.count < 2 { return [] } + let bpm = Int(b[1]) + let markerOk = b.count < 4 || ((Int(b[3]) << 8) | Int(b[2])) == 0x0400 + if !markerOk || bpm <= 0 { return [] } + return [.heartRateSample(bpm: bpm, timestamp: now)] + } + + /// Framed `fdd3` reply: `FD DA 10 `. v1 acknowledges recognised + /// command echoes; richer metric replies (HR/SpO2 results, history) are decoded as more layouts + /// are confirmed against the decompile/hardware. + private static func decodeFramedReply(_ frame: Data, now: Date) -> [RingDecodedEvent] { + let b = [UInt8](frame) + if b.count < CRPProtocol.headerSize { return [] } + let group = Int(b[4]) + let cmd = Int(b[5]) + // Only the command echo is confirmed for the v1 command set; treat as an ack so the + // raw-notify/debug feed still records it without inventing a metric value. + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (group << 4) | (cmd & 0x0F)))] + } + + /// Little-endian unsigned 3-byte int at `offset`. + private static func le3(_ b: [UInt8], _ offset: Int) -> Int { + Int(b[offset]) | (Int(b[offset + 1]) << 8) | (Int(b[offset + 2]) << 16) + } +} diff --git a/PulseLoop/RingProtocol/CRPDriver.swift b/PulseLoop/RingProtocol/CRPDriver.swift new file mode 100644 index 0000000..5bb0621 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPDriver.swift @@ -0,0 +1,57 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// CRP ("crrepa"/CRPsmart) driver — the `fdda`-profile family behind the Moyoung "Da Rings" app, +/// the official app for the CRP-firmware Colmi R11 (see `CRPProtocol` and `decompiled-moyoung-official/`). +/// +/// **BLE topology.** Proprietary service `fdda`; write to `fdd2`; notify on `fdd1` (current-steps +/// push), `fdd3` (framed command replies) and `fdd6` (recording/OTA, ignored in v1). Heart rate +/// rides the standard `180d`/`2a37` characteristic and battery the standard `180f`/`2a19` — both +/// declared so `RingBLEClient` binds them. +/// +/// **Framing is identity.** `CRPProtocol` and `CRPSyncEngine` emit fully-framed `FD DA …` packets +/// (all v1 commands fit one ≤20-byte packet, so no chunking is needed), so `frame(_:)` returns its input. +/// +/// **Inbound.** `fdd3` replies may span several notifications and are reassembled by +/// `CRPFrameAssembler`; `fdd1`/`2a37` pushes are self-contained. A fresh driver is built per connect +/// (`RingBLEClient.installDriver` calls `coordinator.makeDriver` every time), so the assembler starts +/// clean without an explicit reset hook (matches `JringDriver`/`LuckRingDriver`). +@MainActor +final class CRPDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let assembler = CRPFrameAssembler() + + init(writer: RingCommandWriter?) { + self.writer = writer + } + + // MARK: BLE topology + let serviceUUIDs: [CBUUID] = [CRPUUIDs.serviceCBUUID, CRPUUIDs.heartRateServiceCBUUID] + let writeUUID = CRPUUIDs.writeCBUUID + let notifyUUIDs: [CBUUID] = [ + CRPUUIDs.stepsNotifyCBUUID, + CRPUUIDs.cmdNotifyCBUUID, + CRPUUIDs.recordingNotifyCBUUID, + CRPUUIDs.heartRateMeasureCBUUID, + ] + let batteryServiceUUID: CBUUID? = CRPUUIDs.batteryServiceCBUUID + let batteryCharUUID: CBUUID? = CRPUUIDs.batteryLevelCBUUID + + // MARK: Framing — the protocol/engine already build full CRP frames. + func frame(_ command: Data) -> Data { command } + + // MARK: Inbound decode + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + // Framed command replies (fdd3) reassemble across notifications; everything else is a + // self-contained push routed by source characteristic inside CRPDecoder. + if characteristic == CRPUUIDs.cmdNotifyCBUUID { + guard let frame = assembler.append(data) else { return [] } + return CRPDecoder.decode(frame, from: characteristic) + } + return CRPDecoder.decode(data, from: characteristic) + } + + func makeSyncEngine() -> RingSyncEngine { CRPSyncEngine(writer: writer) } +} diff --git a/PulseLoop/RingProtocol/CRPProtocol.swift b/PulseLoop/RingProtocol/CRPProtocol.swift new file mode 100644 index 0000000..2b34ff9 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -0,0 +1,168 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// CRP ("crrepa" / CRPsmart) ring protocol — the family behind the Moyoung "Da Rings" app +/// (`com.moyoung.ring`), which is the OFFICIAL app for the CRP-firmware Colmi R11 and its siblings. +/// See `decompiled-moyoung-official/` at the repo root; this file is a faithful port of that app's +/// on-the-wire behaviour (per AGENTS.md "match the vendor app"), carried over from the Android app's +/// `CRPProtocol.kt`. +/// +/// Why this family exists separately from `ColmiCoordinator`: the "R11 / SMART_RING" name is sold +/// under (at least) two different firmware stacks. One exposes the Colmi/QRing Nordic-UART profile +/// (`6e40fff0`/`de5bf728`) that `ColmiDriver` speaks; the other — this one — exposes a proprietary +/// `fdda` profile and speaks the CRP framing below. A CRP ring driven by the Colmi/jring driver +/// finds none of its characteristics and hangs the connect forever (issue #29, zaggash's ring). +/// +/// **iOS reachability.** Unlike the Android port — whose BLE stack re-routes a driver post-connect +/// once the `fdda` service is discovered — iOS resolves an ambiguous `SMART_RING`/Colmi firmware by +/// the user's carousel pick at pairing (`preferredFamily`), exactly as it separates the QRing vs +/// SmartHealth Colmi firmwares. So the CRP driver is reached by explicitly picking the +/// "Colmi R11 (Da Rings app)" card (`WearableModel.colmiR11CRP`, family `.crp`), not by a +/// post-connect swap iOS's `RingBLEClient` has no mechanism for. +/// +/// ## GATT topology (decompiled `k1/a.java`, `BleWriteCharacteristicProxy.getWriteCharacteristic`) +/// Service `fdda` with characteristics `fdd1`..`fdd6`: +/// - **write** → `fdd2` (default for all normal commands; `fdd5`/`fdd6` are OTA/recording only) +/// - **notify** → `fdd1` (current-steps push), `fdd3` (framed command replies), `fdd6` (recording) +/// Plus the standard services: `180f`/`2a19` battery, `180d`/`2a37` heart-rate, `180a` device info. +/// +/// ## Frame format (decompiled `b1/q.java`) +/// `FD DA 10 ` where `len = payload.count + 6` (header included). +/// Responses use the identical header; the group is byte[4], the command byte[5], payload byte[6+]. +/// A logical frame may span several notifications and is reassembled by total length — the 9th bit of +/// the length rides bit0 of byte[2] (`0x10`), so length = `((byte[2] & 1) << 8) | byte[3]` (>255 ok). +enum CRPUUIDs { + // Proprietary CRP service + characteristics. + static let service = "0000fdda-0000-1000-8000-00805f9b34fb" + static let stepsNotify = "0000fdd1-0000-1000-8000-00805f9b34fb" // current-steps push + static let write = "0000fdd2-0000-1000-8000-00805f9b34fb" // command write target + static let cmdNotify = "0000fdd3-0000-1000-8000-00805f9b34fb" // framed command replies + static let recordingNotify = "0000fdd6-0000-1000-8000-00805f9b34fb" // OTA/recording (ignored in v1) + + // Standard GATT services reused by the ring. + static let heartRateService = "0000180d-0000-1000-8000-00805f9b34fb" + static let heartRateMeasure = "00002a37-0000-1000-8000-00805f9b34fb" + static let batteryService = "0000180f-0000-1000-8000-00805f9b34fb" + static let batteryLevel = "00002a19-0000-1000-8000-00805f9b34fb" + + // CBUUID forms — used for BLE topology and inbound routing. A SIG-base 128-bit UUID compares + // equal to the 16-bit form CoreBluetooth delivers (the jring's `000056ff…` service relies on the + // same normalization), so declaring the full form here still matches the ring's advertised chars. + static let serviceCBUUID = CBUUID(string: service) + static let stepsNotifyCBUUID = CBUUID(string: stepsNotify) + static let writeCBUUID = CBUUID(string: write) + static let cmdNotifyCBUUID = CBUUID(string: cmdNotify) + static let recordingNotifyCBUUID = CBUUID(string: recordingNotify) + static let heartRateServiceCBUUID = CBUUID(string: heartRateService) + static let heartRateMeasureCBUUID = CBUUID(string: heartRateMeasure) + static let batteryServiceCBUUID = CBUUID(string: batteryService) + static let batteryLevelCBUUID = CBUUID(string: batteryLevel) +} + +/// CRP command groups + subcommands (verified from the decompiled `b1` package builders). +/// Only the v1 subset is enumerated; the vendor SDK spans groups 1–10 with dozens of subcommands. +enum CRPCommands { + // Group 1 — device config / measurement control. + static let groupDevice = 1 + static let cmdSetUserInfo = 0 // b1/k.a: [height, weight, age, gender, strideLen] + static let cmdSetTime = 1 // b1/e.b: [epochSecondsLE(4), tzByte] + static let cmdMeasureHR = 9 // b1/t.d: [enable] — start(1)/stop(0) continuous HR + static let cmdMeasureSpO2 = 11 // b1/h.d: [enable] — start(1)/stop(0) SpO2 + + // Group 3 — power control. + static let groupPower = 3 + static let cmdFactoryReset = 0 // b1/l.v: q.b(3,0) + static let cmdRestart = 1 // b1/l.w: q.b(3,1) + + // Group 9 — device actions. + static let groupAction = 9 + static let cmdFindDevice = 2 // b1/c0.c: [enable] +} + +/// Builds and parses CRP wire frames. Pure and side-effect free so the framing is unit-testable +/// without a BLE stack (see `CRPProtocolTests`). +enum CRPProtocol { + private static let header0: UInt8 = 0xFD + private static let header1: UInt8 = 0xDA + private static let header2: UInt8 = 0x10 + static let headerSize = 6 + + /// Build a fully-framed CRP packet: `FD DA 10 `. + static func frame(group: Int, cmd: Int, payload: [UInt8] = []) -> Data { + let total = payload.count + headerSize + var out = [UInt8](repeating: 0, count: total) + out[0] = header0 + out[1] = header1 + out[2] = header2 + out[3] = UInt8(truncatingIfNeeded: total) + out[4] = UInt8(truncatingIfNeeded: group) + out[5] = UInt8(truncatingIfNeeded: cmd) + for (i, byte) in payload.enumerated() { out[headerSize + i] = byte } + return Data(out) + } + + /// True when `data` begins a CRP frame (`FD DA …`). + static func isFrameStart(_ data: Data) -> Bool { + data.count >= 2 && data[data.startIndex] == header0 && data[data.startIndex + 1] == header1 + } + + /// Total declared length of a frame whose header is `data`. Mirrors the vendor's + /// `H(byte[2], byte[3])`: the length's 9th bit rides bit0 of byte[2] (`0x10`), so long history + /// frames (>255 bytes) decode correctly. Returns 0 if `data` is too short. + static func frameLength(_ data: Data) -> Int { + guard data.count >= 4 else { return 0 } + let b = [UInt8](data) + return ((Int(b[2]) & 0x01) << 8) | (Int(b[3]) & 0xFF) + } + + // MARK: - Command builders (v1 subset) + + /// Set the device clock. Vendor quirk (`b1/e.b`): the wall-clock components are encoded as if the + /// zone were GMT+8, with a fixed tz byte of 8 — the ring then displays the correct local wall clock + /// regardless of the phone's real timezone. Replicated verbatim so history stamps agree with what + /// the vendor app would have written. + /// + /// The Android source builds this from `LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(8))`: + /// the phone's local wall clock re-interpreted as a GMT+8 instant. The equivalent here takes the + /// real epoch, adds the phone's own UTC offset to get the wall-clock-as-seconds, then subtracts 8h. + static func setTime(date: Date = Date(), timeZone: TimeZone = .current) -> Data { + let offset = timeZone.secondsFromGMT(for: date) + let wallClockSeconds = date.timeIntervalSince1970 + Double(offset) + let epoch = UInt32(truncatingIfNeeded: Int(wallClockSeconds) - 8 * 3600) + let payload: [UInt8] = [ + UInt8(truncatingIfNeeded: epoch), + UInt8(truncatingIfNeeded: epoch >> 8), + UInt8(truncatingIfNeeded: epoch >> 16), + UInt8(truncatingIfNeeded: epoch >> 24), + 8, // timezone byte (GMT+8), matching the vendor + ] + return frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdSetTime, payload: payload) + } + + /// Push user anthropometrics so on-device step/calorie algorithms have real inputs. + /// Layout from `b1/k.a`: [height(cm), weight(kg), age(yr), gender, strideLen(cm)]. + static func setUserInfo(heightCm: Int, weightKg: Int, ageYears: Int, gender: Int, strideCm: Int) -> Data { + let payload: [UInt8] = [ + UInt8(truncatingIfNeeded: heightCm), UInt8(truncatingIfNeeded: weightKg), + UInt8(truncatingIfNeeded: ageYears), UInt8(truncatingIfNeeded: gender), + UInt8(truncatingIfNeeded: strideCm), + ] + return frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdSetUserInfo, payload: payload) + } + + static func measureHeartRate(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureHR, payload: [enable ? 1 : 0]) + } + + static func measureSpO2(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureSpO2, payload: [enable ? 1 : 0]) + } + + static func findDevice(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupAction, cmd: CRPCommands.cmdFindDevice, payload: [enable ? 1 : 0]) + } + + static func factoryReset() -> Data { + frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdFactoryReset) + } +} diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift new file mode 100644 index 0000000..c21eee1 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Per-connection orchestration for a CRP ("crrepa") ring. Ported in spirit from the Moyoung +/// "Da Rings" connect flow (`d1/b.java` + `b1` package builders): after the link is up the app sets +/// the clock and pushes user anthropometrics, then the ring streams current steps (`fdd1`) on its own +/// and answers measurement commands. There is no bulk history state machine in v1, so most of the +/// `RingSyncEngine` surface is left as the protocol's no-op defaults. +/// +/// v1 scope: clock + user-info handshake, live/manual heart rate, find-device. Steps and battery +/// arrive as autonomous pushes/reads (see `CRPDriver`) and need no command here. Sleep / SpO2 / HRV / +/// stress / temperature and history sync are deliberately deferred — their reply layouts aren't yet +/// confirmed against the decompile, and `CRPCoordinator` doesn't advertise those capabilities, so +/// nothing calls the corresponding methods. +/// +/// Factory reset / power off: the CRP command (`CRPProtocol.factoryReset`, group 3 / cmd 0) is known, +/// but iOS's `RingSyncEngine` exposes no factory-reset/power-off hook (the Colmi encoder has the +/// opcodes too, with no invocation path), so there is nothing to wire it into here — matching the +/// Android `CRPSyncEngine`, whose `factoryReset()` this port intentionally does not surface as a +/// capability. +@MainActor +final class CRPSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private var profile: UserProfileValues? + + init(writer: RingCommandWriter?) { + self.writer = writer + } + + func runStartup() { + // Set the device clock first (matches the vendor's connect handshake), then user info so the + // ring's step/calorie algorithm has real inputs. + send(CRPProtocol.setTime()) + if let profile { send(userInfoFrame(profile)) } + } + + func handle(_ event: RingDecodedEvent) { + // Steps/HR/battery are persisted by RingBLEClient via RingEventBridge; v1 keeps no engine-side + // state (no staged history pipeline to advance). + } + + // MARK: - Heart rate (standard 2a37 stream, started/stopped via the fdda command channel) + func startHeartRate() { send(CRPProtocol.measureHeartRate(true)) } + func stopHeartRate() { send(CRPProtocol.measureHeartRate(false)) } + + // MARK: - SpO2 (command verified; result parsing deferred, so the capability isn't advertised) + func startSpO2() { send(CRPProtocol.measureSpO2(true)) } + func stopSpO2() { send(CRPProtocol.measureSpO2(false)) } + + func findDevice() { send(CRPProtocol.findDevice(true)) } + + func setGoal(steps: Int) { + // Step-goal command layout not yet confirmed from the decompile; no-op for now. + } + + // MARK: - User profile + func setUserProfile(_ profile: UserProfileValues) { self.profile = profile } + + func applyUserProfile(_ profile: UserProfileValues) { + self.profile = profile + send(userInfoFrame(profile)) + } + + func resyncTime() { send(CRPProtocol.setTime()) } + + /// Map the app's `UserProfileValues` onto the CRP user-info payload. Stride length isn't carried + /// by the profile, so estimate it from height (~0.43·height, a common default). + private func userInfoFrame(_ p: UserProfileValues) -> Data { + let heightCm = Int(p.heightCm) + let strideCm = min(255, max(0, Int(Double(heightCm) * 0.43))) + return CRPProtocol.setUserInfo( + heightCm: heightCm, + weightKg: Int(p.weightKg), + ageYears: Int(p.age), + gender: Int(p.gender), + strideCm: strideCm + ) + } + + private func send(_ frame: Data?) { + if let frame { writer?.enqueue(frame) } + } +} diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 51603a7..7217842 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -42,6 +42,11 @@ final class RingBLEClient: NSObject { ColmiCoordinator.self, LuckRingCoordinator.self, TK5Coordinator.self, + // CRP matches only its family-exclusive `fdda` service, which the CRP R11 doesn't advertise + // pre-connect — so its position is not load-bearing and it never auto-claims at scan. It's + // reached by an explicit "Colmi R11 (Da Rings app)" carousel pick (`preferredFamily = .crp`), + // iOS having no post-connect driver re-route like the Android app's. + CRPCoordinator.self, ] /// Which coordinator serves a connection. Pure, so the pairing rules are testable without a diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index c4e19af..27c152c 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -239,6 +239,9 @@ struct DeviceHeroCard: View { case .colmiR02, .colmiSmartHealth: return nil case .tk5: return "tk5" case .luckRing: return "luckring-tk18" + // The connection reveals only the family; both R11 firmwares share the generic Colmi ring line, + // so the CRP family falls back to the generic ring here (the carousel card carries its own art). + case .crp: return nil case nil: return nil } } diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index c00eb80..4a83446 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -15,6 +15,12 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { /// LuckRing / TK18 family (the "K6" vendor SDK, company ID `0xFF64`). Sold under simsonlab and other /// brands; TK18 is the hardware-tested unit. See `LuckRingCoordinator`. case luckRing + /// CRP ("crrepa"/CRPsmart) family — the proprietary `fdda`-profile rings whose official app is + /// Moyoung "Da Rings" (`com.moyoung.ring`). Notably the CRP-firmware Colmi R11: it advertises the + /// generic "SMART_RING" name with no service UUID, so it's classified jring at scan and only reveals + /// its `fdda` service post-connect (issue #29, zaggash's ring). Reached on iOS by picking the + /// "Colmi R11 (Da Rings app)" card. See `CRPCoordinator`. + case crp /// Human-facing default name when no advertised name is available. var displayName: String { @@ -24,6 +30,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .tk5: return "TK5 ring" case .colmiSmartHealth: return "Colmi ring (SmartHealth)" case .luckRing: return "LuckRing" + case .crp: return "Colmi / Moyoung ring (CRP)" } } } diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 5babdcd..b829bbb 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -53,7 +53,7 @@ enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { switch family { case .colmiR02: self = .qring case .colmiSmartHealth: self = .smartHealth - case .jring, .tk5, .luckRing: return nil + case .jring, .tk5, .luckRing, .crp: return nil } } @@ -114,6 +114,9 @@ extension RingDeviceType { case .tk5: return .limited // TK18 is the only hardware-tested LuckRing; every 0xFF64 sibling is still a prediction. case .luckRing: return .limited + // The CRP driver is a conservative v1 reconstruction from the decompiled "Da Rings" app, + // not yet proven against zaggash's ring on hardware — so it wears the "Limited support" badge. + case .crp: return .limited } } } @@ -183,6 +186,18 @@ extension WearableModel { advertisedNamePatterns: ["^TK18([ _-].*)?$"], imageName: "luckring-tk18" ) + /// The **CRP-firmware** R11 — the same physical ring as `colmiR11`, but its official app is + /// Moyoung "Da Rings" and it speaks the proprietary `fdda` CRP protocol, not the Colmi/QRing UART + /// (see `CRPCoordinator`). "R11 / SMART_RING" is sold under both firmwares; a unit is this one when + /// the user picks this card. No usable name pattern — the ring advertises the same generic + /// `SMART_RING` as jring, so there is nothing for the scan to match on and the pick is the only + /// entry point. Reuses the `yawell-r11` art (same hardware as `colmiR11`). + static let colmiR11CRP = WearableModel( + id: "colmi-r11-crp", displayName: "Colmi R11 (Da Rings app)", brand: "Colmi", family: .crp, + tint: PulseColors.hrv, blurb: "HR · Steps", + advertisedNamePatterns: [], imageName: "yawell-r11" + ) + // Yawell-branded variants of the same hardware. static let yawellR05 = colmiFamily("yawell-r05", "Yawell R05", brand: "Yawell", pattern: "^R05_[0-9A-F]{4}$") static let yawellR10 = colmiFamily("yawell-r10", "Yawell R10", brand: "Yawell", pattern: "^R10_[0-9A-F]{4}$") @@ -312,6 +327,7 @@ extension WearableModel { yawellR05, yawellR10, yawellR11, h59, tk5, luckRingTK18, + colmiR11CRP, ] static func model(id: String?) -> WearableModel? { diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift new file mode 100644 index 0000000..387c89d --- /dev/null +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -0,0 +1,90 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// Unit tests for CRP inbound decoding + reassembly (`CRPDecoder`, `CRPFrameAssembler`) and the +/// `CRPDriver.ingest` routing. Byte layouts are from the decompiled Moyoung app (`e1/k.b` steps, +/// `g1/a.B` heart rate, `g1/a.k` frame reassembly). No BLE stack needed. Ported from the Android +/// app's `CRPDecoderTest.kt`. +@MainActor +final class CRPDecoderTests: XCTestCase { + + private let fdd1 = CRPUUIDs.stepsNotifyCBUUID + private let fdd3 = CRPUUIDs.cmdNotifyCBUUID + private let hr = CRPUUIDs.heartRateMeasureCBUUID + + func testCurrentStepsPushDecodesLittleEndianStepsDistanceCalories() { + // steps=1000 (E8 03 00), distance=500 (F4 01 00), calories=42 (2A 00 00) + let data = Data([0xE8, 0x03, 0x00, 0xF4, 0x01, 0x00, 0x2A, 0x00, 0x00]) + let events = CRPDecoder.decode(data, from: fdd1) + XCTAssertEqual(events.count, 1) + guard case let .activityUpdate(_, steps, distanceMeters, calories) = events[0] else { + return XCTFail("expected activityUpdate, got \(events[0])") + } + XCTAssertEqual(steps, 1000) + XCTAssertEqual(distanceMeters, 500) + XCTAssertEqual(calories, 42) + } + + func testStepsPushWithOnlyTheStepTripleDecodesDistanceAndCaloriesZero() { + guard case let .activityUpdate(_, steps, distanceMeters, calories) = + CRPDecoder.decode(Data([0x0A, 0x00, 0x00]), from: fdd1)[0] else { + return XCTFail("expected activityUpdate") + } + XCTAssertEqual(steps, 10) + XCTAssertEqual(distanceMeters, 0) + XCTAssertEqual(calories, 0) + } + + func testStepsPushOfNonMultipleOfThreeLengthIsRejected() { + XCTAssertTrue(CRPDecoder.decode(Data([1, 2]), from: fdd1).isEmpty) + } + + func testHeartRate2a37ReadsBpmFromByte1WhenThe0x0400MarkerIsPresent() { + // [status, bpm=72, 0x00, 0x04] -> marker bytes[2..3] == 0x0400 + guard case let .heartRateSample(bpm, _) = CRPDecoder.decode(Data([0x00, 72, 0x00, 0x04]), from: hr)[0] else { + return XCTFail("expected heartRateSample") + } + XCTAssertEqual(bpm, 72) + } + + func testHeartRate2a37WithWrongMarkerIsDropped() { + XCTAssertTrue(CRPDecoder.decode(Data([0x00, 72, 0x00, 0x08]), from: hr).isEmpty) + } + + func testHeartRate2a37WithZeroBpmIsDropped() { + XCTAssertTrue(CRPDecoder.decode(Data([0x00, 0, 0x00, 0x04]), from: hr).isEmpty) + } + + func testAssemblerReturnsASinglePacketFrameImmediately() { + let a = CRPFrameAssembler() + let frame = CRPProtocol.frame(group: 1, cmd: 9, payload: [0x50]) // len 7 + XCTAssertEqual(a.append(frame), frame) + } + + func testAssemblerReassemblesAFrameSplitAcrossTwoNotifications() { + let a = CRPFrameAssembler() + // A 10-byte frame: FD DA 10 0A 02 05 + 4 payload bytes, delivered as 6 + 4. + let full = CRPProtocol.frame(group: 2, cmd: 5, payload: [1, 2, 3, 4]) // size 10 + XCTAssertNil(a.append(Data(full.prefix(6)))) // header only — not complete + let done = a.append(Data(full.suffix(4))) // continuation completes it + XCTAssertEqual(done, full) + } + + func testAssemblerDropsAContinuationWithNoInProgressFrame() { + let a = CRPFrameAssembler() + XCTAssertNil(a.append(Data([1, 2, 3, 4]))) + } + + func testDriverRoutesFdd1ToStepsAndReassemblesFdd3Replies() { + let driver = CRPDriver(writer: nil) + let steps = driver.ingest(Data([0x05, 0x00, 0x00]), from: fdd1) + XCTAssertEqual(steps.count, 1) + guard case .activityUpdate = steps[0] else { return XCTFail("expected activityUpdate") } + + // A framed reply split across two fdd3 notifications yields exactly one decoded event. + let full = CRPProtocol.frame(group: 1, cmd: 9, payload: [0x50]) // size 7 + XCTAssertTrue(driver.ingest(Data(full.prefix(4)), from: fdd3).isEmpty) + XCTAssertEqual(driver.ingest(Data(full.suffix(3)), from: fdd3).count, 1) + } +} diff --git a/PulseLoopTests/CRPProtocolTests.swift b/PulseLoopTests/CRPProtocolTests.swift new file mode 100644 index 0000000..811dcf7 --- /dev/null +++ b/PulseLoopTests/CRPProtocolTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import PulseLoop + +/// Unit tests for the CRP ("crrepa") framing + command builders (`CRPProtocol`). Pure byte-level +/// checks against the decompiled Moyoung "Da Rings" builders (`b1/q.java`, `b1/e.java`, `b1/k.java`, +/// `b1/t.java`, `b1/c0.java`, `b1/l.java`); no BLE stack needed. See `decompiled-moyoung-official/`. +/// Ported from the Android app's `CRPProtocolTest.kt`. +final class CRPProtocolTests: XCTestCase { + + func testFrameLaysOutFDDA10LenGroupCmdPayload() { + let f = CRPProtocol.frame(group: 1, cmd: 9, payload: [1]) + // FD DA 10 | len=7 | group=1 | cmd=9 | payload=01 + XCTAssertEqual(f, Data([0xFD, 0xDA, 0x10, 7, 1, 9, 1])) + } + + func testFrameLengthEqualsPayloadPlusSixByteHeader() { + XCTAssertEqual(CRPProtocol.frame(group: 3, cmd: 0).count, 6) // no payload + XCTAssertEqual(CRPProtocol.frame(group: 1, cmd: 0, payload: [UInt8](repeating: 0, count: 5)).count, 11) + } + + func testIsFrameStartRecognisesTheFDDAMagicOnly() { + XCTAssertTrue(CRPProtocol.isFrameStart(Data([0xFD, 0xDA, 0x10, 6]))) + XCTAssertFalse(CRPProtocol.isFrameStart(Data([0xFD, 0x00]))) + XCTAssertFalse(CRPProtocol.isFrameStart(Data([0xDA]))) + } + + func testFrameLengthReadsByte3WithThe9thBitFromByte2() { + // Short frame: byte[2]=0x10 (bit0 clear) => length is byte[3]. + XCTAssertEqual(CRPProtocol.frameLength(Data([0xFD, 0xDA, 0x10, 20])), 20) + // Long frame: bit0 of byte[2] set => +256. + XCTAssertEqual(CRPProtocol.frameLength(Data([0xFD, 0xDA, 0x11, 5])), 256 + 5) + } + + func testSetUserInfoMatchesVendorLayout() { + // b1/k.a: q.c(1, 0, [height, weight, age, gender, strideLen]) + let f = CRPProtocol.setUserInfo(heightCm: 175, weightKg: 70, ageYears: 30, gender: 1, strideCm: 75) + XCTAssertEqual(f, Data([0xFD, 0xDA, 0x10, 11, 1, 0, 175, 70, 30, 1, 75])) + } + + func testSetTimeIsGroup1Cmd1WithLittleEndianEpochAndTZByte8() { + let b = [UInt8](CRPProtocol.setTime()) + XCTAssertEqual(b[0], 0xFD); XCTAssertEqual(b[1], 0xDA); XCTAssertEqual(b[2], 0x10) + XCTAssertEqual(Int(b[3]), 11) // 5 payload + 6 header + XCTAssertEqual(Int(b[4]), 1) // group + XCTAssertEqual(Int(b[5]), 1) // cmd + XCTAssertEqual(Int(b[10]), 8) // trailing timezone byte + // Epoch is little-endian: reconstruct and sanity-check it's a plausible 2020s timestamp. + let epoch = UInt32(b[6]) | (UInt32(b[7]) << 8) | (UInt32(b[8]) << 16) | (UInt32(b[9]) << 24) + XCTAssertTrue((1_577_836_800...4_102_444_800).contains(Int(epoch)), "epoch \(epoch) out of expected range") + } + + func testHeartRateStartAndStopToggleTheEnableByteOnGroup1Cmd9() { + XCTAssertEqual(CRPProtocol.measureHeartRate(true), Data([0xFD, 0xDA, 0x10, 7, 1, 9, 1])) + XCTAssertEqual(CRPProtocol.measureHeartRate(false), Data([0xFD, 0xDA, 0x10, 7, 1, 9, 0])) + } + + func testSpO2UsesGroup1Cmd11() { + XCTAssertEqual(CRPProtocol.measureSpO2(true), Data([0xFD, 0xDA, 0x10, 7, 1, 11, 1])) + } + + func testFindDeviceIsGroup9Cmd2() { + XCTAssertEqual(CRPProtocol.findDevice(true), Data([0xFD, 0xDA, 0x10, 7, 9, 2, 1])) + } + + func testFactoryResetIsGroup3Cmd0WithNoPayload() { + XCTAssertEqual(CRPProtocol.factoryReset(), Data([0xFD, 0xDA, 0x10, 6, 3, 0])) + } +} diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift new file mode 100644 index 0000000..4214ebc --- /dev/null +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import PulseLoop + +/// Unit tests for `CRPSyncEngine` — the connect handshake and interactive commands enqueue the right +/// CRP frames. Mirrors the vendor's connect flow (set clock, then user info). Ported from the Android +/// app's `CRPSyncEngineTest.kt`, adapted to iOS's `UserProfileValues` initializer. +@MainActor +final class CRPSyncEngineTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + /// (group, cmd) of each written frame. + var opcodes: [[Int]] { sent.map { let b = [UInt8]($0); return [Int(b[4]), Int(b[5])] } } + func payloadByte(_ frame: Int, _ index: Int) -> Int { Int([UInt8](sent[frame])[index]) } + } + + func testRunStartupSendsSetTimeThenUserInfoOnceAProfileIsStored() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + XCTAssertEqual(w.opcodes, [[1, 1]]) // set-time only, no profile yet + + w.sent.removeAll() + engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) + engine.runStartup() + XCTAssertEqual(w.opcodes, [[1, 1], [1, 0]]) // set-time then set-user-info + } + + func testHeartRateStartAndStopEnqueueGroup1Cmd9() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.startHeartRate() + engine.stopHeartRate() + XCTAssertEqual(w.opcodes, [[1, 9], [1, 9]]) + XCTAssertEqual(w.payloadByte(0, 6), 1) // enable + XCTAssertEqual(w.payloadByte(1, 6), 0) // disable + } + + func testFindDeviceEnqueuesItsCommand() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.findDevice() + XCTAssertEqual(w.opcodes, [[9, 2]]) + } + + func testApplyUserProfilePushesUserInfoImmediately() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.applyUserProfile(UserProfileValues(metric: true, sex: "female", age: 25, heightCm: 165, weightKg: 60)) + XCTAssertEqual(w.opcodes, [[1, 0]]) + // height passes through; stride is estimated as ~0.43*height. + XCTAssertEqual(w.payloadByte(0, 6), 165) + XCTAssertEqual(w.payloadByte(0, 10), Int(165.0 * 0.43)) // 70 + } +} diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index d049619..83740f0 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -677,6 +677,57 @@ final class PairingMatchingTests: XCTestCase { XCTAssertNil(RingAppVariant(family: .luckRing), "single-firmware family — no app picker") } + // MARK: - CRP / Colmi R11 (Da Rings app) + + /// A CRP ring advertising its family-exclusive `fdda` service. + private var crpServiceAdv: AdvertisementInfo { + AdvertisementInfo(serviceUUIDs: [CRPUUIDs.serviceCBUUID], manufacturerData: nil) + } + + /// The CRP R11 has no scan signature (generic `SMART_RING`, no service UUID), so it is reached only + /// by the carousel card — not by any name or (in practice) service match. The coordinator still + /// claims a ring that *does* advertise `fdda`, so such a ring lands on the CRP driver, not jring. + func testCRPClaimsOnlyTheFddaServiceAndNeverTheSmartRingName() { + XCTAssertTrue(CRPCoordinator.matches(name: "SMART_RING", advertisement: crpServiceAdv)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "Unlabeled", advertisement: crpServiceAdv), .crp) + // The bare `SMART_RING` the CRP R11 actually advertises is claimed by jring, as before — the + // CRP driver is reached by the user's pick, not the scan. + XCTAssertFalse(CRPCoordinator.matches(name: "SMART_RING", advertisement: noAdv)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "SMART_RING", advertisement: noAdv), .jring) + // jring claims the *named* `SMART_RING` even alongside the `fdda` service — it matches on the + // name alone — so a named CRP R11 lands on jring at scan, which is why the pick is the entry + // point. Only an *unlabeled* `fdda` ring is unambiguously the CRP driver's. + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "SMART_RING", advertisement: crpServiceAdv), .jring) + XCTAssertFalse(JringCoordinator.matches(name: "Unlabeled", advertisement: crpServiceAdv)) + XCTAssertFalse(ColmiCoordinator.matches(name: "Unlabeled", advertisement: crpServiceAdv)) + } + + /// The card carries no name pattern, so it resolves purely from the family + selected model id — + /// the explicit-pick path a `preferredFamily = .crp` connect takes. + func testCRPModelResolvesFromTheExplicitPick() { + XCTAssertEqual(WearableModel.colmiR11CRP.family, .crp) + XCTAssertTrue(WearableModel.colmiR11CRP.advertisedNamePatterns.isEmpty) + XCTAssertEqual( + WearableModel.resolve(advertisedName: "SMART_RING", selectedModelID: "colmi-r11-crp", family: .crp)?.id, + "colmi-r11-crp" + ) + // The CRP coordinator serves the CRP family, so picking the card can't silently fall back to jring. + XCTAssertEqual(RingBLEClient.coordinatorType(preferredFamily: .crp, autoMatched: .jring).deviceType, .crp) + } + + func testCRPSupportLevelIsLimitedAndHasNoAppPicker() { + XCTAssertEqual(RingDeviceType.crp.supportLevel, .limited) + XCTAssertEqual(WearableModel.colmiR11CRP.supportLevel, .limited) + XCTAssertNil(RingAppVariant(family: .crp), "single-firmware family — no app picker") + XCTAssertTrue(WearableModel.colmiR11CRP.appVariants.isEmpty) + XCTAssertEqual(RingDeviceType.crp.displayName, "Colmi / Moyoung ring (CRP)") + } + + /// The CRP card reuses the Yawell R11 art (same physical ring as the QRing-firmware `colmiR11`). + func testCRPReusesYawellR11Image() { + XCTAssertEqual(WearableModel.colmiR11CRP.imageName, WearableModel.yawellR11.imageName) + } + // MARK: - Support level func testSupportLevelIsPerFamily() { @@ -690,7 +741,9 @@ final class PairingMatchingTests: XCTestCase { /// family (only the TK18 unit is proven). The SmartHealth-Colmi graduated to `.full` once an R99 /// ran against the driver on hardware, so neither Colmi picker position wears a badge anymore. func testLimitedSupportFamiliesCarryTheBadge() { - let limitedByDefault: Set = [WearableModel.tk5.id, WearableModel.luckRingTK18.id] + let limitedByDefault: Set = [ + WearableModel.tk5.id, WearableModel.luckRingTK18.id, WearableModel.colmiR11CRP.id, + ] for model in WearableModel.catalog { let expected: WearableSupportLevel = limitedByDefault.contains(model.id) ? .limited : .full XCTAssertEqual(model.supportLevel, expected, model.displayName) From a2c6c01766b3b5cd82c294ad3c936a7840ab248a Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Tue, 21 Jul 2026 06:45:17 -0700 Subject: [PATCH 2/6] fix(ring): decode CRP vital results from group-1 replies, fix command mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from Android feat/crp-vitals branch. Same root cause and fix: Root cause — CRPDecoder.decodeFramedReply collapsed every group-1 reply to CommandAck, discarding real-time vital results (HR 74, etc.) that the ring sends back on fdd3 as group-1/cmd replies. The vendor's dispatcher (g1/a.java lines 664–712) routes them by cmd: 9=HR, 10=HRV, 11=SpO2, 14=stress, 32=temp. Fixes: - CRPDecoder.decodeFramedReply now decodes group-1 vital results with payload[0] value parsing and plausibility guards (HR 40-200, SpO2 70-100, stress 0-100, HRV 20-200). - Removed dead 2a37 HR characteristic path — CRP rings never use it. - CRPCoordinator now advertises .spo2, .stress, .hrv, .temperature. Command mapping corrections (verified against decompiled b1 package): - enableTimingHR: cmd 6 (was 7), enableTimingHRV: cmd 7 (was 9 — collided with MEASURE_HR), enableTimingSpO2: cmd 8 (was 11 — collided with MEASURE_SPO2), enableTimingStress: cmd 39 (was 13), enableTimingTemp: cmd 13 (was 15). - Disable: HR/HRV/SpO2/Stress use enable with interval=0; Temp uses cmd 32 with [false]. - History queries: group 7 (was group 2) — e0.a/b/e/f use q.b(7,…) and q.c(7,…). Only sleep (cmd 14) and temp (cmd 48) remain on group 2. - Added queryFirmwareVersion() to startup handshake (fixes "Firmware: reading" in UI). - CRPSyncEngine accepts MeasurementSettings, uses hrIntervalMinutes for vital intervals, re-sends enable/disable on live config changes. Unit tests updated: removed 2a37 tests (dead code), added vital result decode tests for HR/HRV/SpO2/stress/temp with plausibility guards. --- PulseLoop/RingProtocol/CRPCoordinator.swift | 12 +- PulseLoop/RingProtocol/CRPDecoder.swift | 108 +++++++++++++---- PulseLoop/RingProtocol/CRPProtocol.swift | 122 ++++++++++++++++++-- PulseLoop/RingProtocol/CRPSyncEngine.swift | 54 +++++++-- PulseLoopTests/CRPDecoderTests.swift | 104 ++++++++++++++--- 5 files changed, 337 insertions(+), 63 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPCoordinator.swift b/PulseLoop/RingProtocol/CRPCoordinator.swift index b3c5552..6961778 100644 --- a/PulseLoop/RingProtocol/CRPCoordinator.swift +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -30,14 +30,16 @@ final class CRPCoordinator: WearableCoordinator { advertisement.serviceUUIDs.contains(CRPUUIDs.serviceCBUUID) } - /// v1 baseline — only capabilities backed by a decode path confirmed from the decompile: - /// current-steps push (`fdd1`), the standard HR stream (`2a37`) with its start/stop command, its - /// spot reading, the standard battery read, and find-device. Sleep / SpO2 / HRV / stress / - /// temperature and history sync are deferred until their CRP reply layouts are confirmed against - /// hardware — deliberately not promised here so the product UI hides them. + /// Real-time vital capabilities backed by decoded group-1 replies (`g1/a.java` lines 664–712): + /// HR (cmd 9), HRV (cmd 10), SpO2 (cmd 11), stress (cmd 14), temperature (cmd 32). + /// History sync and sleep are still deferred — their group-7 reply layouts aren't confirmed + /// against hardware yet. Steps push (`fdd1`), battery (`2a19`), find-device also confirmed. + /// Note: HR does NOT use the standard `2a37` characteristic on CRP rings — all vital results + /// come back as framed replies on `fdd3` group 1. let capabilities: Set = [ .steps, .realtimeSteps, .heartRate, .realtimeHeartRate, .manualHeartRate, + .spo2, .stress, .hrv, .temperature, .battery, .findDevice, ] diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index f815828..fdca559 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -33,20 +33,25 @@ final class CRPFrameAssembler { /// `from` UUID `CRPDriver.ingest` passes through), matching the vendor's `g1/a.a(characteristic)` /// dispatch: /// - `fdd1` → raw current-steps triples (no CRP header) -/// - `2a37` → standard HR-measurement stream /// - `fdd3` → framed `FD DA …` command replies (already reassembled by `CRPFrameAssembler`) /// -/// Unverified-against-hardware layouts are decoded conservatively: anything whose byte layout isn't -/// confirmed from the decompile is emitted as `.commandAck` rather than fabricating a metric value. -/// Extend `decodeFramedReply` as more command replies are confirmed. +/// NOTE: This ring does NOT use the standard `2a37` HR characteristic — all vital results come +/// back as framed replies on `fdd3` with group/cmd routing. The `2a37` path is dead code for CRP +/// rings (removed during port). +/// +/// Group-1 replies (`g1/a.java` lines 664–712) carry real-time vital results: +/// cmd 9 → HR (payload[0] = bpm, per `e1/f.b()`) +/// cmd 10 → HRV (payload[0] = ms) +/// cmd 11 → SpO2 (payload[0] = percent) +/// cmd 14 → stress (payload[0] = 0..100) +/// cmd 32 → temperature (payload[0..] = raw) +/// Other cmd values → command acknowledgment. enum CRPDecoder { static func decode(_ data: Data, from characteristic: CBUUID, now: Date = Date()) -> [RingDecodedEvent] { switch characteristic { case CRPUUIDs.stepsNotifyCBUUID: return decodeCurrentSteps(data, now: now) - case CRPUUIDs.heartRateMeasureCBUUID: - return decodeHeartRateMeasure(data, now: now) default: return CRPProtocol.isFrameStart(data) ? decodeFramedReply(data, now: now) : [] } @@ -64,30 +69,89 @@ enum CRPDecoder { distanceMeters: Double(distance), calories: Double(calories))] } - /// Standard HR characteristic (`2a37`). From `g1/a.B`: bpm at byte[1], validated by the `0x0400` - /// marker at bytes[2..3] (little-endian: byte[3] high). - private static func decodeHeartRateMeasure(_ data: Data, now: Date) -> [RingDecodedEvent] { - let b = [UInt8](data) - if b.count < 2 { return [] } - let bpm = Int(b[1]) - let markerOk = b.count < 4 || ((Int(b[3]) << 8) | Int(b[2])) == 0x0400 - if !markerOk || bpm <= 0 { return [] } - return [.heartRateSample(bpm: bpm, timestamp: now)] - } - - /// Framed `fdd3` reply: `FD DA 10 `. v1 acknowledges recognised - /// command echoes; richer metric replies (HR/SpO2 results, history) are decoded as more layouts - /// are confirmed against the decompile/hardware. + /// Framed `fdd3` reply: `FD DA 10 `. + /// Real-time vital results come on group 1; history queries on group 7; device info on group 7. private static func decodeFramedReply(_ frame: Data, now: Date) -> [RingDecodedEvent] { let b = [UInt8](frame) if b.count < CRPProtocol.headerSize { return [] } let group = Int(b[4]) let cmd = Int(b[5]) - // Only the command echo is confirmed for the v1 command set; treat as an ack so the - // raw-notify/debug feed still records it without inventing a metric value. + let payload = b.count > CRPProtocol.headerSize ? Array(b[CRPProtocol.headerSize.. [RingDecodedEvent] { + guard !payload.isEmpty else { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDevice << 4) | (cmd & 0x0F)))] + } + let value = Int(payload[0]) + + switch cmd { + case CRPCommands.cmdMeasureHR: + // HR from `e1/f.b()`: byte2int(payload[0]). + guard value >= 40 && value <= 200 else { return [] } + return [.heartRateSample(bpm: value, timestamp: now)] + + case CRPCommands.cmdEnableTimingHRV: + // HRV from `e1/g.d()`: twoBytes2int(payload[1], payload[0]), but vendor's onHrv() + // callback receives byte2int(payload[0]) for the live measurement path. + // We accept either layout: single-byte if payload is 1 byte, two-byte otherwise. + let hrvValue: Int + if payload.count >= 2 { + hrvValue = Int(payload[0]) | (Int(payload[1]) << 8) + } else { + hrvValue = value + } + guard hrvValue >= 20 && hrvValue <= 200 else { return [] } + return [.hrvSample(value: hrvValue, timestamp: now)] + + case CRPCommands.cmdEnableTimingSpO2: + // SpO2 from `e1/d.b()`: byte2int(payload[0]). + guard value >= 70 && value <= 100 else { return [] } + return [.spo2Result(value: value, timestamp: now)] + + case CRPCommands.cmdEnableTimingStress: + // Stress/physical strength from `e1/h.c()`: byte2int(payload[0]). + guard value >= 0 && value <= 100 else { return [] } + return [.stressSample(value: value, timestamp: now)] + + case CRPCommands.cmdEnableTimingTemp: + // Temperature: vendor uses onMeasureComplete with payload. Layout unconfirmed. + // Emit as temperature_sample with raw byte as placeholder until verified. + return [.temperatureSample(celsius: Double(value), timestamp: now)] + + default: + // Acknowledgment for enable/disable commands. + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDevice << 4) | (cmd & 0x0F)))] + } + } + + /// Decode group-7 responses: history queries (cmd 4–7, 14, 48) and device info (cmd 0, 1, 13). + /// History layouts are unconfirmed against hardware — emit as CommandAck so the raw-packet feed + /// records them without inventing metric values. Extend `decodeHistoryOrDeviceInfoResponse` + /// as more layouts are confirmed. + private static func decodeHistoryOrDeviceInfoResponse(cmd: Int, payload: [UInt8], now: Date) -> [RingDecodedEvent] { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDeviceInfo << 4) | (cmd & 0x0F)))] + } + /// Little-endian unsigned 3-byte int at `offset`. private static func le3(_ b: [UInt8], _ offset: Int) -> Int { Int(b[offset]) | (Int(b[offset + 1]) << 8) | (Int(b[offset + 2]) << 16) diff --git a/PulseLoop/RingProtocol/CRPProtocol.swift b/PulseLoop/RingProtocol/CRPProtocol.swift index 2b34ff9..351cade 100644 --- a/PulseLoop/RingProtocol/CRPProtocol.swift +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -61,6 +61,9 @@ enum CRPUUIDs { /// CRP command groups + subcommands (verified from the decompiled `b1` package builders). /// Only the v1 subset is enumerated; the vendor SDK spans groups 1–10 with dozens of subcommands. +/// +/// **NOTE on disable:** HR/HRV/SpO2/Stress disable by sending enable with interval=0. Temp disable +/// uses a separate cmd (32) with `[false]`. (Per `d1/b.java` `disableTiming*` methods.) enum CRPCommands { // Group 1 — device config / measurement control. static let groupDevice = 1 @@ -69,6 +72,28 @@ enum CRPCommands { static let cmdMeasureHR = 9 // b1/t.d: [enable] — start(1)/stop(0) continuous HR static let cmdMeasureSpO2 = 11 // b1/h.d: [enable] — start(1)/stop(0) SpO2 + // Group 1 — timing/enable controls (decompiled b1 package). + // Disable: HR/HRV/SpO2/Stress use enable with interval=0. Temp uses a separate cmd. + static let cmdEnableTimingHR = 6 // b1/t.c: q.c(1,6, [interval]) + static let cmdEnableTimingHRV = 7 // b1/u.c: q.c(1,7, [interval]) + static let cmdEnableTimingSpO2 = 8 // b1/h.c: q.c(1,8, [interval]) + static let cmdEnableTimingStress = 39 // b1/h0.c: q.c(1,39, [interval]) + static let cmdEnableTimingTemp = 13 // b1/i0.c: q.c(1,13, [true]) + static let cmdDisableTimingTemp = 32 // b1/i0.d: q.c(1,32, [false]) + + // Group 7 — history queries + device info (decompiled b1/e0 + b1/r). + // NOTE: History queries are group 7, NOT group 2 (the b1/e0 builders use q.b(7,…) and q.c(7,…)). + static let groupDeviceInfo = 7 + static let cmdQueryDeviceInfo = 0 // b1/r.a: q.b(7,0) + static let cmdQueryFirmwareVersion = 1 // b1/r.b: q.b(7,1) + static let cmdQueryDeviceSN = 13 // b1/r.c: q.b(7,13) + static let cmdQueryHistoryHR = 4 // b1/e0.a: q.b(7,4) + static let cmdQueryHistoryStress = 5 // b1/e0.b: q.c(7,5, [interval]) + static let cmdQueryHistoryHRV = 6 // b1/e0.e: q.c(7,6, [interval]) + static let cmdQueryHistorySpO2 = 7 // b1/e0.f: q.b(7,7) + static let cmdQueryHistorySleep = 14 // b1/e0.c: q.c(2,14, [CRPHistoryDay]) + static let cmdQueryHistoryTemp = 48 // b1/e0.d: q.b(7,48) + // Group 3 — power control. static let groupPower = 3 static let cmdFactoryReset = 0 // b1/l.v: q.b(3,0) @@ -107,8 +132,8 @@ enum CRPProtocol { } /// Total declared length of a frame whose header is `data`. Mirrors the vendor's - /// `H(byte[2], byte[3])`: the length's 9th bit rides bit0 of byte[2] (`0x10`), so long history - /// frames (>255 bytes) decode correctly. Returns 0 if `data` is too short. + /// `H(byte[2], byte[3])`: the length's 9th bit rides bit0 of byte[2] (`0x10`), so long + /// history frames (>255 bytes) decode correctly. Returns 0 if `data` is too short. static func frameLength(_ data: Data) -> Int { guard data.count >= 4 else { return 0 } let b = [UInt8](data) @@ -117,14 +142,10 @@ enum CRPProtocol { // MARK: - Command builders (v1 subset) - /// Set the device clock. Vendor quirk (`b1/e.b`): the wall-clock components are encoded as if the - /// zone were GMT+8, with a fixed tz byte of 8 — the ring then displays the correct local wall clock - /// regardless of the phone's real timezone. Replicated verbatim so history stamps agree with what - /// the vendor app would have written. - /// - /// The Android source builds this from `LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(8))`: - /// the phone's local wall clock re-interpreted as a GMT+8 instant. The equivalent here takes the - /// real epoch, adds the phone's own UTC offset to get the wall-clock-as-seconds, then subtracts 8h. + /// Set the device clock. Vendor quirk (`b1/e.b`): the wall-clock components are encoded as if + /// the zone were GMT+8, with a fixed tz byte of 8 — the ring then displays the correct local + /// wall clock regardless of the phone's real timezone. Replicated verbatim so history stamps + /// agree with what the vendor app would have written. static func setTime(date: Date = Date(), timeZone: TimeZone = .current) -> Data { let offset = timeZone.secondsFromGMT(for: date) let wallClockSeconds = date.timeIntervalSince1970 + Double(offset) @@ -165,4 +186,85 @@ enum CRPProtocol { static func factoryReset() -> Data { frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdFactoryReset) } + + // MARK: - Timing/enable commands (group 1) + // HR/HRV/SpO2/Stress disable by sending enable with interval=0 (per d1/b.java disable* methods). + // Temp disable uses a separate cmd (32) with `[false]` (per b1/i0.d and d1/b.java disableTimingTemp). + static func enableTimingHeartRate(intervalMinutes: Int) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHR, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) + } + + static func disableTimingHeartRate() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHR, payload: [0]) + } + + static func enableTimingHRV(intervalMinutes: Int) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHRV, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) + } + + static func disableTimingHRV() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHRV, payload: [0]) + } + + static func enableTimingSpO2(intervalMinutes: Int) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingSpO2, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) + } + + static func disableTimingSpO2() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingSpO2, payload: [0]) + } + + static func enableTimingStress(intervalMinutes: Int) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingStress, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) + } + + static func disableTimingStress() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingStress, payload: [0]) + } + + static func enableTimingTemp() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingTemp) + } + + static func disableTimingTemp() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdDisableTimingTemp) + } + + // MARK: - History query commands (group 7) + static func queryHistoryHeartRate() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryHR) + } + + static func queryHistoryStress() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryStress) + } + + static func queryHistoryHRV() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryHRV) + } + + static func queryHistorySpO2() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistorySpO2) + } + + static func queryHistorySleep(daysAgo: Int = 0) -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistorySleep, payload: [UInt8(truncatingIfNeeded: daysAgo)]) + } + + static func queryHistoryTemp() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryTemp) + } + + // MARK: - Device info queries (group 7) + static func queryDeviceInfo() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryDeviceInfo) + } + + static func queryFirmwareVersion() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryFirmwareVersion) + } + + static func queryDeviceSN() -> Data { + frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryDeviceSN) + } } diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index c21eee1..0e6ddad 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -6,11 +6,11 @@ import Foundation /// and answers measurement commands. There is no bulk history state machine in v1, so most of the /// `RingSyncEngine` surface is left as the protocol's no-op defaults. /// -/// v1 scope: clock + user-info handshake, live/manual heart rate, find-device. Steps and battery -/// arrive as autonomous pushes/reads (see `CRPDriver`) and need no command here. Sleep / SpO2 / HRV / -/// stress / temperature and history sync are deliberately deferred — their reply layouts aren't yet -/// confirmed against the decompile, and `CRPCoordinator` doesn't advertise those capabilities, so -/// nothing calls the corresponding methods. +/// v1 scope: clock + user-info handshake, live/manual heart rate, find-device, factory reset. +/// Steps and battery arrive as autonomous pushes/reads (see `CRPDriver`) and need no command here. +/// Sleep / SpO2 / HRV / stress / temperature and history sync are deliberately deferred — their +/// reply layouts aren't yet confirmed against the decompile, and `CRPCoordinator` doesn't advertise +/// those capabilities, so nothing calls the corresponding methods. /// /// Factory reset / power off: the CRP command (`CRPProtocol.factoryReset`, group 3 / cmd 0) is known, /// but iOS's `RingSyncEngine` exposes no factory-reset/power-off hook (the Colmi encoder has the @@ -24,15 +24,33 @@ final class CRPSyncEngine: RingSyncEngine { private weak var writer: RingCommandWriter? private var profile: UserProfileValues? + /// User-chosen all-day measurement config. Applied in the connect handshake and updatable + /// live via `applyMeasurementSettings`. `nil` ⇒ the user has never saved one, so the engine + /// skips the vital enable commands (the ring's own settings are the source of truth). + private var measurementSettings: MeasurementSettings? + init(writer: RingCommandWriter?) { self.writer = writer } func runStartup() { - // Set the device clock first (matches the vendor's connect handshake), then user info so the - // ring's step/calorie algorithm has real inputs. + // Set the device clock first (matches the vendor's connect handshake), then user info so + // the ring's step/calorie algorithm has real inputs. send(CRPProtocol.setTime()) + // Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report). + send(CRPProtocol.queryFirmwareVersion()) if let profile { send(userInfoFrame(profile)) } + // Enable vital monitoring only when the user has configured it (mirrors the vendor app's + // connect flow). Uses the user's polling interval for all vital types — the CRP protocol + // takes a single interval byte per enable command, and MeasurementSettings only exposes + // hrIntervalMinutes (no per-vital intervals), so we share it across the board. + if let settings = measurementSettings { + if settings.hrEnabled { send(CRPProtocol.enableTimingHeartRate(intervalMinutes: settings.hrIntervalMinutes)) } + if settings.hrvEnabled { send(CRPProtocol.enableTimingHRV(intervalMinutes: settings.hrIntervalMinutes)) } + if settings.stressEnabled { send(CRPProtocol.enableTimingStress(intervalMinutes: settings.hrIntervalMinutes)) } + if settings.spo2Enabled { send(CRPProtocol.enableTimingSpO2(intervalMinutes: settings.hrIntervalMinutes)) } + if settings.temperatureEnabled { send(CRPProtocol.enableTimingTemp()) } + } } func handle(_ event: RingDecodedEvent) { @@ -44,7 +62,7 @@ final class CRPSyncEngine: RingSyncEngine { func startHeartRate() { send(CRPProtocol.measureHeartRate(true)) } func stopHeartRate() { send(CRPProtocol.measureHeartRate(false)) } - // MARK: - SpO2 (command verified; result parsing deferred, so the capability isn't advertised) + // MARK: - SpO2 (command verified; result parsing deferred, so capability isn't advertised) func startSpO2() { send(CRPProtocol.measureSpO2(true)) } func stopSpO2() { send(CRPProtocol.measureSpO2(false)) } @@ -62,6 +80,26 @@ final class CRPSyncEngine: RingSyncEngine { send(userInfoFrame(profile)) } + // MARK: - Measurement settings + func setMeasurementSettings(_ settings: MeasurementSettings?) { + measurementSettings = settings + } + + func applyMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + // Re-send vital enable/disable commands with the updated settings. + if settings.hrEnabled { send(CRPProtocol.enableTimingHeartRate(intervalMinutes: settings.hrIntervalMinutes)) } + else { send(CRPProtocol.disableTimingHeartRate()) } + if settings.hrvEnabled { send(CRPProtocol.enableTimingHRV(intervalMinutes: settings.hrIntervalMinutes)) } + else { send(CRPProtocol.disableTimingHRV()) } + if settings.stressEnabled { send(CRPProtocol.enableTimingStress(intervalMinutes: settings.hrIntervalMinutes)) } + else { send(CRPProtocol.disableTimingStress()) } + if settings.spo2Enabled { send(CRPProtocol.enableTimingSpO2(intervalMinutes: settings.hrIntervalMinutes)) } + else { send(CRPProtocol.disableTimingSpO2()) } + if settings.temperatureEnabled { send(CRPProtocol.enableTimingTemp()) } + else { send(CRPProtocol.disableTimingTemp()) } + } + func resyncTime() { send(CRPProtocol.setTime()) } /// Map the app's `UserProfileValues` onto the CRP user-info payload. Stride length isn't carried diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift index 387c89d..84f418b 100644 --- a/PulseLoopTests/CRPDecoderTests.swift +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -4,14 +4,15 @@ import CoreBluetooth /// Unit tests for CRP inbound decoding + reassembly (`CRPDecoder`, `CRPFrameAssembler`) and the /// `CRPDriver.ingest` routing. Byte layouts are from the decompiled Moyoung app (`e1/k.b` steps, -/// `g1/a.B` heart rate, `g1/a.k` frame reassembly). No BLE stack needed. Ported from the Android -/// app's `CRPDecoderTest.kt`. +/// `e1/f.b` HR, `g1/a.k` frame reassembly). No BLE stack needed. Ported from the Android app's +/// `CRPDecoderTest.kt`. @MainActor final class CRPDecoderTests: XCTestCase { private let fdd1 = CRPUUIDs.stepsNotifyCBUUID private let fdd3 = CRPUUIDs.cmdNotifyCBUUID - private let hr = CRPUUIDs.heartRateMeasureCBUUID + + // MARK: - Steps func testCurrentStepsPushDecodesLittleEndianStepsDistanceCalories() { // steps=1000 (E8 03 00), distance=500 (F4 01 00), calories=42 (2A 00 00) @@ -40,21 +41,7 @@ final class CRPDecoderTests: XCTestCase { XCTAssertTrue(CRPDecoder.decode(Data([1, 2]), from: fdd1).isEmpty) } - func testHeartRate2a37ReadsBpmFromByte1WhenThe0x0400MarkerIsPresent() { - // [status, bpm=72, 0x00, 0x04] -> marker bytes[2..3] == 0x0400 - guard case let .heartRateSample(bpm, _) = CRPDecoder.decode(Data([0x00, 72, 0x00, 0x04]), from: hr)[0] else { - return XCTFail("expected heartRateSample") - } - XCTAssertEqual(bpm, 72) - } - - func testHeartRate2a37WithWrongMarkerIsDropped() { - XCTAssertTrue(CRPDecoder.decode(Data([0x00, 72, 0x00, 0x08]), from: hr).isEmpty) - } - - func testHeartRate2a37WithZeroBpmIsDropped() { - XCTAssertTrue(CRPDecoder.decode(Data([0x00, 0, 0x00, 0x04]), from: hr).isEmpty) - } + // MARK: - Assembler func testAssemblerReturnsASinglePacketFrameImmediately() { let a = CRPFrameAssembler() @@ -76,6 +63,87 @@ final class CRPDecoderTests: XCTestCase { XCTAssertNil(a.append(Data([1, 2, 3, 4]))) } + // MARK: - Vital result decoding (group 1, real-time) + + func testGroup1Cmd9DecodesHeartRateBpm() { + // HR response: group1/cmd9, payload[0]=74 (0x4A) → 74 bpm + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdMeasureHR, payload: [0x4A]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .heartRateSample(bpm, _) = events[0] else { + return XCTFail("expected heartRateSample, got \(events[0])") + } + XCTAssertEqual(bpm, 74) + } + + func testGroup1Cmd9HeartRateBelowPlausibilityThresholdDropped() { + // bpm=30 is below the 40..200 guard. + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdMeasureHR, payload: [0x1E]) + XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) + } + + func testGroup1Cmd9HeartRateAbovePlausibilityThresholdDropped() { + // bpm=250 is above the 40..200 guard. + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdMeasureHR, payload: [0xFA]) + XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) + } + + func testGroup1Cmd10DecodesHRV() { + // HRV response: group1/cmd10, payload[0]=45 → 45 ms + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingHRV, payload: [0x2D]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .hrvSample(value, _) = events[0] else { + return XCTFail("expected hrvSample, got \(events[0])") + } + XCTAssertEqual(value, 45) + } + + func testGroup1Cmd11DecodesSpO2() { + // SpO2 response: group1/cmd11, payload[0]=96 → 96% + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingSpO2, payload: [0x60]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .spo2Result(value, _) = events[0] else { + return XCTFail("expected spo2Result, got \(events[0])") + } + XCTAssertEqual(value, 96) + } + + func testGroup1Cmd14DecodesStress() { + // Stress response: group1/cmd14, payload[0]=42 → stress 42 + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingStress, payload: [0x2A]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .stressSample(value, _) = events[0] else { + return XCTFail("expected stressSample, got \(events[0])") + } + XCTAssertEqual(value, 42) + } + + func testGroup1Cmd32DecodesTemperature() { + // Temp response: group1/cmd32, payload[0]=38 → 38 °C (placeholder layout) + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingTemp, payload: [0x26]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .temperatureSample(celsius, _) = events[0] else { + return XCTFail("expected temperatureSample, got \(events[0])") + } + XCTAssertEqual(celsius, 38.0) + } + + func testGroup1UnknownCmdReturnsCommandAck() { + // Unknown cmd in group 1 → ack, not a fabricated metric. + let frame = CRPProtocol.frame(group: 1, cmd: 99) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case .commandAck = events[0] else { + return XCTFail("expected commandAck, got \(events[0])") + } + } + + // MARK: - Driver routing + func testDriverRoutesFdd1ToStepsAndReassemblesFdd3Replies() { let driver = CRPDriver(writer: nil) let steps = driver.ingest(Data([0x05, 0x00, 0x00]), from: fdd1) From eb5555204abee6178a44849c9f1438a9e5d194cb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 25 Jul 2026 02:55:06 -0700 Subject: [PATCH 3/6] feat(crp): port the R11 all-day history, sleep and wear-state decode from Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the iOS CRP driver up to the Android implementation that shipped in v1.0.0+30, so the R11 is no longer a connect-and-spot-measure-only device here. Protocol — the history opcodes were wrong. HR/SpO2/HRV/stress/sleep were being queried on group 7 (the device-info group); the ring answers every one of those empty. They live on group 2: sleep 14, HR 15, HRV 16, SpO2 17, stress 47, temp 48, each taking [day, frameIndex]. Same fix as Android's ea9855c. Decoder: - decodeTimingHistory: the all-day timeline. One 5-minute slot per sample, zero = no reading. HR/SpO2/stress are one byte per slot (144 slots/frame, terminal frame 1); HRV is little-endian two-byte (72 slots/frame, terminal frame 3). Slots anchor on LOCAL midnight of (today - day), so a Calendar is now threaded through decode(). Emits one .historyMeasurement per valid slot plus a .timingHistoryFrame cursor. - decodeSleep: vendor e1/j.b. [dayIndex] then 3-byte [state, hour, minute] records, each state running until the next record. Splits into separate timelines on an awake run >= SleepSegmentation.sessionGapMinutes so a nap doesn't merge into the night; short mid-night wakes stay inside their bout. - wear state (group 3 / cmd 7): onWearStateChange(payload[0] > 0). This is the signal that explained the R11 "measure broken" report on Android — an optical sensor with no skin contact cannot read. Also fixes group-1 vital results, which were switched on the enable-timing opcodes (7/8/39/13) rather than the result opcodes (10/11/14/32) the vendor dispatcher uses. HRV/SpO2/stress/temperature results were therefore never decoded, while an all-day config ack could be mistaken for a reading. The existing tests encoded the same mistake -- each one's comment named the right opcode while its code passed the wrong constant -- so they passed against the buggy decoder. Temperature also now uses the real two-byte layout ((p[1]<<8|p[0])/10) instead of treating a raw byte as celsius. Sync engine: force all-day monitoring on when no config is saved (a fresh ring ships with every monitor off and records nothing), pull the stored timelines on each startup pass, and walk each vital's frames to its terminal index with a duplicate-request guard. 763 tests pass, up from 738 with 2 failing: the startup test had never been updated for the firmware query added for zaggash's "Firmware: reading" report. Not yet hardware-validated on iOS -- the layouts are confirmed against zaggash's R11 captures via the Android implementation, not an iOS device. --- PulseLoop/RingProtocol/CRPDecoder.swift | 270 +++++++++++++++++++-- PulseLoop/RingProtocol/CRPProtocol.swift | 78 ++++-- PulseLoop/RingProtocol/CRPSyncEngine.swift | 89 +++++-- PulseLoop/RingProtocol/RingProtocol.swift | 12 + PulseLoopTests/CRPDecoderTests.swift | 256 ++++++++++++++++++- PulseLoopTests/CRPSyncEngineTests.swift | 111 ++++++++- 6 files changed, 743 insertions(+), 73 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index fdca559..a0c9124 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -48,15 +48,29 @@ final class CRPFrameAssembler { /// Other cmd values → command acknowledgment. enum CRPDecoder { - static func decode(_ data: Data, from characteristic: CBUUID, now: Date = Date()) -> [RingDecodedEvent] { + /// `calendar` resolves the ring's day-relative history (`day 0` = today) against the device's + /// **local** midnight, which is what the ring stamps against. Injectable so tests can pin a zone. + static func decode(_ data: Data, from characteristic: CBUUID, now: Date = Date(), + calendar: Calendar = .current) -> [RingDecodedEvent] { switch characteristic { case CRPUUIDs.stepsNotifyCBUUID: return decodeCurrentSteps(data, now: now) default: - return CRPProtocol.isFrameStart(data) ? decodeFramedReply(data, now: now) : [] + return CRPProtocol.isFrameStart(data) ? decodeFramedReply(data, now: now, calendar: calendar) : [] } } + /// All-day timeline frames carry sample slots at a fixed 5-minute cadence (`w0.b.a() / 5` in the + /// vendor). Two slot widths: HR/SpO2/stress store one byte per slot (144 slots/frame, terminal + /// frame index 1); HRV stores a little-endian 2-byte value per slot (72 slots/frame, terminal + /// index 3). Both reassemble to a 288-slot (24 h) day across their frames. + private static let timingSlotMinutes = 5 + private static let timingSlotsPerFrame1Byte = 144 + private static let timingSlotsPerFrame2Byte = 72 + /// `CRPHistoryDay` tops out at 14 days ago; a wilder value is a corrupt reply, not a real day. + private static let maxHistoryDay = 14 + private static let maxSleepMinutes = 24 * 60 + /// `fdd1` push — little-endian 3-byte triples: [steps][distance][calories]. From `e1/k.b`. /// distance is metres, calories kcal (vendor units). private static func decodeCurrentSteps(_ data: Data, now: Date) -> [RingDecodedEvent] { @@ -70,26 +84,55 @@ enum CRPDecoder { } /// Framed `fdd3` reply: `FD DA 10 `. - /// Real-time vital results come on group 1; history queries on group 7; device info on group 7. - private static func decodeFramedReply(_ frame: Data, now: Date) -> [RingDecodedEvent] { + /// Real-time vital results come on group 1; stored day history on group 2; device info on group 7; + /// power control + the autonomous wear-state push on group 3. + private static func decodeFramedReply(_ frame: Data, now: Date, calendar: Calendar) -> [RingDecodedEvent] { let b = [UInt8](frame) if b.count < CRPProtocol.headerSize { return [] } let group = Int(b[4]) let cmd = Int(b[5]) let payload = b.count > CRPProtocol.headerSize ? Array(b[CRPProtocol.headerSize.. [RingDecodedEvent] { + [.commandAck(commandId: UInt8(truncatingIfNeeded: (group << 4) | (cmd & 0x0F)))] + } // Group 1: real-time vital results (decompiled `g1/a.java` lines 664–712). if group == CRPCommands.groupDevice { return decodeVitalResult(cmd: cmd, payload: payload, now: now) } - // Group 7: history queries + device info (decompiled `b1/e0` + `b1/r`). + // Group 2: sleep + the all-day "timing" vital timelines + temperature history. + // cmd 14 → sleep (`e1/j`), confirmed against a hardware capture. + // cmd 15/16/17/47 → HR/HRV/SpO2/stress all-day timeline (`e1/{f,g,d,l}`), confirmed + // against zaggash's R11 capture (Android issue #29). + // cmd 48 → temperature history, still an ack until a non-empty capture pins it. + if group == CRPCommands.groupHistory { + if cmd == CRPCommands.cmdQueryHistorySleep { + return decodeSleep(payload, now: now, calendar: calendar) + } + if let timing = decodeTimingHistory(cmd: cmd, payload: payload, now: now, calendar: calendar) { + return timing + } + return ack() + } + + // Group 7: device info (decompiled `b1/r`). if group == CRPCommands.groupDeviceInfo { return decodeHistoryOrDeviceInfoResponse(cmd: cmd, payload: payload, now: now) } + // Group 3: power control + the autonomous wear-state push (`g1/a.java` case 3→7, + // `onWearStateChange(payload[0] > 0)`). Confirmed against zaggash's R11: a spot measure + // returns nothing while `payload[0] == 0` (ring off the finger). + if group == CRPCommands.groupPower { + if cmd == CRPCommands.cmdWearState, let first = payload.first { + return [.wearingStatus(worn: first != 0, timestamp: now)] + } + return ack() + } + // Unknown group/cmd — ack. - return [.commandAck(commandId: UInt8(truncatingIfNeeded: (group << 4) | (cmd & 0x0F)))] + return ack() } /// Decode group-1 vital result replies. Confirmed against `g1/a.java` and `e1/f.java` (HR), @@ -105,38 +148,32 @@ enum CRPDecoder { let value = Int(payload[0]) switch cmd { - case CRPCommands.cmdMeasureHR: + case CRPCommands.cmdResultHR: // HR from `e1/f.b()`: byte2int(payload[0]). guard value >= 40 && value <= 200 else { return [] } return [.heartRateSample(bpm: value, timestamp: now)] - case CRPCommands.cmdEnableTimingHRV: - // HRV from `e1/g.d()`: twoBytes2int(payload[1], payload[0]), but vendor's onHrv() - // callback receives byte2int(payload[0]) for the live measurement path. - // We accept either layout: single-byte if payload is 1 byte, two-byte otherwise. - let hrvValue: Int - if payload.count >= 2 { - hrvValue = Int(payload[0]) | (Int(payload[1]) << 8) - } else { - hrvValue = value - } - guard hrvValue >= 20 && hrvValue <= 200 else { return [] } - return [.hrvSample(value: hrvValue, timestamp: now)] + case CRPCommands.cmdResultHRV: + // HRV: the vendor's live `onHrv()` receives byte2int(payload[0]). + guard value >= 20 && value <= 200 else { return [] } + return [.hrvSample(value: value, timestamp: now)] - case CRPCommands.cmdEnableTimingSpO2: + case CRPCommands.cmdResultSpO2: // SpO2 from `e1/d.b()`: byte2int(payload[0]). guard value >= 70 && value <= 100 else { return [] } return [.spo2Result(value: value, timestamp: now)] - case CRPCommands.cmdEnableTimingStress: - // Stress/physical strength from `e1/h.c()`: byte2int(payload[0]). + case CRPCommands.cmdResultStress: + // Stress/physical strength: byte2int(payload[0]). guard value >= 0 && value <= 100 else { return [] } return [.stressSample(value: value, timestamp: now)] - case CRPCommands.cmdEnableTimingTemp: - // Temperature: vendor uses onMeasureComplete with payload. Layout unconfirmed. - // Emit as temperature_sample with raw byte as placeholder until verified. - return [.temperatureSample(celsius: Double(value), timestamp: now)] + case CRPCommands.cmdResultTemp: + // Vendor `e1/m.a(payload[1], payload[0])`: twoBytes2int / 10, valid 28.0…50.0 °C. + guard payload.count >= 2 else { return [] } + let celsius = Double((Int(payload[1]) << 8) | Int(payload[0])) / 10.0 + guard celsius >= 28.0 && celsius <= 50.0 else { return [] } + return [.temperatureSample(celsius: celsius, timestamp: now)] default: // Acknowledgment for enable/disable commands. @@ -144,6 +181,66 @@ enum CRPDecoder { } } + /// Decode a CRP all-day "timing" vital-history reply (group 2). Returns `nil` for a non-timing + /// group-2 cmd (e.g. temp cmd 48) so the caller falls back to an ack. Layout, confirmed against + /// zaggash's R11 capture and the vendor parsers `e1/{f,g,d,l}.java`: + /// `[day][frameIndex][slot samples…]` — one 5-minute slot per sample, `0` = no reading. + /// HR/SpO2/stress use one byte per slot; HRV a little-endian 2-byte value. Each slot's absolute + /// time is `localMidnight(today − day) + (frameIndex*slotsPerFrame + slot)*5min`, matching the + /// vendor's `w0.b.a()/5` slot indexing. Emits one `.historyMeasurement` per valid slot plus a + /// trailing `.timingHistoryFrame` that drives the engine's next-frame follow-up. + private static func decodeTimingHistory(cmd: Int, payload: [UInt8], now: Date, + calendar: Calendar) -> [RingDecodedEvent]? { + // (kind, sample byte-width, validity predicate) per vital. Ranges mirror the vendor clamps: + // HR 40…200 (`e1/f.e`), SpO2 1…100 (`e1/d.e`, >100→0), HRV any positive (`e1/g.d`, no clamp), + // stress 1…100 (`e1/l.d`, no clamp; 0 treated as no-reading). Zero is always "no sample". + let kind: MeasurementKind + let twoByte: Bool + let valid: (Int) -> Bool + switch cmd { + case CRPCommands.cmdQueryTimingHR: + kind = .heartRate; twoByte = false; valid = { $0 >= 40 && $0 <= 200 } + case CRPCommands.cmdQueryTimingSpO2: + kind = .spo2; twoByte = false; valid = { $0 >= 1 && $0 <= 100 } + case CRPCommands.cmdQueryTimingHRV: + kind = .hrv; twoByte = true; valid = { $0 >= 1 && $0 <= 300 } + case CRPCommands.cmdQueryTimingStress: + kind = .stress; twoByte = false; valid = { $0 >= 1 && $0 <= 100 } + default: + return nil + } + // [day][frameIndex] header; anything shorter is malformed. + if payload.count < 2 { return [] } + let day = Int(payload[0]) + let frameIndex = Int(payload[1]) + // A wilder day than CRPHistoryDay allows is a corrupt reply — ack without inventing samples. + if day > maxHistoryDay { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupHistory << 4) | (cmd & 0x0F)))] + } + guard let midnight = calendar.date(byAdding: .day, value: -day, to: calendar.startOfDay(for: now)) else { + return [] + } + + let slotsPerFrame = twoByte ? timingSlotsPerFrame2Byte : timingSlotsPerFrame1Byte + let step = twoByte ? 2 : 1 + var events: [RingDecodedEvent] = [] + var slot = 0 + var i = 2 + while i + step - 1 < payload.count { + let value = twoByte ? (Int(payload[i]) | (Int(payload[i + 1]) << 8)) : Int(payload[i]) + if valid(value) { + let globalSlot = frameIndex * slotsPerFrame + slot + let ts = midnight.addingTimeInterval(Double(globalSlot * timingSlotMinutes * 60)) + events.append(.historyMeasurement(kind: kind, value: Double(value), timestamp: ts)) + } + i += step + slot += 1 + } + // Drive the vendor's sequential next-frame pull (see `.timingHistoryFrame`). + events.append(.timingHistoryFrame(cmd: cmd, day: day, frameIndex: frameIndex)) + return events + } + /// Decode group-7 responses: history queries (cmd 4–7, 14, 48) and device info (cmd 0, 1, 13). /// History layouts are unconfirmed against hardware — emit as CommandAck so the raw-packet feed /// records them without inventing metric values. Extend `decodeHistoryOrDeviceInfoResponse` @@ -152,6 +249,127 @@ enum CRPDecoder { return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDeviceInfo << 4) | (cmd & 0x0F)))] } + private struct SleepTransition { + let elapsed: Int + let state: Int + } + + /// Decode a sleep-history reply (`group 2 / cmd 14`), a faithful port of the vendor parser + /// `e1/j.b` (Moyoung "Da Rings"). Layout: `[dayIndex]` then repeating 3-byte records + /// `[state, hour, minute]`, where a record marks the moment sleep entered `state` and that state + /// runs until the NEXT record's timestamp (state 0=awake, 1=light, 2=deep, 3=rem). The vendor + /// requires `length % 3 == 1` (one day byte + N whole records); anything else is malformed. + /// + /// Confirmed against a hardware capture (Android issue #29): a `dayIndex 0` reply of 26 records + /// decoded to a clean 01:07→08:05 night (245 light / 110 deep / 63 REM minutes). + /// + /// Emitted as `.sleepTimeline`s whose `stages` lists are one entry per minute, matching + /// `ColmiDecoder`'s sleep shape. A day's reply can hold more than one bout (a night plus a nap), + /// so we split at any awake run of `SleepSegmentation.sessionGapMinutes`+ — the same gap the + /// persistence layer uses to separate sessions. Short mid-night wakes stay inside their bout. + /// + /// Two deliberate departures from the vendor: + /// - The vendor extends the final record's state to the current wall-clock when it isn't awake + /// (an in-progress sleep). We don't — a completed night always ends on an awake record, so the + /// only case affected is a sync taken mid-sleep, where showing the night up to the last real + /// transition beats inventing minutes up to "now". + /// - Session-start anchoring is ours (the vendor keeps minute-of-day only and lets the UI place + /// the date from `dayIndex`). We anchor the FIRST record on the wake day (`today − dayIndex`) + /// with the same evening-rollover rule as Colmi — a first record later in the clock than the + /// last means the night began before midnight — then place later bouts by elapsed offset. + /// NOTE: assumes `dayIndex` is the WAKE day; verified against a post-midnight capture, but an + /// evening-start night is not yet capture-confirmed. + private static func decodeSleep(_ payload: [UInt8], now: Date, calendar: Calendar) -> [RingDecodedEvent] { + // [dayIndex] + N*[state,hour,minute]; the vendor rejects any other shape outright. + if payload.count < 4 || payload.count % 3 != 1 { return [] } + let dayIndex = Int(payload[0]) + if dayIndex > maxHistoryDay { return [] } + let recordCount = (payload.count - 1) / 3 + + // Pass 1: fold records into monotonic transition points — an elapsed-minute offset from the + // first valid record plus the state beginning there. Corrupt records are skipped without + // advancing the cursor, matching the vendor's `iA >= 0` guard. + var transitions: [SleepTransition] = [] + var firstMinuteOfDay = -1 + var lastMinuteOfDay = 0 + var elapsed = 0 + var prevHour = 0 + var prevMinute = 0 + for k in 0.. 23 || minute > 59 { continue } + if transitions.isEmpty { + firstMinuteOfDay = hour * 60 + minute + lastMinuteOfDay = firstMinuteOfDay + transitions.append(SleepTransition(elapsed: 0, state: state)) + } else { + let duration = sleepSegmentMinutes(prevHour: prevHour, prevMinute: prevMinute, + hour: hour, minute: minute) + if duration < 0 || duration > maxSleepMinutes { continue } + elapsed += duration + lastMinuteOfDay = hour * 60 + minute + transitions.append(SleepTransition(elapsed: elapsed, state: state)) + } + prevHour = hour + prevMinute = minute + } + if transitions.count < 2 { return [] } + + // Anchor the first record; every bout is then just an offset from it. + let startOffset = firstMinuteOfDay > lastMinuteOfDay ? firstMinuteOfDay - 1440 : firstMinuteOfDay + guard let wakeDayStart = calendar.date(byAdding: .day, value: -dayIndex, + to: calendar.startOfDay(for: now)) else { return [] } + let anchor = wakeDayStart.addingTimeInterval(Double(startOffset) * 60) + + // Pass 2: each transition's state runs until the next; split bouts on a long awake gap. + var events: [RingDecodedEvent] = [] + var boutStages: [SleepStage] = [] + var boutStartElapsed = 0 + for i in 0..<(transitions.count - 1) { + let segment = transitions[i] + let duration = transitions[i + 1].elapsed - segment.elapsed + if duration <= 0 { continue } + let stage = mapSleepState(segment.state) + if stage == .awake && duration >= SleepSegmentation.sessionGapMinutes { + emitSleepBout(into: &events, anchor: anchor, startElapsed: boutStartElapsed, stages: boutStages) + boutStages = [] + continue + } + if boutStages.isEmpty { boutStartElapsed = segment.elapsed } + boutStages.append(contentsOf: repeatElement(stage, count: duration)) + } + emitSleepBout(into: &events, anchor: anchor, startElapsed: boutStartElapsed, stages: boutStages) + return events + } + + /// Emit a bout as a `.sleepTimeline`, unless it holds no actual sleep (awake-only). + private static func emitSleepBout(into events: inout [RingDecodedEvent], anchor: Date, + startElapsed: Int, stages: [SleepStage]) { + if !stages.contains(where: { $0 != .awake }) { return } + events.append(.sleepTimeline(timestamp: anchor.addingTimeInterval(Double(startElapsed) * 60), + stages: stages)) + } + + /// Minutes from a previous `hh:mm` to this one, wrapping across midnight (vendor `e1/j.a`). + private static func sleepSegmentMinutes(prevHour: Int, prevMinute: Int, hour: Int, minute: Int) -> Int { + let wrappedHour = prevHour > hour ? hour + 24 : hour + return ((wrappedHour - prevHour) * 60 + minute) - prevMinute + } + + /// Vendor `e1/j.c` state codes → shared `SleepStage`. + private static func mapSleepState(_ state: Int) -> SleepStage { + switch state { + case 0: return .awake + case 1: return .light + case 2: return .deep + case 3: return .rem + default: return .unknown + } + } + /// Little-endian unsigned 3-byte int at `offset`. private static func le3(_ b: [UInt8], _ offset: Int) -> Int { Int(b[offset]) | (Int(b[offset + 1]) << 8) | (Int(b[offset + 2]) << 16) diff --git a/PulseLoop/RingProtocol/CRPProtocol.swift b/PulseLoop/RingProtocol/CRPProtocol.swift index 351cade..3827f2c 100644 --- a/PulseLoop/RingProtocol/CRPProtocol.swift +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -69,8 +69,21 @@ enum CRPCommands { static let groupDevice = 1 static let cmdSetUserInfo = 0 // b1/k.a: [height, weight, age, gender, strideLen] static let cmdSetTime = 1 // b1/e.b: [epochSecondsLE(4), tzByte] - static let cmdMeasureHR = 9 // b1/t.d: [enable] — start(1)/stop(0) continuous HR - static let cmdMeasureSpO2 = 11 // b1/h.d: [enable] — start(1)/stop(0) SpO2 + static let cmdMeasureHR = 9 // b1/t.d: q.c(1,9, [enable]) — start(1)/stop(0) continuous HR + static let cmdMeasureHRV = 10 // b1/u.d: q.c(1,10, [enable]) + static let cmdMeasureSpO2 = 11 // b1/h.d: q.c(1,11, [enable]) + static let cmdMeasureStress = 14 // b1/h0.d: q.c(1,14, [enable]) + static let cmdMeasureTemp = 32 // b1/i0.d: q.c(1,32, [enable]) + + // Group 1 — the ring answers a spot measure on the SAME cmd it was started with, so the + // result opcodes are aliases of the measure opcodes (vendor `g1/a.java` lines 664–712). + // These are deliberately NOT the `cmdEnableTiming*` values: a reply on 6/7/8/39/13 is the + // all-day config being acknowledged, not a reading. + static let cmdResultHR = cmdMeasureHR // g1/a: onHeartRate(e1/f.b → payload[0]) + static let cmdResultHRV = cmdMeasureHRV // g1/a: onHrv(byte2int(payload[0])) + static let cmdResultSpO2 = cmdMeasureSpO2 // g1/a: onBloodOxygen(e1/d.b → payload[0]) + static let cmdResultStress = cmdMeasureStress // g1/a: onStressChange(byte2int(payload[0])) + static let cmdResultTemp = cmdMeasureTemp // g1/a: onMeasureComplete(e1/m.a → (p[1]<<8|p[0])/10) // Group 1 — timing/enable controls (decompiled b1 package). // Disable: HR/HRV/SpO2/Stress use enable with interval=0. Temp uses a separate cmd. @@ -81,23 +94,33 @@ enum CRPCommands { static let cmdEnableTimingTemp = 13 // b1/i0.c: q.c(1,13, [true]) static let cmdDisableTimingTemp = 32 // b1/i0.d: q.c(1,32, [false]) - // Group 7 — history queries + device info (decompiled b1/e0 + b1/r). - // NOTE: History queries are group 7, NOT group 2 (the b1/e0 builders use q.b(7,…) and q.c(7,…)). + // Group 7 — device info only (decompiled b1/r). static let groupDeviceInfo = 7 static let cmdQueryDeviceInfo = 0 // b1/r.a: q.b(7,0) static let cmdQueryFirmwareVersion = 1 // b1/r.b: q.b(7,1) static let cmdQueryDeviceSN = 13 // b1/r.c: q.b(7,13) - static let cmdQueryHistoryHR = 4 // b1/e0.a: q.b(7,4) - static let cmdQueryHistoryStress = 5 // b1/e0.b: q.c(7,5, [interval]) - static let cmdQueryHistoryHRV = 6 // b1/e0.e: q.c(7,6, [interval]) - static let cmdQueryHistorySpO2 = 7 // b1/e0.f: q.b(7,7) - static let cmdQueryHistorySleep = 14 // b1/e0.c: q.c(2,14, [CRPHistoryDay]) - static let cmdQueryHistoryTemp = 48 // b1/e0.d: q.b(7,48) - // Group 3 — power control. + // Group 2 — stored day history. The all-day "timing" vital timelines and sleep live HERE, not + // on group 7: the earlier group-7 opcodes were the device-info group and the ring answered every + // one of them empty (Android issue #29, fixed in `ea9855c`). Confirmed against zaggash's R11 + // capture and the vendor `b1/{t,u,h,h0,e0}` builders. + static let groupHistory = 2 + static let cmdQueryHistorySleep = 14 // b1/e0.c: q.c(2,14, [CRPHistoryDay]) + static let cmdQueryTimingHR = 15 // b1/t.b: q.c(2,15, [day, frameIndex]) + static let cmdQueryTimingHRV = 16 // b1/u.b: q.c(2,16, [day, frameIndex]) + static let cmdQueryTimingSpO2 = 17 // b1/h.b: q.c(2,17, [day, frameIndex]) + static let cmdQueryTimingStress = 47 // b1/h0.b: q.c(2,47, [day, frameIndex]) + static let cmdQueryHistoryTemp = 48 // b1/e0.d: q.b(2,48) + static let historyDayToday = 0 // CRPHistoryDay.TODAY; YESTERDAY = 1 + + // Group 3 — power control + wear state. static let groupPower = 3 static let cmdFactoryReset = 0 // b1/l.v: q.b(3,0) static let cmdRestart = 1 // b1/l.w: q.b(3,1) + /// Autonomous push: `g1/a.java` decodes it as `onWearStateChange(payload[0] > 0)` — on-finger / + /// skin-contact detection. `[00]` = not worn, which is why an optical spot measure returns + /// nothing (Android issue #29 mis-diagnosis). + static let cmdWearState = 7 // Group 9 — device actions. static let groupAction = 9 @@ -230,29 +253,38 @@ enum CRPProtocol { frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdDisableTimingTemp) } - // MARK: - History query commands (group 7) - static func queryHistoryHeartRate() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryHR) + // MARK: - History query commands (group 2) + // Each all-day "timing" vital is pulled a frame at a time: `[day, frameIndex]`. The reply echoes + // both back (see `CRPDecoder.decodeTimingHistory`), and `CRPSyncEngine` walks frameIndex up to the + // vital's terminal frame — the vendor's sequential `insertBleMessage(.b(day, index + 1))`. + + static func queryTimingHeartRateHistory(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHR, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) } - static func queryHistoryStress() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryStress) + static func queryTimingHrvHistory(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHRV, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) } - static func queryHistoryHRV() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryHRV) + static func queryTimingSpO2History(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingSpO2, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) } - static func queryHistorySpO2() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistorySpO2) + static func queryTimingStressHistory(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingStress, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) } - static func queryHistorySleep(daysAgo: Int = 0) -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistorySleep, payload: [UInt8(truncatingIfNeeded: daysAgo)]) + static func queryHistorySleep(daysAgo: Int = CRPCommands.historyDayToday) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, + payload: [UInt8(truncatingIfNeeded: daysAgo)]) } static func queryHistoryTemp() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryHistoryTemp) + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistoryTemp) } // MARK: - Device info queries (group 7) diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index 0e6ddad..792eaf2 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -25,10 +25,18 @@ final class CRPSyncEngine: RingSyncEngine { private var profile: UserProfileValues? /// User-chosen all-day measurement config. Applied in the connect handshake and updatable - /// live via `applyMeasurementSettings`. `nil` ⇒ the user has never saved one, so the engine - /// skips the vital enable commands (the ring's own settings are the source of truth). + /// live via `applyMeasurementSettings`. `nil` ⇒ the user has never saved one; unlike QRing/YCBT + /// the CRP ring exposes no way to read back its own config, so a fresh R11 ships with every + /// all-day monitor OFF and never records anything to sync. We therefore fall back to + /// `MeasurementSettings.allOnDefault` (matching how `ColmiSyncEngine` force-enables on connect) + /// so the day timeline actually accumulates. private var measurementSettings: MeasurementSettings? + /// Frame follow-ups already requested this poll pass, keyed `cmd * 100 + frameIndex`, so a ring + /// that re-sends the same frame can't trigger a request storm. Cleared at the start of every + /// `queryAllHistory` pass so each sync re-pulls the full timeline. + private var requestedTimingFrames: Set = [] + init(writer: RingCommandWriter?) { self.writer = writer } @@ -40,22 +48,66 @@ final class CRPSyncEngine: RingSyncEngine { // Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report). send(CRPProtocol.queryFirmwareVersion()) if let profile { send(userInfoFrame(profile)) } - // Enable vital monitoring only when the user has configured it (mirrors the vendor app's - // connect flow). Uses the user's polling interval for all vital types — the CRP protocol - // takes a single interval byte per enable command, and MeasurementSettings only exposes - // hrIntervalMinutes (no per-vital intervals), so we share it across the board. - if let settings = measurementSettings { - if settings.hrEnabled { send(CRPProtocol.enableTimingHeartRate(intervalMinutes: settings.hrIntervalMinutes)) } - if settings.hrvEnabled { send(CRPProtocol.enableTimingHRV(intervalMinutes: settings.hrIntervalMinutes)) } - if settings.stressEnabled { send(CRPProtocol.enableTimingStress(intervalMinutes: settings.hrIntervalMinutes)) } - if settings.spo2Enabled { send(CRPProtocol.enableTimingSpO2(intervalMinutes: settings.hrIntervalMinutes)) } - if settings.temperatureEnabled { send(CRPProtocol.enableTimingTemp()) } + // Enable all-day vital monitoring. A fresh ring has these OFF, so without this the ring + // stores no HR/SpO2/HRV/stress/temperature history and every history query below returns an + // empty reply (Android issue #29, zaggash's full-day capture). When the user has saved a + // config we honour it exactly (interval included); until then we fall back to allOnDefault. + applyTimingSettings(measurementSettings ?? .allOnDefault) + // Pull the day's stored all-day timeline. runStartup() IS the poll pass (the background sync + // and a foreground sync both re-invoke it), so this runs at the app's configured cadence; the + // ring samples at hrIntervalMinutes (above). The ring only emits history replies once asked. + queryAllHistory() + } + + /// Request the stored all-day timelines the ring has accumulated: the group-2 "timing" vital + /// timelines (HR/SpO2/HRV/stress), temperature, and sleep. Vendor `u3/g1.java` fires the same set + /// on its sync pass. Each timing query pulls frame 0; the reply drives `handle` to pull the next + /// frame until the day is complete. + private func queryAllHistory() { + requestedTimingFrames.removeAll() + send(CRPProtocol.queryTimingHeartRateHistory()) + send(CRPProtocol.queryTimingSpO2History()) + send(CRPProtocol.queryTimingHrvHistory()) + send(CRPProtocol.queryTimingStressHistory()) + send(CRPProtocol.queryHistoryTemp()) + send(CRPProtocol.queryHistorySleep()) + } + + /// The last frame index each timing vital emits before its day is complete (vendor terminal + /// index: HR/SpO2/stress finalize at frame 1 — two 144-slot frames; HRV at frame 3 — four + /// 72-slot frames). A reply below this index triggers a pull of the next frame. + private func terminalFrameIndex(cmd: Int) -> Int { + cmd == CRPCommands.cmdQueryTimingHRV ? 3 : 1 + } + + /// Build the next-frame query for a timing vital, or `nil` for a non-timing cmd. + private func timingQuery(cmd: Int, day: Int, frameIndex: Int) -> Data? { + switch cmd { + case CRPCommands.cmdQueryTimingHR: + return CRPProtocol.queryTimingHeartRateHistory(day: day, frameIndex: frameIndex) + case CRPCommands.cmdQueryTimingHRV: + return CRPProtocol.queryTimingHrvHistory(day: day, frameIndex: frameIndex) + case CRPCommands.cmdQueryTimingSpO2: + return CRPProtocol.queryTimingSpO2History(day: day, frameIndex: frameIndex) + case CRPCommands.cmdQueryTimingStress: + return CRPProtocol.queryTimingStressHistory(day: day, frameIndex: frameIndex) + default: + return nil } } func handle(_ event: RingDecodedEvent) { - // Steps/HR/battery are persisted by RingBLEClient via RingEventBridge; v1 keeps no engine-side - // state (no staged history pipeline to advance). + // Steps/HR/battery are persisted by RingBLEClient via RingEventBridge. The one piece of + // engine-side state is the all-day timeline's multi-frame pull: on each timing-history frame + // the ring returns, request the next frame until the vital's terminal index — the vendor's + // sequential `insertBleMessage(.b(day, index + 1))` (`e1/{f,d,g,l}.java`). The samples + // themselves are decoded + persisted via the bridge; this only advances the cursor. + guard case let .timingHistoryFrame(cmd, day, frameIndex) = event else { return } + if frameIndex >= terminalFrameIndex(cmd: cmd) { return } + let nextIndex = frameIndex + 1 + // Guard against a ring that re-sends the same frame spamming duplicate follow-ups. + guard requestedTimingFrames.insert(cmd * 100 + nextIndex).inserted else { return } + send(timingQuery(cmd: cmd, day: day, frameIndex: nextIndex)) } // MARK: - Heart rate (standard 2a37 stream, started/stopped via the fdda command channel) @@ -87,7 +139,14 @@ final class CRPSyncEngine: RingSyncEngine { func applyMeasurementSettings(_ settings: MeasurementSettings) { measurementSettings = settings - // Re-send vital enable/disable commands with the updated settings. + applyTimingSettings(settings) + } + + /// Send the all-day enable/disable command for every vital. The CRP protocol takes a single + /// interval byte per enable, and `MeasurementSettings` carries only `hrIntervalMinutes` (no + /// per-vital cadence), so the HR interval is shared across the board. Disabled vitals are + /// explicitly turned off so a reconnect can't leave a previously-enabled monitor running. + private func applyTimingSettings(_ settings: MeasurementSettings) { if settings.hrEnabled { send(CRPProtocol.enableTimingHeartRate(intervalMinutes: settings.hrIntervalMinutes)) } else { send(CRPProtocol.disableTimingHeartRate()) } if settings.hrvEnabled { send(CRPProtocol.enableTimingHRV(intervalMinutes: settings.hrIntervalMinutes)) } diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index c1b8f8b..33ab66c 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -150,6 +150,15 @@ enum RingDecodedEvent: Sendable { /// the owner's R99 refuses HRV (mode `0x0a` → status `0x01`), and without this the app polls a ring /// that already said no for the full 45-second window before reporting a generic failure. case measurementRejected(mode: UInt8) + /// One frame of a CRP all-day "timing" vital timeline just landed. The ring returns a day in + /// fixed-size frames and only sends the next one when asked, so `CRPSyncEngine.handle` uses this + /// as a cursor: request `frameIndex + 1` until the vital's terminal frame (the vendor's sequential + /// `insertBleMessage(.b(day, index + 1))` in `e1/{f,d,g,l}.java`). `cmd` identifies the + /// vital, `day` is 0 = today. + /// + /// Produces no `PulseEvent` — the samples themselves arrive as separate `.historyMeasurement` + /// events; this only advances the cursor. + case timingHistoryFrame(cmd: Int, day: Int, frameIndex: Int) case timeSyncAck(timestamp: Date) case commandAck(commandId: UInt8) case unknown(commandId: UInt8, raw: Data) @@ -182,6 +191,7 @@ enum RingDecodedEvent: Sendable { case .chipScheme: return "chip_scheme" case .wearingStatus: return "wearing_status" case .measurementRejected: return "measurement_rejected" + case .timingHistoryFrame: return "timing_history_frame" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" case .unknown: return "unknown" @@ -240,6 +250,8 @@ enum RingDecodedEvent: Sendable { return #"{"worn":\#(worn)}"# case let .measurementRejected(mode): return #"{"rejected_mode":\#(mode)}"# + case let .timingHistoryFrame(cmd, day, frameIndex): + return #"{"cmd":\#(cmd),"day":\#(day),"frameIndex":\#(frameIndex)}"# case let .historySyncProgress(stage): return #"{"stage":"\#(stage)"}"# case let .battery(percent): diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift index 84f418b..767dab1 100644 --- a/PulseLoopTests/CRPDecoderTests.swift +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -89,8 +89,9 @@ final class CRPDecoderTests: XCTestCase { } func testGroup1Cmd10DecodesHRV() { - // HRV response: group1/cmd10, payload[0]=45 → 45 ms - let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingHRV, payload: [0x2D]) + // HRV response: group1/cmd10, payload[0]=45 → 45 ms. The RESULT opcode (10), not the + // enable-timing opcode (7) — a reply on 7 is the all-day config being acked, not a reading. + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdResultHRV, payload: [0x2D]) let events = CRPDecoder.decode(frame, from: fdd3) XCTAssertEqual(events.count, 1) guard case let .hrvSample(value, _) = events[0] else { @@ -101,7 +102,7 @@ final class CRPDecoderTests: XCTestCase { func testGroup1Cmd11DecodesSpO2() { // SpO2 response: group1/cmd11, payload[0]=96 → 96% - let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingSpO2, payload: [0x60]) + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdResultSpO2, payload: [0x60]) let events = CRPDecoder.decode(frame, from: fdd3) XCTAssertEqual(events.count, 1) guard case let .spo2Result(value, _) = events[0] else { @@ -112,7 +113,7 @@ final class CRPDecoderTests: XCTestCase { func testGroup1Cmd14DecodesStress() { // Stress response: group1/cmd14, payload[0]=42 → stress 42 - let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingStress, payload: [0x2A]) + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdResultStress, payload: [0x2A]) let events = CRPDecoder.decode(frame, from: fdd3) XCTAssertEqual(events.count, 1) guard case let .stressSample(value, _) = events[0] else { @@ -122,14 +123,36 @@ final class CRPDecoderTests: XCTestCase { } func testGroup1Cmd32DecodesTemperature() { - // Temp response: group1/cmd32, payload[0]=38 → 38 °C (placeholder layout) - let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdEnableTimingTemp, payload: [0x26]) + // Temp response: group1/cmd32. Vendor `e1/m.a(payload[1], payload[0])` is + // twoBytes2int / 10, so 36.5 °C arrives as 365 = 0x016D little-endian. + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdResultTemp, payload: [0x6D, 0x01]) let events = CRPDecoder.decode(frame, from: fdd3) XCTAssertEqual(events.count, 1) guard case let .temperatureSample(celsius, _) = events[0] else { return XCTFail("expected temperatureSample, got \(events[0])") } - XCTAssertEqual(celsius, 38.0) + XCTAssertEqual(celsius, 36.5, accuracy: 0.001) + } + + /// A single-byte temperature payload is not the vendor layout — reject rather than + /// mis-scale it by 10x (the old placeholder decoded `[0x26]` as 38 °C). + func testGroup1Cmd32RejectsShortTemperaturePayload() { + let frame = CRPProtocol.frame(group: 1, cmd: CRPCommands.cmdResultTemp, payload: [0x26]) + XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) + } + + /// A reply on an enable-timing opcode is the all-day config being acknowledged. It must NOT + /// decode as a reading — that conflation is what made the interval byte look like a vital. + func testEnableTimingRepliesAreAcksNotReadings() { + for cmd in [CRPCommands.cmdEnableTimingHRV, CRPCommands.cmdEnableTimingSpO2, + CRPCommands.cmdEnableTimingStress, CRPCommands.cmdEnableTimingHR] { + let frame = CRPProtocol.frame(group: 1, cmd: cmd, payload: [0x05]) // interval = 5 min + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case .commandAck = events[0] else { + return XCTFail("cmd \(cmd) should ack, got \(events[0])") + } + } } func testGroup1UnknownCmdReturnsCommandAck() { @@ -155,4 +178,223 @@ final class CRPDecoderTests: XCTestCase { XCTAssertTrue(driver.ingest(Data(full.prefix(4)), from: fdd3).isEmpty) XCTAssertEqual(driver.ingest(Data(full.suffix(3)), from: fdd3).count, 1) } + + // MARK: - Wear state (group 3 / cmd 7) + + /// `g1/a.java` decodes group3/cmd7 as `onWearStateChange(payload[0] > 0)`. `[00]` = not worn, + /// which is why an optical spot measure returns nothing (Android issue #29). + func testWearStateDecodesBothPolarities() { + for (byte, expected) in [(UInt8(0x00), false), (UInt8(0x01), true)] { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdWearState, payload: [byte]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case let .wearingStatus(worn, _) = events[0] else { + return XCTFail("expected wearingStatus, got \(events[0])") + } + XCTAssertEqual(worn, expected) + } + } + + /// Other group-3 commands (factory reset, restart) stay acks — only cmd 7 is wear state. + func testOtherGroup3CommandsRemainAcks() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdFactoryReset) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + + // MARK: - All-day "timing" vital history (group 2) + + /// A UTC calendar keeps slot maths independent of the machine's zone: the ring stamps history + /// against LOCAL midnight, so the decoder must anchor on the injected calendar's day start. + private var utcCalendar: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + } + + /// HR is one byte per 5-minute slot. Slot n of frame 0 lands at localMidnight + n*5min, and + /// zero means "no reading" rather than a real zero-bpm sample. + func testTimingHeartRateHistoryDecodesOneBytePerFiveMinuteSlot() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cal = utcCalendar + // [day=0][frame=0][slot0=60][slot1=0 (no reading)][slot2=61] + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingHR, + payload: [0, 0, 60, 0, 61]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: cal) + + let samples = events.compactMap { event -> (MeasurementKind, Double, Date)? in + guard case let .historyMeasurement(kind, value, ts) = event else { return nil } + return (kind, value, ts) + } + XCTAssertEqual(samples.count, 2, "the zero slot must be dropped") + let midnight = cal.startOfDay(for: now) + XCTAssertEqual(samples[0].0, .heartRate) + XCTAssertEqual(samples[0].1, 60) + XCTAssertEqual(samples[0].2, midnight) + XCTAssertEqual(samples[1].1, 61) + XCTAssertEqual(samples[1].2, midnight.addingTimeInterval(10 * 60), "slot 2 = +10 min") + } + + /// HRV is a little-endian TWO-byte value per slot with 72 slots/frame, so frame 1's first slot + /// is global slot 72 — not 144 as it would be for the one-byte vitals. + func testTimingHrvHistoryIsTwoByteAndUsesSeventyTwoSlotFrames() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cal = utcCalendar + // [day=0][frame=1][slot0 = 0x012C = 300] + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingHRV, + payload: [0, 1, 0x2C, 0x01]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: cal) + guard case let .historyMeasurement(kind, value, ts) = events[0] else { + return XCTFail("expected historyMeasurement, got \(events[0])") + } + XCTAssertEqual(kind, .hrv) + XCTAssertEqual(value, 300) + XCTAssertEqual(ts, cal.startOfDay(for: now).addingTimeInterval(72 * 5 * 60)) + } + + /// `day` counts back from today in whole LOCAL days. + func testTimingHistoryAnchorsOnTheRequestedDay() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cal = utcCalendar + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingHR, + payload: [2, 0, 60]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: cal) + guard case let .historyMeasurement(_, _, ts) = events[0] else { + return XCTFail("expected historyMeasurement") + } + let expected = cal.date(byAdding: .day, value: -2, to: cal.startOfDay(for: now))! + XCTAssertEqual(ts, expected) + } + + /// Every timing reply ends with the cursor the sync engine walks. + func testTimingHistoryEmitsFrameMarkerForFollowUp() { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingStress, + payload: [0, 1, 40]) + let events = CRPDecoder.decode(frame, from: fdd3) + guard case let .timingHistoryFrame(cmd, day, frameIndex) = events.last else { + return XCTFail("expected trailing timingHistoryFrame, got \(String(describing: events.last))") + } + XCTAssertEqual(cmd, CRPCommands.cmdQueryTimingStress) + XCTAssertEqual(day, 0) + XCTAssertEqual(frameIndex, 1) + } + + /// Out-of-range values are the vendor's per-vital clamps, not real samples. + func testTimingHistoryDropsOutOfRangeSamples() { + // HR clamp is 40…200: 30 and 250 are both noise, 80 is real. + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingHR, + payload: [0, 0, 30, 250, 80]) + let events = CRPDecoder.decode(frame, from: fdd3) + let samples = events.filter { if case .historyMeasurement = $0 { return true } else { return false } } + XCTAssertEqual(samples.count, 1) + } + + /// A day beyond CRPHistoryDay's 14-day window is a corrupt reply — ack, don't invent samples. + func testTimingHistoryRejectsImplausibleDay() { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryTimingHR, + payload: [200, 0, 60]) + let events = CRPDecoder.decode(frame, from: fdd3) + XCTAssertEqual(events.count, 1) + guard case .commandAck = events[0] else { + return XCTFail("expected commandAck, got \(events[0])") + } + } + + /// Temperature history (cmd 48) has no confirmed layout yet — it must stay an ack. + func testTemperatureHistoryStaysAnAck() { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistoryTemp, payload: [0, 0, 1, 2]) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + + // MARK: - Sleep (group 2 / cmd 14) + + /// Vendor `e1/j.b`: `[dayIndex]` then 3-byte `[state, hour, minute]` records, each marking the + /// moment that state BEGINS and running until the next record. + func testSleepDecodesStagesOnePerMinute() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cal = utcCalendar + // 01:00 light (60 min) → 02:00 deep (30 min) → 02:30 awake (ends the night) + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 1, 1, 0, 2, 2, 0, 0, 2, 30]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: cal) + XCTAssertEqual(events.count, 1) + guard case let .sleepTimeline(ts, stages) = events[0] else { + return XCTFail("expected sleepTimeline, got \(events[0])") + } + XCTAssertEqual(stages.count, 90, "60 light + 30 deep, one entry per minute") + XCTAssertEqual(stages.prefix(60).filter { $0 == .light }.count, 60) + XCTAssertEqual(stages.suffix(30).filter { $0 == .deep }.count, 30) + XCTAssertEqual(ts, cal.startOfDay(for: now).addingTimeInterval(60 * 60), "anchored at 01:00") + } + + /// A day can hold a night plus a nap; an awake run of >= the session gap splits them. + func testSleepSplitsBoutsOnALongAwakeGap() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // 01:00 light 60m → 02:00 awake 180m → 05:00 light 30m → 05:30 awake + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 1, 1, 0, 0, 2, 0, 1, 5, 0, 0, 5, 30]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: utcCalendar) + XCTAssertEqual(events.count, 2, "a >=60-minute awake run separates the bouts") + } + + /// A short mid-night wake stays inside its bout as awake minutes. + func testSleepKeepsShortWakesInsideTheBout() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // 01:00 light 60m → 02:00 awake 10m → 02:10 light 30m → 02:40 awake + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 1, 1, 0, 0, 2, 0, 1, 2, 10, 0, 2, 40]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: utcCalendar) + XCTAssertEqual(events.count, 1) + guard case let .sleepTimeline(_, stages) = events[0] else { return XCTFail("expected sleepTimeline") } + XCTAssertEqual(stages.count, 100, "60 light + 10 awake + 30 light") + XCTAssertEqual(stages.filter { $0 == .awake }.count, 10) + } + + /// The vendor requires `length % 3 == 1` (one day byte + whole records). + func testSleepRejectsMalformedPayloadLength() { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 1, 1, 0, 2]) // 5 bytes → 5 % 3 == 2 + XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) + } + + /// An awake-only reply carries no sleep, so it must produce no timeline at all. + func testSleepEmitsNothingWhenNoActualSleep() { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 0, 1, 0, 0, 2, 0]) + XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) + } + + /// A night that starts before midnight: the first record reads later on the clock than the last, + /// so the anchor rolls back a day rather than placing the night in the wrong evening. + func testSleepAnchorsAnEveningStartBeforeMidnight() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cal = utcCalendar + // 23:00 light 120m → 01:00 deep 60m → 02:00 awake + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQueryHistorySleep, + payload: [0, 1, 23, 0, 2, 1, 0, 0, 2, 0]) + let events = CRPDecoder.decode(frame, from: fdd3, now: now, calendar: cal) + guard case let .sleepTimeline(ts, stages) = events.first else { + return XCTFail("expected sleepTimeline") + } + XCTAssertEqual(stages.count, 180) + // 23:00 the previous evening = wake-day midnight minus 60 minutes. + XCTAssertEqual(ts, cal.startOfDay(for: now).addingTimeInterval(-60 * 60)) + } } diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 4214ebc..25e9b0a 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -15,16 +15,20 @@ final class CRPSyncEngineTests: XCTestCase { func payloadByte(_ frame: Int, _ index: Int) -> Int { Int([UInt8](sent[frame])[index]) } } + /// The connect handshake's leading commands, in order: set-time, firmware query, then user info + /// once a profile exists. Everything after that is the all-day timing config plus the history + /// pull, covered by their own tests below. func testRunStartupSendsSetTimeThenUserInfoOnceAProfileIsStored() { let w = FakeWriter() let engine = CRPSyncEngine(writer: w) engine.runStartup() - XCTAssertEqual(w.opcodes, [[1, 1]]) // set-time only, no profile yet + // set-time, then the firmware query that keeps the UI off "Firmware: reading". + XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [7, 1]]) w.sent.removeAll() engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) engine.runStartup() - XCTAssertEqual(w.opcodes, [[1, 1], [1, 0]]) // set-time then set-user-info + XCTAssertEqual(Array(w.opcodes.prefix(3)), [[1, 1], [7, 1], [1, 0]]) } func testHeartRateStartAndStopEnqueueGroup1Cmd9() { @@ -53,4 +57,107 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertEqual(w.payloadByte(0, 6), 165) XCTAssertEqual(w.payloadByte(0, 10), Int(165.0 * 0.43)) // 70 } + + // MARK: - All-day monitoring + history pull + + /// A fresh R11 ships with every all-day monitor OFF and cannot be asked what its config is, so + /// connecting without a saved config must still force them on — otherwise the ring records + /// nothing and every history query comes back empty (Android issue #29). + func testRunStartupForcesAllDayMonitoringOnWithoutASavedConfig() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + // Enable-timing opcodes are group 1: HR 6, HRV 7, SpO2 8, stress 39, temp 13. + for cmd in [6, 7, 8, 39, 13] { + XCTAssertTrue(w.opcodes.contains([1, cmd]), "expected all-day enable for group1/cmd\(cmd)") + } + } + + /// The history pull uses the group-2 opcodes. The old group-7 ones were the device-info group + /// and the ring answered every one of them empty. + func testRunStartupQueriesHistoryOnGroupTwo() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + for cmd in [15, 17, 16, 47, 48, 14] { // HR, SpO2, HRV, stress, temp, sleep + XCTAssertTrue(w.opcodes.contains([2, cmd]), "expected group2/cmd\(cmd) history query") + } + XCTAssertFalse(w.opcodes.contains { $0[0] == 7 && $0[1] != 1 }, + "group 7 should carry only the firmware query now") + } + + /// Each timing query starts at frame 0 of today. + func testHistoryQueriesStartAtTodayFrameZero() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + guard let index = w.opcodes.firstIndex(of: [2, 15]) else { return XCTFail("no HR history query") } + XCTAssertEqual(w.payloadByte(index, 6), 0) // day = today + XCTAssertEqual(w.payloadByte(index, 7), 0) // frameIndex = 0 + } + + /// A frame below the vital's terminal index pulls the next one — the vendor's sequential + /// `insertBleMessage(.b(day, index + 1))`. + func testTimingFrameBelowTerminalIndexRequestsTheNextFrame() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + XCTAssertEqual(w.opcodes, [[2, 15]]) + XCTAssertEqual(w.payloadByte(0, 7), 1, "should ask for frame 1") + } + + /// HR/SpO2/stress finish at frame 1 (two 144-slot frames); HRV runs to frame 3 (four 72-slot). + func testTerminalFrameIndexEndsTheWalkPerVital() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 1)) + XCTAssertTrue(w.sent.isEmpty, "HR terminates at frame 1") + + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 16, day: 0, frameIndex: 1)) + XCTAssertEqual(w.opcodes, [[2, 16]], "HRV continues past frame 1") + + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 16, day: 0, frameIndex: 3)) + XCTAssertTrue(w.sent.isEmpty, "HRV terminates at frame 3") + } + + /// A ring that re-sends the same frame must not trigger a request storm. + func testDuplicateFrameDoesNotRequestTwice() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + XCTAssertEqual(w.sent.count, 1) + } + + /// Each sync pass re-pulls the whole timeline, so the dedupe guard resets on every startup. + func testFollowUpGuardResetsEachSyncPass() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + engine.runStartup() + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + XCTAssertEqual(w.sent.count, 1, "a new pass may re-request frame 1") + } + + /// A non-timing event must not be mistaken for a history cursor. + func testNonTimingEventsAreIgnoredByHandle() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + w.sent.removeAll() + engine.handle(.heartRateSample(bpm: 60, timestamp: Date())) + engine.handle(.wearingStatus(worn: false, timestamp: Date())) + XCTAssertTrue(w.sent.isEmpty) + } } From 3091e704c9467b7cc9d6cc88060fd19fd8b5e86a Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 25 Jul 2026 02:59:10 -0700 Subject: [PATCH 4/6] feat(crp): advertise manualSpo2 now that cmd-11 results decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability was withheld with the note "result parsing deferred, so capability isn't advertised". That premise no longer holds: group-1 cmd 11 decodes into .spo2Result, and startSpO2/stopSpO2 already send the confirmed b1/h.d start/stop commands. .manualSpo2 is what surfaces the SpO2 "Measure now" button in Vitals, so without it the R11 could take a spot SpO2 reading but the user had no way to ask for one. Android's CRPCoordinator has claimed MANUAL_SPO2 all along. Also drops the stale "history sync and sleep are still deferred" note — both are decoded now; they just aren't capability-gated. --- PulseLoop/RingProtocol/CRPCoordinator.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPCoordinator.swift b/PulseLoop/RingProtocol/CRPCoordinator.swift index 6961778..1e9f385 100644 --- a/PulseLoop/RingProtocol/CRPCoordinator.swift +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -32,13 +32,21 @@ final class CRPCoordinator: WearableCoordinator { /// Real-time vital capabilities backed by decoded group-1 replies (`g1/a.java` lines 664–712): /// HR (cmd 9), HRV (cmd 10), SpO2 (cmd 11), stress (cmd 14), temperature (cmd 32). - /// History sync and sleep are still deferred — their group-7 reply layouts aren't confirmed - /// against hardware yet. Steps push (`fdd1`), battery (`2a19`), find-device also confirmed. - /// Note: HR does NOT use the standard `2a37` characteristic on CRP rings — all vital results - /// come back as framed replies on `fdd3` group 1. + /// + /// The stored day timelines are decoded too: sleep (group-2/cmd-14) and the all-day "timing" + /// vital histories (HR/SpO2/HRV/stress, group-2/cmd 15/16/17/47) — see `CRPDecoder`. They are + /// pulled by `CRPSyncEngine.runStartup` and persisted through the event bridge; no capability + /// bit gates them, so none is claimed here. + /// + /// `manualSpo2` is claimed alongside `manualHeartRate`: both surface a "Measure now" button in + /// Vitals, the start/stop commands are confirmed (`b1/h.d`), and cmd-11 results now decode. + /// + /// Steps push (`fdd1`), battery (`2a19`), find-device also confirmed. Note: HR does NOT use the + /// standard `2a37` characteristic on CRP rings — all vital results come back as framed replies + /// on `fdd3` group 1. let capabilities: Set = [ .steps, .realtimeSteps, - .heartRate, .realtimeHeartRate, .manualHeartRate, + .heartRate, .realtimeHeartRate, .manualHeartRate, .manualSpo2, .spo2, .stress, .hrv, .temperature, .battery, .findDevice, From c396b12fbcb309e967e1a67c8a6fb1f511b93f4d Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 25 Jul 2026 03:08:07 -0700 Subject: [PATCH 5/6] feat(crp): fast-fail a not-worn spot measure instead of idling the full window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wear state was decoded but consumed by nothing, so a measure taken with the ring off the finger spun its entire window and then blamed the user's stillness. An optical sensor with no skin contact cannot read at all — stillness is the wrong thing to fix, and 30 seconds is a long time to wait to be told the wrong thing. - PulseEvent.wearState(worn:) and the bridge mapping for .wearingStatus. The bridge fans out unconditionally; RingSyncCoordinator gates on .crp, because only CRP's polarity is hardware-confirmed. That moves the guard on YCBT's unverified polarity from the bridge to the coordinator rather than dropping it — a wrong guess still cannot reach the UI. - RingSyncCoordinator.measureNotWorn, set when the not-worn push arrives while a measure is in flight AND before any reading has landed, so a wear-state drop right after a good reading can't turn a success into a failure. HR reuses the existing hrNoReadingReported abort; SpO2 gets spo2NotWornReported, since SpO2 has no "complete with no reading" reply to key off. - The measurement sheet swaps its steadiness copy for "The ring isn't detecting your finger. Put it on snugly, then try again." One message for every kind: the fix doesn't vary by vital, and naming the vital would bury the instruction that matters. Ports Android's behaviour from the R11 wear-state work, matching its gating and its "only before a reading" rule. 765 tests pass. One existing YCBT assertion changed on purpose: it asserted wear state stays out of the fan-out because "nothing in the app gates on wear state yet", which is no longer true. --- PulseLoop/Events/PulseEventBus.swift | 9 +++++- PulseLoop/RingProtocol/RingEventBridge.swift | 5 +++ PulseLoop/Services/RingSyncCoordinator.swift | 31 ++++++++++++++++++- .../Views/MeasurementKindPresentation.swift | 7 +++++ PulseLoop/Views/MeasurementModal.swift | 3 ++ PulseLoopTests/EventBridgeTests.swift | 21 +++++++++++++ PulseLoopTests/YCBTDecoderTests.swift | 7 +++-- 7 files changed, 79 insertions(+), 4 deletions(-) diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 5bf4598..cc9b44f 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -36,6 +36,11 @@ enum PulseEvent: Sendable { case fatigueSample(value: Int, timestamp: Date) case bloodSugarSample(mgdl: Double, timestamp: Date) /// Firmware version string parsed from the ring's status/firmware payload; persisted on the Device. + /// The ring reported whether it is on the finger (CRP group-3/cmd-7 `onWearStateChange`). + /// `RingSyncCoordinator` uses `worn == false` to fast-fail an in-flight spot measure: an optical + /// sensor with no skin contact cannot read, so idling out the full window only wastes the user's + /// time. Not persisted — it is a live condition, not data. + case wearState(worn: Bool) case firmwareVersion(String) /// Friendly history-sync progress for the product UI (e.g. "Syncing sleep…"). Never protocol terms. case syncProgress(stage: String) @@ -344,7 +349,9 @@ final class EventPersistenceSubscriber { // The rows are committed by now; the next sync re-checks against the database. seenHistoryKeys.removeAll(keepingCapacity: true) } - case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused, .workoutResumed, .workoutFinished, .coachTrace: + // `.wearState` is a live condition the measurement flow reacts to, not data — nothing to store. + case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused, + .workoutResumed, .workoutFinished, .coachTrace, .wearState: break } // NB: no per-event save here — `scheduleFlush()` (called by `persist`) batches the save. diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index 3d91422..e67817d 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -114,6 +114,11 @@ enum RingEventBridge { guard (0...100).contains(percent) else { return [] } return [.batteryLevel(percent: percent)] + case let .wearingStatus(worn, _): + // Fanned out unconditionally; `RingSyncCoordinator` is what gates on family, because only + // CRP's polarity is hardware-confirmed (see `RingDecodedEvent.wearingStatus`). + return [.wearState(worn: worn)] + case let .status(address): // The status reply carries the ring's embedded address; surface it (and refresh // last-sync) by re-asserting the connected state with the address attached. diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index d760976..d1f8648 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -223,6 +223,14 @@ final class RingSyncCoordinator { /// Set when the ring reports a completed HR measurement with no usable reading (not worn), so a /// spot measurement can fail fast instead of waiting out the full window. private var hrNoReadingReported = false + /// The ring told us it isn't on the finger during a spot measure (CRP wear-state push). Read by + /// the Vitals sheet to show "put the ring on" instead of the generic steadiness hint. Set only + /// when the not-worn signal arrives *before* any reading, so a wear-state drop right after a good + /// reading can't turn a success into a failure. + private(set) var measureNotWorn = false + /// SpO2's counterpart to `hrNoReadingReported`: SpO2 has no "complete with no reading" reply, so + /// the wear-state push is the only thing that can abort it early. + private var spo2NotWornReported = false /// The samples of the HR measurement in flight, and the rule for whether they settled — see /// `HRSampleWindow`, which owns the warm-up echo and the consistency gate. private var hrWindow = HRSampleWindow() @@ -482,6 +490,7 @@ final class RingSyncCoordinator { // NOTE: do *not* clear `latestHRValue` — it's the live value the workout UI shows, so a new // measurement keeps the last reading on screen until a fresh one replaces it (no blanking to —). hrNoReadingReported = false + measureNotWorn = false hrWindow.begin() // Spot reading: the engine picks the right command (jring live stream / Colmi manual 0x69 // continuous stream). Always stop the stream when we're done so the ring doesn't keep measuring. @@ -534,12 +543,15 @@ final class RingSyncCoordinator { guard client.state == .connected else { spo2State = .failed; return nil } spo2State = .measuring latestSpO2Value = nil + measureNotWorn = false + spo2NotWornReported = false let token = spot.begin(mode: YCBTMeasurementMode.spo2) engine?.startSpO2() let result = await pollForValue( window: spo2MeasureSeconds, value: { self.latestSpO2Value }, - abort: { self.spot.isRejected(token) } + // The ring refused the measurement, or told us it isn't on the finger. + abort: { self.spot.isRejected(token) || self.spo2NotWornReported } ) spot.end(token) engine?.stopSpO2() @@ -661,6 +673,23 @@ final class RingSyncCoordinator { // The ring reported a genuine error/no-reading (worn incorrectly). Only fast-fail if this // measurement hasn't already produced a real reading. if hrState == .measuring, !measurementReceivedReading { hrNoReadingReported = true } + case let .wearState(worn): + // `worn == false` means no skin contact, so an optical spot measure can't read. Fast-fail + // the in-flight measure instead of idling out the full window, and flag *why* — but only + // if no reading landed first (a wear-state drop right after a good reading must not turn a + // success into a failure). Gated to CRP: other families' wear polarity is unverified. + if !worn, client.activeDeviceType == .crp { + var flagged = false + if hrState == .measuring, !measurementReceivedReading { + hrNoReadingReported = true + flagged = true + } + if spo2State == .measuring, latestSpO2Value == nil { + spo2NotWornReported = true + flagged = true + } + if flagged { measureNotWorn = true } + } case let .spo2Result(value, _): latestSpO2Value = value case let .spo2Progress(percent, _): diff --git a/PulseLoop/Views/MeasurementKindPresentation.swift b/PulseLoop/Views/MeasurementKindPresentation.swift index 7bf397d..0f0072a 100644 --- a/PulseLoop/Views/MeasurementKindPresentation.swift +++ b/PulseLoop/Views/MeasurementKindPresentation.swift @@ -83,6 +83,13 @@ extension MeasurementSheet.Kind { } } + /// Shown instead of `failureMessage` when the ring reported it wasn't on the finger (CRP wear + /// state). Deliberately one message for every kind: the fix is the same regardless of which vital + /// was being measured, and naming the vital here would only bury the one instruction that matters. + var notWornMessage: String { + "The ring isn't detecting your finger. Put it on snugly, then try again." + } + /// SpO₂ breathes rather than beats — its ambient pulse runs at a slower cadence. var slowBreathing: Bool { self == .spo2 } } diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index c48fbd3..24f953e 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -311,6 +311,9 @@ struct MeasurementSheet: View { guard ble.state == .connected else { return "Your ring isn't connected. Reconnect it and try again." } + // The ring told us it wasn't on the finger, so the generic "keep still" advice would send the + // user to fix the wrong thing — an optical sensor with no skin contact cannot read at all. + if coordinator.measureNotWorn { return kind.notWornMessage } return kind.failureMessage } diff --git a/PulseLoopTests/EventBridgeTests.swift b/PulseLoopTests/EventBridgeTests.swift index 999d0c3..b296f2e 100644 --- a/PulseLoopTests/EventBridgeTests.swift +++ b/PulseLoopTests/EventBridgeTests.swift @@ -262,4 +262,25 @@ final class EventBridgeTests: XCTestCase { XCTAssertTrue(RingEventBridge.events( for: .activityBucket(timestamp: unsetRingClock, steps: 120, distanceMeters: 90)).isEmpty) } + + // MARK: - Wear state + + /// Wear state must reach the coordinator — it used to stop at the decoder, which is why a + /// not-worn measure spun the full window with no explanation. + func testWearStateFansOutBothPolarities() { + for worn in [true, false] { + let events = RingEventBridge.events(for: .wearingStatus(worn: worn, timestamp: Date())) + XCTAssertEqual(events.count, 1) + guard case let .wearState(mapped) = events[0] else { + return XCTFail("expected wearState, got \(events[0])") + } + XCTAssertEqual(mapped, worn) + } + } + + /// The bridge fans out unconditionally; family gating lives in the coordinator, because only + /// CRP's wear polarity is hardware-confirmed. + func testWearStateIsNotFamilyGatedInTheBridge() { + XCTAssertEqual(RingEventBridge.events(for: .wearingStatus(worn: false, timestamp: Date())).count, 1) + } } diff --git a/PulseLoopTests/YCBTDecoderTests.swift b/PulseLoopTests/YCBTDecoderTests.swift index 48d8b31..7725f4e 100644 --- a/PulseLoopTests/YCBTDecoderTests.swift +++ b/PulseLoopTests/YCBTDecoderTests.swift @@ -146,8 +146,11 @@ final class YCBTDecoderTests: XCTestCase { } XCTAssertFalse(isWorn) - // It must stay out of the typed fan-out — nothing in the app gates on wear state yet. - XCTAssertTrue(RingEventBridge.events(for: .wearingStatus(worn: true, timestamp: Date())).isEmpty) + // Wear state now fans out (the CRP measurement flow fast-fails a not-worn spot measure), so + // the guard against YCBT's unverified polarity moved from the bridge to the coordinator: the + // bridge is family-agnostic, and `RingSyncCoordinator` only acts on it for `.crp`. That keeps a + // wrong polarity guess here from reaching the UI while still letting CRP use the signal. + XCTAssertEqual(RingEventBridge.events(for: .wearingStatus(worn: true, timestamp: Date())).count, 1) } // MARK: Device pushes (group 0x04, DevControl) From 1d99423dc5537184fadf35c12c64155c4c3ae4ba Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 25 Jul 2026 03:37:05 -0700 Subject: [PATCH 6/6] fix(crp): give CRPFrameAssembler the nonisolated deinit its siblings have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes every class in this project main-actor isolated, so a plain `deinit` hops back to the main actor to run — the pattern that double-frees and SIGABRTs the test runner on the iOS 26.0–26.2 simulator runtimes (see the note in .github/workflows/ci.yml). Both sibling assemblers, `YCBTFrameAssembler` and `LuckRingFrameAssembler`, carry `nonisolated deinit {}` for exactly this reason; CRPFrameAssembler was the one that didn't. It is the riskiest of the three to leave out: a fresh `CRPDriver` — and with it a fresh assembler — is built on every connect, so the deallocation happens on each disconnect/reconnect cycle rather than once at teardown. 780 tests pass. --- PulseLoop/RingProtocol/CRPDecoder.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index a0c9124..eb01466 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -6,6 +6,8 @@ import Foundation /// is complete. Mirrors the vendor's `g1/a.k()`. One assembler instance per connection — a fresh /// `CRPDriver` is built on every connect, so state always starts clean. final class CRPFrameAssembler { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private var buffer: [UInt8] = [] private var expected = 0