From 9d3b133592781a87323a14d1b5968ca9bc50c6a0 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Tue, 21 Jul 2026 06:09:15 -0700 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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 From 5427dc0d91cb428cd595a297a1df0b6236332cb6 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 7 Aug 2026 06:33:56 -0700 Subject: [PATCH 7/8] fix(crp): port the R11 opcode corrections and read-backs 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 app's finished R11 state. Every opcode below was re-resolved through its `d1/b.java` caller in decompiled-moyoung-official/, not by position in the builder class — jadx alphabetises method names, and pairing them positionally is what produced the wrong constants in the first place. - Group 7 is the vendor's **Gomore** module, not device info. Every `b1/r` builder resolves to a Gomore call, so `queryFirmwareVersion` was really `querySavedGomoreKey` — which is why zaggash's R11 answered none of the 23 sends (issue #29). Firmware is group 3 / cmd 3 (`b1/l.k`), a bare UTF-8 string, now decoded to `.firmware(version:)`. `queryDeviceInfo`/`queryDeviceSN` are gone; the Gomore constants stay only so a capture is still identifiable. - Temperature history is `2/22` (`b1/i0.b`, `[day, frameIndex]`), not `2/48` — `q.b(2,48)` is the vendor's `querySleepState`. - Both all-day temp toggles ride cmd 13 with an enable byte. The disable used cmd 32, which is `b1/i0.d`, the *spot* toggle — so disabling all-day temperature was starting a one-shot measurement instead. - Add the read-backs that let the ring describe itself: `querySupportSpO2Type` (2/37) plus the monitor-state queries (2/6, 2/7, 2/8, 2/45, 2/21). Sent once per connection and **before** the timing config, so the replies report the ring's own state rather than the one we just imposed. SpO2 support is decoded for the raw-packet feed only — the hardware is capture-confirmed and `refinedCapabilities` is additive-only, so it stays unconditional. - Backfill the prior six nights of sleep once per connection. The poll pass only ever asks for `daysAgo = 0`, so stored history could otherwise only grow one night at a time. Safe to send blind: each reply carries its own day index. - Add the missing HRV/stress/temp spot-measure builders. Also fixes an iOS-only bug: `setMeasurementSettings` took a `MeasurementSettings?`, a signature that satisfied no `RingSyncEngine` requirement. The protocol's no-op default extension supplied conformance instead, so every `RingSyncCoordinator` call landed there and the user's saved all-day config was silently discarded in favour of `.allOnDefault`. --- PulseLoop/RingProtocol/CRPCoordinator.swift | 5 + PulseLoop/RingProtocol/CRPDecoder.swift | 86 +++++++++--- PulseLoop/RingProtocol/CRPProtocol.swift | 138 ++++++++++++++++---- PulseLoop/RingProtocol/CRPSyncEngine.swift | 108 +++++++++++++-- PulseLoopTests/CRPDecoderTests.swift | 73 +++++++++++ PulseLoopTests/CRPProtocolTests.swift | 41 ++++++ PulseLoopTests/CRPSyncEngineTests.swift | 105 +++++++++++++-- 7 files changed, 482 insertions(+), 74 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPCoordinator.swift b/PulseLoop/RingProtocol/CRPCoordinator.swift index 1e9f385..c4c8635 100644 --- a/PulseLoop/RingProtocol/CRPCoordinator.swift +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -40,6 +40,11 @@ final class CRPCoordinator: WearableCoordinator { /// /// `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. + /// SpO2 stays **unconditional** rather than bitmap-gated even though `CRPSyncEngine` now asks the + /// ring directly (`querySupportSpO2Type`, group 2 / cmd 37): the hardware is confirmed by a real + /// reading in zaggash's 2026-07-23 capture (`group 1 / cmd 11` payload `0x61` = 97 %), and + /// `refinedCapabilities` is additive-only, so gating it would only ever be a no-op or a + /// regression. The read-back is decoded for the raw-packet feed — see `CRPDecoder.decodeSpO2Support`. /// /// 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 diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index eb01466..6fe9f58 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -86,8 +86,9 @@ enum CRPDecoder { } /// Framed `fdd3` reply: `FD DA 10 `. - /// 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. + /// Real-time vital results come on group 1; sleep/all-day history and the capability read-backs on + /// group 2; device identity and state pushes on group 3. Group 7 is the vendor's Gomore module, + /// not device info. private static func decodeFramedReply(_ frame: Data, now: Date, calendar: Calendar) -> [RingDecodedEvent] { let b = [UInt8](frame) if b.count < CRPProtocol.headerSize { return [] } @@ -103,33 +104,43 @@ enum CRPDecoder { return decodeVitalResult(cmd: cmd, payload: payload, now: now) } - // Group 2: sleep + the all-day "timing" vital timelines + temperature history. + // Group 7: the vendor's Gomore module (`b1/r`). Nothing we send lands here any more; kept so + // an unsolicited Gomore frame in a capture is still recorded rather than dropped. + if group == CRPCommands.groupGomore { + return ack() + } + + // Group 2: sleep + the all-day "timing" vital timelines + temperature history + read-backs. // 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. + // cmd 37 → the ring's own SpO2-hardware answer. + // cmd 22 → 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 cmd == CRPCommands.cmdQuerySupportSpO2Type { + return decodeSpO2Support(payload) + } 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). + // Group 3: device control, the firmware-version string (cmd 3), and 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)] } + if cmd == CRPCommands.cmdQueryFirmwareVersion, + let firmware = decodeFirmwareVersion(payload) { + // nil ⇒ nothing readable in the payload; fall through to the ack below. + return firmware + } return ack() } @@ -184,7 +195,7 @@ 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 + /// group-2 cmd (e.g. temp cmd 22) 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 @@ -243,12 +254,49 @@ enum CRPDecoder { 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` - /// 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)))] + /// The firmware version string (`group 3 / cmd 3`). Vendor `g1/a.i1`: + /// `onVersion(new String(payload, StandardCharsets.UTF_8))` — a bare UTF-8 string with no length + /// prefix or terminator, e.g. `MOY-R1K3-2.1.6` on zaggash's R11 (Android issue #29). + /// + /// Returns `nil` when the payload holds nothing readable, so the caller acks it instead. + /// Surfaced as `.firmware(version:)`, which the event bridge maps to `.firmwareVersion` — a + /// device-info event, *not* a connection-state one. That distinction matters: the query is part of + /// `CRPSyncEngine.runStartup`, which is also the ~30-minute background sync, so a reply that + /// bridged to "connected" would re-fire on every pass. + private static func decodeFirmwareVersion(_ payload: [UInt8]) -> [RingDecodedEvent]? { + // Trims NUL padding as well as whitespace: some firmwares pad the frame to a fixed width. + let version = String(decoding: payload, as: UTF8.self) + .trimmingCharacters(in: CharacterSet(charactersIn: " \0\t\r\n")) + if version.isEmpty { return nil } + return [.firmware(version: version)] + } + + /// The ring's own answer to "do you have SpO2 hardware?" (`group 2 / cmd 37`). Vendor `g1/a.V0` + /// hands `payload[0]` to `CRPBloodOxygenType`, which defines exactly three values: + /// **0 = NOT_SUPPORT, 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN** — `getInstance` returns nil for + /// anything else, so only 1 and 2 count as a claim of support. + /// + /// Treating "any non-zero" as support would be a real hazard on this ring: it uses `0xFF` as a + /// no-reading sentinel elsewhere (every failed spot SpO2 answers `group 1 / cmd 11 [FF]`), and a + /// `0xFF` here would otherwise read as a capability claim. + /// + /// **This is currently diagnostic, not capability-driving.** SpO2 is in `CRPCoordinator`'s + /// unconditional capabilities because it is hardware-confirmed: zaggash's 2026-07-23 capture has a + /// real reading (`group 1 / cmd 11` payload `0x61` = 97 %). `WearableCoordinator.refinedCapabilities` + /// is additive-only (`capabilities.union(bitmapGated ∩ derived)`), so a NOT_SUPPORT answer cannot + /// take SpO2 away — decoding it simply puts the ring's own answer in the raw-packet feed, where the + /// next capture can confirm or challenge what we assume. Acting on a NOT_SUPPORT would need a + /// subtractive mechanism that does not exist yet, and should not be invented without a ring that + /// actually reports one. + private static func decodeSpO2Support(_ payload: [UInt8]) -> [RingDecodedEvent] { + guard let type = payload.first else { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: + (CRPCommands.groupHistory << 4) | (CRPCommands.cmdQuerySupportSpO2Type & 0x0F)))] + } + // Only the two documented "supported" values; 0 = NOT_SUPPORT and anything else is unknown. + // Report an empty set rather than nothing, so the feed records that the ring was asked. + let granted: Set = (type == 1 || type == 2) ? [.spo2, .manualSpo2] : [] + return [.supportFunctions(granted)] } private struct SleepTransition { diff --git a/PulseLoop/RingProtocol/CRPProtocol.swift b/PulseLoop/RingProtocol/CRPProtocol.swift index 3827f2c..cdd36d5 100644 --- a/PulseLoop/RingProtocol/CRPProtocol.swift +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -62,8 +62,14 @@ 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.) +/// **NOTE on disable:** HR/HRV/SpO2/Stress disable by sending enable with interval=0. Temp toggles +/// on its own cmd (13) with `[1]`/`[0]`. (Per `d1/b.java` `enableTimingTemp`/`disableTimingTemp`, +/// both of which call `b1/i0.c(Bool)`.) +/// +/// **Resolve every opcode through its `d1/b.java` caller, never by position in the builder class.** +/// jadx alphabetises method names, so `b1/r`'s `a`/`b`/`c` order carries no meaning. Pairing methods +/// with opcodes positionally is what mislabelled the whole of group 7 as device info (see +/// `groupGomore`) and `3/1` as restart; both are corrected below. enum CRPCommands { // Group 1 — device config / measurement control. static let groupDevice = 1 @@ -73,7 +79,7 @@ enum CRPCommands { 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]) + static let cmdMeasureTemp = 32 // b1/i0.d: q.c(1,32, [enable]) — the SPOT toggle, not all-day // 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). @@ -86,19 +92,26 @@ enum CRPCommands { 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. + // Disable: HR/HRV/SpO2/Stress use enable with interval=0. Temp uses `[0]` on its own 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 — 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 cmdEnableTimingTemp = 13 // b1/i0.c: q.c(1,13, [enable]) — all-day temp on/off + + // Group 7 is the vendor's **Gomore** group (the licensed activity-analytics module), NOT device + // info — every builder in `b1/r` resolves to a Gomore call in `d1/b.java`: + // q.b(7,0)=querySupportGomore q.b(7,1)=querySavedGomoreKey q.b(7,2)=queryGomoreEUID + // q.c(7,3,str)=sendGomoreKey q.b(7,13)=queryGomoreVersion + // The earlier constants here paired `b1/r`'s methods with opcodes positionally (a→0, b→1, c→13) + // and mislabelled all three as device info; `queryFirmwareVersion` was really + // `querySavedGomoreKey`, which is why zaggash's R11 answered none of the 23 sends (issue #29). + // Real device queries live on group 3 — see `groupPower`. Kept only so a capture containing + // these frames is still identifiable; nothing sends them. + static let groupGomore = 7 + static let cmdQuerySupportGomore = 0 // b1/r.e: q.b(7,0) → d1/b.querySupportGomore + static let cmdQuerySavedGomoreKey = 1 // b1/r.d: q.b(7,1) → d1/b.querySavedGomoreKey + static let cmdQueryGomoreVersion = 13 // b1/r.c: q.b(7,13) → d1/b.queryGomoreVersion // 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 @@ -110,13 +123,41 @@ enum CRPCommands { 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) + /// Temperature history. **Not 48** — `q.b(2,48)` is the vendor's `querySleepState` (`b1/e0.d`, + /// `d1/b.java` line 650); the real temperature history is `i0.b(day, frameIndex)` = + /// `q.c(2,22, [day, idx])`, the same `[day, frameIndex]` shape as the other timing histories. We + /// queried 48 for months and the ring never answered — see zaggash's 2026-07-25 capture, 23 sends + /// and 0 replies. Its sample layout is still unconfirmed by a non-empty capture, so the reply + /// stays an ack for now. + static let cmdQueryHistoryTemp = 22 // b1/i0.b: q.c(2,22, [day, frameIndex]) static let historyDayToday = 0 // CRPHistoryDay.TODAY; YESTERDAY = 1 - // Group 3 — power control + wear state. + // Group 2 — read-back queries. The ring can be *asked* what it supports and what is currently + // enabled, so the app doesn't have to guess (vendor `d1/b.java` querySupport*/queryTiming*State). + /// `b1/h.e`: q.b(2,37). Reply payload[0] is a `CRPBloodOxygenType`: 0 = NOT_SUPPORT, + /// 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN (`g1/a.V0` → `onSupportBloodOxygenType`). This is how a + /// ring reports its own SpO2 hardware instead of us inferring it from a marketing page. + static let cmdQuerySupportSpO2Type = 37 + /// The all-day monitor state queries. Each reply carries the configured interval in minutes + /// (`g1/a.{p1,r1,n1,t1}` → `onTimingInterval`); 0 means the monitor is off. + static let cmdQueryTimingHRState = 6 // b1/t.e: q.b(2,6) + static let cmdQueryTimingHRVState = 7 // b1/u.e: q.b(2,7) + static let cmdQueryTimingSpO2State = 8 // b1/h.f: q.b(2,8) + static let cmdQueryTimingTempState = 21 // b1/i0.a: q.b(2,21) → onTimingState(type, state) + static let cmdQueryTimingStressState = 45 // b1/h0.e: q.b(2,45) + + // Group 3 — device control, identity queries, and device-state pushes. (Named `groupPower` from + // when only the two power opcodes were known; the group is broader than the name.) Opcodes read + // off the `b1/l` builders via their `d1/b.java` callers. 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) + static let cmdFactoryReset = 0 // b1/l.v: q.b(3,0) → d1/b.reset + static let cmdShutDown = 1 // b1/l.y: q.b(3,1) → d1/b.shutDown (was mislabelled `cmdRestart`) + // Firmware identity — the pair the vendor's own "Firmware information" screen shows + // (`FirmwareInformationActivity`): version is a bare UTF-8 string, hash a hex code. + static let cmdQueryFirmwareVersion = 3 // b1/l.k: q.b(3,3) → d1/b.queryFirmwareVersion + static let cmdQueryFirmwareHash = 4 // b1/l.j: q.b(3,4) → d1/b.queryFirmwareHash + static let cmdQueryRealtimeBattery = 6 // b1/l.f: q.b(3,6) → d1/b.queryRealTimeBattery + static let cmdRestart = 14 // b1/l.w: q.b(3,14) → d1/b.restart /// 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). @@ -194,14 +235,31 @@ enum CRPProtocol { return frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdSetUserInfo, payload: payload) } + // MARK: - Spot (manual) measurement toggles + // start(true)/stop(false) an on-demand reading; the ring reports back on the same cmd byte, + // decoded by `CRPDecoder.decodeVitalResult`. Mirrors the vendor's startMeasureX/stopMeasureX + // (`d1/b.java` → `b1/t.d`, `u.d`, `h.d`, `h0.d`, `i0.d`). + static func measureHeartRate(_ enable: Bool) -> Data { frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureHR, payload: [enable ? 1 : 0]) } + static func measureHRV(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureHRV, payload: [enable ? 1 : 0]) + } + static func measureSpO2(_ enable: Bool) -> Data { frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureSpO2, payload: [enable ? 1 : 0]) } + static func measureStress(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureStress, payload: [enable ? 1 : 0]) + } + + static func measureTemp(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureTemp, payload: [enable ? 1 : 0]) + } + static func findDevice(_ enable: Bool) -> Data { frame(group: CRPCommands.groupAction, cmd: CRPCommands.cmdFindDevice, payload: [enable ? 1 : 0]) } @@ -212,7 +270,8 @@ enum CRPProtocol { // 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). + // Temp toggles on cmd 13 with `[1]`/`[0]` — `d1/b.enableTimingTemp`/`disableTimingTemp` both call + // `b1/i0.c(Bool)`. Cmd 32 (`b1/i0.d`) is the *spot* temp toggle, a different thing entirely. static func enableTimingHeartRate(intervalMinutes: Int) -> Data { frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHR, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) } @@ -246,11 +305,11 @@ enum CRPProtocol { } static func enableTimingTemp() -> Data { - frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingTemp) + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingTemp, payload: [1]) } static func disableTimingTemp() -> Data { - frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdDisableTimingTemp) + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingTemp, payload: [0]) } // MARK: - History query commands (group 2) @@ -283,20 +342,43 @@ enum CRPProtocol { payload: [UInt8(truncatingIfNeeded: daysAgo)]) } - static func queryHistoryTemp() -> Data { - frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistoryTemp) + static func queryHistoryTemp(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistoryTemp, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) + } + + // MARK: - Read-back queries: let the ring tell us what it supports and what is enabled + + /// Ask whether this unit has SpO2 hardware at all. See `CRPCommands.cmdQuerySupportSpO2Type`. + static func querySupportSpO2Type() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQuerySupportSpO2Type) } - // MARK: - Device info queries (group 7) - static func queryDeviceInfo() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryDeviceInfo) + static func queryTimingHeartRateState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHRState) } - static func queryFirmwareVersion() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryFirmwareVersion) + static func queryTimingHrvState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHRVState) } - static func queryDeviceSN() -> Data { - frame(group: CRPCommands.groupDeviceInfo, cmd: CRPCommands.cmdQueryDeviceSN) + static func queryTimingSpO2State() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingSpO2State) + } + + static func queryTimingStressState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingStressState) + } + + static func queryTimingTempState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingTempState) + } + + // MARK: - Device identity queries (group 3) + // `queryDeviceInfo`/`queryDeviceSN` are gone: they framed group-7 Gomore opcodes, which the ring + // never answers. Firmware version is the one the vendor's Firmware-information screen reads. + + static func queryFirmwareVersion() -> Data { + frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdQueryFirmwareVersion) } } diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index 792eaf2..22e1dd3 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -1,22 +1,26 @@ import Foundation +/// Nights before today to pull once per connection. See `CRPSyncEngine.sendSleepBackfill`. +private let crpSleepBackfillDays = 6 + /// 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. +/// and answers measurement commands. /// -/// 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. +/// Scope: clock + user-info handshake, spot HR + SpO2 (Measure button), all-day vital timing +/// enable/disable driven by `MeasurementSettings`, find-device. Steps and battery arrive as +/// autonomous pushes/reads (see `CRPDriver`). HRV / stress / temperature are all-day metrics — their +/// timing is enabled here and live results decode via `CRPDecoder`. Of the stored day timelines, +/// sleep (group-2/cmd-14) is decoded (`CRPDecoder.decodeSleep`, confirmed against a hardware +/// capture), and the group-2 all-day "timing" vital histories (HR/SpO2/HRV/stress) decode into +/// `.historyMeasurement` samples; their multi-frame replies reassemble via the next-frame follow-up +/// in `handle`. /// /// 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. +/// opcodes too, with no invocation path), so there is nothing to wire it into here — which is why +/// `CRPCoordinator` doesn't claim `.factoryReset` even though the Android coordinator does. @MainActor final class CRPSyncEngine: RingSyncEngine { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) @@ -25,11 +29,16 @@ 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; 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 + /// live via `applyMeasurementSettings`. `nil` ⇒ the user has never saved one, and a fresh R11 + /// ships with every all-day monitor OFF, so it records nothing to sync. We therefore fall back to /// `MeasurementSettings.allOnDefault` (matching how `ColmiSyncEngine` force-enables on connect) /// so the day timeline actually accumulates. + /// + /// Note this is a *forced* default, not a read-back: `sendConnectionReadBacks` now asks the ring + /// for each monitor's current interval, but the replies are only surfaced as diagnostics — we + /// still impose a config rather than adopting the ring's. Matching the vendor here (query state, + /// apply the saved config, leave the ring alone otherwise) is a known open divergence on both + /// platforms; it needs the read-back replies confirmed against hardware first. private var measurementSettings: MeasurementSettings? /// Frame follow-ups already requested this poll pass, keyed `cmd * 100 + frameIndex`, so a ring @@ -46,8 +55,13 @@ final class CRPSyncEngine: RingSyncEngine { // 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). + // The 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old + // opcode was group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey`, not + // firmware. The real query is group 3 cmd 3 (`b1/l.k` → `d1/b.queryFirmwareVersion`), and it + // answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11. send(CRPProtocol.queryFirmwareVersion()) if let profile { send(userInfoFrame(profile)) } + sendConnectionReadBacks() // 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 @@ -59,6 +73,42 @@ final class CRPSyncEngine: RingSyncEngine { queryAllHistory() } + /// Whether this connection's read-backs have been sent. A fresh `CRPSyncEngine` is built per + /// connection (`RingBLEClient.installDriver` calls `driver.makeSyncEngine()` on connect), so + /// instance state gives "once per connection" for free. + private var readBacksSent = false + + /// Ask the ring to describe itself, once per connection. + /// + /// `querySupportSpO2Type` answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN; the timing-state + /// queries report each all-day monitor's configured interval (0 = off). Together they are the + /// evidence base for whether a silent history query means "the monitor is off" or "this ring + /// lacks the sensor" — stress (`2/47`), temperature (`2/22`) and firmware (formerly `7/1`) all + /// went unanswered on zaggash's ring, and these replies are how we tell those apart next capture. + /// + /// Deliberately **not** part of the poll pass. `runStartup` doubles as the background re-sync, + /// but what a ring supports cannot change between syncs. Re-asking would add six writes to every + /// pass on a ring that funnels the handshake, timing config, history pull *and* on-demand + /// measures through the single `fdd2` channel — and a spot SpO2 needs ~48 s of that channel to + /// return a reading. + /// + /// **Call order matters: this must run BEFORE `applyTimingSettings`.** The state queries report + /// each monitor's *current* interval, and `applyTimingSettings` force-enables everything moments + /// later. Ask afterwards and every reply describes the state we just imposed, which answers + /// nothing — the whole point is to learn whether stress and temperature were silent because their + /// monitor was off. `CRPSyncEngineTests` pins the ordering; if that assertion ever fails, fix the + /// call site rather than the expectation. + private func sendConnectionReadBacks() { + if readBacksSent { return } + readBacksSent = true + send(CRPProtocol.querySupportSpO2Type()) + send(CRPProtocol.queryTimingHeartRateState()) + send(CRPProtocol.queryTimingHrvState()) + send(CRPProtocol.queryTimingSpO2State()) + send(CRPProtocol.queryTimingStressState()) + send(CRPProtocol.queryTimingTempState()) + } + /// 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 @@ -71,6 +121,31 @@ final class CRPSyncEngine: RingSyncEngine { send(CRPProtocol.queryTimingStressHistory()) send(CRPProtocol.queryHistoryTemp()) send(CRPProtocol.queryHistorySleep()) + sendSleepBackfill() + } + + /// Whether this connection has already backfilled older nights. Same "fresh engine per + /// connection" trick as `readBacksSent`. + private var sleepBackfillSent = false + + /// Pull the nights *before* today, once per connection. + /// + /// The poll pass above only ever asks for `daysAgo = 0`, so the app's stored history could only + /// ever grow one night at a time from whenever the user installed. Asking for the ring's own + /// back-catalogue is what actually restores a user's history. + /// + /// Safe to send blind. Each reply is self-describing: `payload[0]` is the ring's own day index, + /// so `CRPDecoder.decodeSleep` dates a night from the reply rather than from what we asked for, + /// and a day the ring has no record of simply produces no reply — the same nothing we get today. + /// + /// Once per connection, and deliberately short of the decoder's 14-day ceiling: `runStartup` is + /// also the background sync, and this ring funnels the handshake, timing config, history pull + /// *and* on-demand measures through one `fdd2` channel (a spot SpO2 needs ~48 s of it). A week is + /// the useful-recovery/quiet-channel trade; raise it once hardware shows the ring answers deeper. + private func sendSleepBackfill() { + if sleepBackfillSent { return } + sleepBackfillSent = true + for daysAgo in 1...crpSleepBackfillDays { send(CRPProtocol.queryHistorySleep(daysAgo: daysAgo)) } } /// The last frame index each timing vital emits before its day is complete (vendor terminal @@ -133,7 +208,12 @@ final class CRPSyncEngine: RingSyncEngine { } // MARK: - Measurement settings - func setMeasurementSettings(_ settings: MeasurementSettings?) { + /// Takes a non-optional `MeasurementSettings` because that is `RingSyncEngine`'s requirement. + /// It used to take `MeasurementSettings?`, which is a *different* signature — so it satisfied + /// nothing, the protocol's no-op default extension supplied conformance instead, and every + /// `RingSyncCoordinator` call landed there. The user's saved config was silently discarded and + /// `runStartup` always fell back to `.allOnDefault`. + func setMeasurementSettings(_ settings: MeasurementSettings) { measurementSettings = settings } diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift index 767dab1..162f75a 100644 --- a/PulseLoopTests/CRPDecoderTests.swift +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -380,6 +380,79 @@ final class CRPDecoderTests: XCTestCase { XCTAssertTrue(CRPDecoder.decode(frame, from: fdd3).isEmpty) } + // MARK: - Firmware version + capability read-backs + + /// `group 3 / cmd 3` carries a bare UTF-8 string with no length prefix or terminator + /// (`g1/a.i1`: `onVersion(new String(payload, UTF_8))`) — `MOY-R1K3-2.1.6` on zaggash's R11. + func testFirmwareVersionDecodesAsABareUTF8String() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: Array("MOY-R1K3-2.1.6".utf8)) + guard case let .firmware(version) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected firmware") + } + XCTAssertEqual(version, "MOY-R1K3-2.1.6") + } + + /// Some firmwares pad the frame to a fixed width; NUL padding must not survive into the UI. + func testFirmwareVersionTrimsNulPadding() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: Array("V1.2".utf8) + [0, 0, 0]) + guard case let .firmware(version) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected firmware") + } + XCTAssertEqual(version, "V1.2") + } + + /// An empty/all-padding payload has no version in it — ack rather than publish an empty string. + func testEmptyFirmwarePayloadFallsBackToAnAck() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: [0, 0]) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + + /// `CRPBloodOxygenType` defines exactly three values: 0 NOT_SUPPORT, 1 SLEEP_OXYGEN, + /// 2 TIMING_OXYGEN. Only 1 and 2 are a claim of support. + func testSpO2SupportGrantsCapabilitiesOnlyForTheTwoDocumentedTypes() { + for type in [1, 2] { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQuerySupportSpO2Type, + payload: [UInt8(type)]) + guard case let .supportFunctions(caps) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected supportFunctions for type \(type)") + } + XCTAssertEqual(caps, [.spo2, .manualSpo2]) + } + } + + /// `0xFF` is this ring's no-reading sentinel (a failed spot SpO2 answers `1/11 [FF]`). Treating + /// "any non-zero" as support would read that sentinel as a capability claim. + func testSpO2SupportRejectsNotSupportAndTheFFSentinel() { + for type: UInt8 in [0, 0xFF] { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQuerySupportSpO2Type, + payload: [type]) + guard case let .supportFunctions(caps) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected supportFunctions for type \(type)") + } + XCTAssertTrue(caps.isEmpty, "type \(type) must not claim SpO2") + } + } + + /// Group 7 is the vendor's Gomore module. Nothing we send lands there, but an unsolicited frame + /// should still be recorded rather than dropped. + func testGomoreGroupRepliesAreAcked() { + let frame = CRPProtocol.frame(group: CRPCommands.groupGomore, + cmd: CRPCommands.cmdQuerySupportGomore, payload: [1]) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + /// 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() { diff --git a/PulseLoopTests/CRPProtocolTests.swift b/PulseLoopTests/CRPProtocolTests.swift index 811dcf7..689d731 100644 --- a/PulseLoopTests/CRPProtocolTests.swift +++ b/PulseLoopTests/CRPProtocolTests.swift @@ -65,4 +65,45 @@ final class CRPProtocolTests: XCTestCase { func testFactoryResetIsGroup3Cmd0WithNoPayload() { XCTAssertEqual(CRPProtocol.factoryReset(), Data([0xFD, 0xDA, 0x10, 6, 3, 0])) } + + // MARK: - Opcodes corrected against their `d1/b.java` callers + + /// `b1/l.k` → `d1/b.queryFirmwareVersion` = `q.b(3,3)`. The old `7/1` was `b1/r.d`, the vendor's + /// `querySavedGomoreKey` — which is why the R11 answered none of the 23 sends (Android issue #29). + func testFirmwareVersionQueryIsGroup3Cmd3() { + XCTAssertEqual(CRPProtocol.queryFirmwareVersion(), Data([0xFD, 0xDA, 0x10, 6, 3, 3])) + } + + /// `b1/i0.b` → `q.c(2,22,[day,idx])`, the same `[day, frameIndex]` shape as the other timing + /// histories. `q.b(2,48)` is `b1/e0.d` = `querySleepState`, not temperature. + func testTemperatureHistoryIsGroup2Cmd22WithDayAndFrameIndex() { + XCTAssertEqual(CRPProtocol.queryHistoryTemp(), Data([0xFD, 0xDA, 0x10, 8, 2, 22, 0, 0])) + XCTAssertEqual(CRPProtocol.queryHistoryTemp(day: 1, frameIndex: 2), + Data([0xFD, 0xDA, 0x10, 8, 2, 22, 1, 2])) + } + + /// Both temp toggles ride cmd 13 with an enable byte — `d1/b.enableTimingTemp` and + /// `disableTimingTemp` both call `b1/i0.c(Bool)`. Cmd 32 (`b1/i0.d`) is the *spot* toggle; + /// sending it as a disable would have started a one-shot measurement instead. + func testTempAllDayTogglesShareCmd13AndDifferOnlyInTheEnableByte() { + XCTAssertEqual(CRPProtocol.enableTimingTemp(), Data([0xFD, 0xDA, 0x10, 7, 1, 13, 1])) + XCTAssertEqual(CRPProtocol.disableTimingTemp(), Data([0xFD, 0xDA, 0x10, 7, 1, 13, 0])) + XCTAssertEqual(CRPProtocol.measureTemp(true), Data([0xFD, 0xDA, 0x10, 7, 1, 32, 1])) + } + + /// The read-backs that let the ring describe itself, all on group 2 with no payload. + func testReadBackQueriesUseTheirVendorOpcodes() { + XCTAssertEqual(CRPProtocol.querySupportSpO2Type(), Data([0xFD, 0xDA, 0x10, 6, 2, 37])) // b1/h.e + XCTAssertEqual(CRPProtocol.queryTimingHeartRateState(), Data([0xFD, 0xDA, 0x10, 6, 2, 6])) // b1/t.e + XCTAssertEqual(CRPProtocol.queryTimingHrvState(), Data([0xFD, 0xDA, 0x10, 6, 2, 7])) // b1/u.e + XCTAssertEqual(CRPProtocol.queryTimingSpO2State(), Data([0xFD, 0xDA, 0x10, 6, 2, 8])) // b1/h.f + XCTAssertEqual(CRPProtocol.queryTimingStressState(), Data([0xFD, 0xDA, 0x10, 6, 2, 45])) // b1/h0.e + XCTAssertEqual(CRPProtocol.queryTimingTempState(), Data([0xFD, 0xDA, 0x10, 6, 2, 21])) // b1/i0.a + } + + /// The remaining spot-measure toggles, each `[enable]` on its own group-1 cmd. + func testSpotMeasureTogglesCoverEveryVital() { + XCTAssertEqual(CRPProtocol.measureHRV(true), Data([0xFD, 0xDA, 0x10, 7, 1, 10, 1])) + XCTAssertEqual(CRPProtocol.measureStress(false), Data([0xFD, 0xDA, 0x10, 7, 1, 14, 0])) + } } diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 25e9b0a..1ef4ab5 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -16,19 +16,85 @@ final class CRPSyncEngineTests: XCTestCase { } /// 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. + /// once a profile exists. Everything after that is the read-backs, the all-day timing config and + /// the history pull, covered by their own tests below. func testRunStartupSendsSetTimeThenUserInfoOnceAProfileIsStored() { let w = FakeWriter() let engine = CRPSyncEngine(writer: w) engine.runStartup() - // set-time, then the firmware query that keeps the UI off "Firmware: reading". - XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [7, 1]]) + // set-time, then the firmware query that keeps the UI off "Firmware: reading". Firmware is + // group 3 / cmd 3 (`b1/l.k`) — the old group-7 opcode was the vendor's Gomore module. + XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [3, 3]]) w.sent.removeAll() engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) engine.runStartup() - XCTAssertEqual(Array(w.opcodes.prefix(3)), [[1, 1], [7, 1], [1, 0]]) + XCTAssertEqual(Array(w.opcodes.prefix(3)), [[1, 1], [3, 3], [1, 0]]) + } + + /// Nothing may target group 7 any more: every `b1/r` builder is a Gomore call, and the R11 + /// answered none of the 23 sends in zaggash's 2026-07-25 capture (Android issue #29). + func testNothingIsSentToTheGomoreGroup() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) + engine.runStartup() + XCTAssertFalse(w.opcodes.contains { $0[0] == 7 }, "group 7 is Gomore, not device info") + } + + // MARK: - Connection read-backs + + /// The read-backs must precede `applyTimingSettings`: they report each monitor's *current* + /// interval, and the timing config force-enables everything moments later. Asked afterwards, + /// every reply would describe the state we just imposed. + func testReadBacksAreSentBeforeTheTimingConfig() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + // Read-backs: SpO2 support 2/37, then the monitor-state queries 2/6, 2/7, 2/8, 2/45, 2/21. + for cmd in [37, 6, 7, 8, 45, 21] { + XCTAssertTrue(w.opcodes.contains([2, cmd]), "expected read-back group2/cmd\(cmd)") + } + guard let lastReadBack = w.opcodes.lastIndex(where: { $0[0] == 2 && [37, 6, 7, 8, 45, 21].contains($0[1]) }), + let firstTimingConfig = w.opcodes.firstIndex(where: { $0[0] == 1 && [6, 7, 8, 39, 13].contains($0[1]) }) + else { return XCTFail("missing read-backs or timing config") } + XCTAssertLessThan(lastReadBack, firstTimingConfig, + "read-backs must be asked before we impose a config") + } + + /// What a ring supports cannot change between syncs, and `runStartup` is also the background + /// re-sync — so re-asking would add six writes to every pass on a single-channel ring. + func testReadBacksAreSentOncePerConnectionNotPerPass() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + w.sent.removeAll() + engine.runStartup() + for cmd in [37, 6, 7, 8, 45, 21] { + XCTAssertFalse(w.opcodes.contains([2, cmd]), "read-back group2/cmd\(cmd) must not repeat") + } + } + + // MARK: - Sleep backfill + + /// The poll pass only asks for `daysAgo = 0`, so without a backfill the app's stored history + /// could only grow one night at a time. Each reply is self-describing, so this is safe to send + /// blind — a day the ring has no record of simply produces no reply. + func testSleepBackfillPullsThePriorWeekOncePerConnection() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + let sleepDays = w.opcodes.indices + .filter { w.opcodes[$0] == [2, 14] } + .map { w.payloadByte($0, 6) } + XCTAssertEqual(sleepDays, [0, 1, 2, 3, 4, 5, 6], "today plus six nights of backfill") + + w.sent.removeAll() + engine.runStartup() + let secondPass = w.opcodes.indices + .filter { w.opcodes[$0] == [2, 14] } + .map { w.payloadByte($0, 6) } + XCTAssertEqual(secondPass, [0], "the backfill is once per connection; the pass re-pulls today only") } func testHeartRateStartAndStopEnqueueGroup1Cmd9() { @@ -60,9 +126,9 @@ final class CRPSyncEngineTests: XCTestCase { // 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). + /// A fresh R11 ships with every all-day monitor OFF, 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) @@ -73,17 +139,30 @@ final class CRPSyncEngineTests: XCTestCase { } } - /// 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. + /// A saved config must actually reach the ring. `setMeasurementSettings` once took an Optional — + /// a signature that satisfied no protocol requirement, so `RingSyncCoordinator`'s call hit the + /// no-op default extension and the user's config was silently dropped in favour of `.allOnDefault`. + func testSavedMeasurementSettingsAreHonouredOverTheForcedDefault() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + var settings = MeasurementSettings.allOnDefault + settings.spo2Enabled = false + engine.setMeasurementSettings(settings) + engine.runStartup() + guard let index = w.opcodes.firstIndex(of: [1, 8]) else { return XCTFail("no SpO2 timing command") } + XCTAssertEqual(w.payloadByte(index, 6), 0, "a disabled vital sends interval 0, not the default") + } + + /// The history pull uses the group-2 opcodes. Temperature is cmd **22** (`b1/i0.b`) — cmd 48 is + /// the vendor's `querySleepState`, which is why the ring never answered the old query. 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 + for cmd in [15, 17, 16, 47, 22, 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") + XCTAssertFalse(w.opcodes.contains([2, 48]), "cmd 48 is querySleepState, not temperature history") } /// Each timing query starts at frame 0 of today. From 4d65b603f4cf1f3538bb34f25953931f9e59028b Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 7 Aug 2026 07:10:14 -0700 Subject: [PATCH 8/8] fix(crp): reset the frame assembler across reconnects, gate connect on fdd3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an adversarial review of this branch. The first two are real bugs; the rest are hardening and corrected reasoning. - **The frame assembler survived a dropped link.** `CRPDriver`'s doc claimed a fresh driver is built per connect, but only `beginConnect`/`adoptRememberedIdentity` call `installDriver` — auto-reconnect re-dials with a bare `central.connect` and keeps the instance. A frame left half-assembled when the old link dropped was completed with bytes from the new one and decoded as genuine; because the group-2 history frames are long and multi-notification, the spliced result is a fabricated vital sample or sleep record, not a parse failure. Adds `CRPFrameAssembler.reset()` and calls it from both lifecycle hooks, matching `LuckRingDriver`/`YCBTDriver`. - **`.connected` no longer fires before the reply channel is live.** With no `requiredSubscriptionsBeforeConnected`, the connection counted as up on the first notify characteristic to report `isNotifying` — for CRP that is `fdd1` (steps), never `fdd3`, which carries every command reply. `.connected` runs `runStartup`, so the handshake could write ~26 frames into a channel we weren't listening to yet, and a lost reply looks exactly like a slow one — the same signature as the opcode bug this branch just fixed. - Fold the firmware query into the once-per-connection block. It was sent on every pass while the six read-backs beside it were gated, on the argument that the single `fdd2` channel is scarce — a firmware string is exactly as immutable as a sensor roster. - Key the timing-history follow-up guard on `day` as well as `cmd`. Every timing query is day 0 today, but this engine already issues multi-day sleep requests, and the old key would silently swallow day 1's frame-1 follow-up the moment the vitals got the same backfill. - Validate the firmware string instead of coercing it. `String(decoding:as:)` cannot fail — it substitutes U+FFFD — so a binary payload rendered as replacement characters presented as a firmware version. Now strict UTF-8, then padding trimmed, then any remaining control byte rejects the frame to an ack. Trimming is deliberately narrower than the vendor's `trim { it <= ' ' }`, which would strip a binary payload's leading junk and pass whatever followed (`01 02 03 41` → "A"). - Correct the "once per connection" comments: that state is per driver install, not per GATT link. Surviving a reconnect is the behaviour we want, so the note says so rather than inviting a lifecycle-hook "fix". - Make the sleep-backfill loop half-open; `1...0` would trap if the documented tuning knob were turned down to today-only. --- PulseLoop/RingProtocol/CRPDecoder.swift | 48 ++++++++++--- PulseLoop/RingProtocol/CRPDriver.swift | 30 ++++++++- PulseLoop/RingProtocol/CRPSyncEngine.swift | 78 ++++++++++++++-------- PulseLoopTests/CRPDecoderTests.swift | 63 +++++++++++++++++ PulseLoopTests/CRPSyncEngineTests.swift | 33 +++++++-- 5 files changed, 203 insertions(+), 49 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPDecoder.swift b/PulseLoop/RingProtocol/CRPDecoder.swift index 6fe9f58..07efcc1 100644 --- a/PulseLoop/RingProtocol/CRPDecoder.swift +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -3,14 +3,27 @@ import Foundation /// 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. +/// is complete. Mirrors the vendor's `g1/a.k()`. +/// +/// **Must be reset when a link comes up.** Auto-reconnect re-uses the same `CRPDriver` instance — +/// `RingBLEClient.didDisconnectPeripheral` re-dials with a bare `central.connect`, and only +/// `beginConnect`/`adoptRememberedIdentity` ever call `installDriver` — so a frame left half-assembled +/// when the old link dropped would be completed with bytes from the new one and decoded as genuine. +/// The group-2 history frames are long and multi-notification, so the spliced result would be a +/// fabricated vital sample or sleep record, not an obvious parse failure. `CRPDriver.connectionDidStart` +/// calls `reset()`, matching `LuckRingDriver`/`YCBTDriver`. 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 + /// Drop any partially-assembled frame. See the type doc — this is not optional bookkeeping. + func reset() { + buffer = [] + 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 } @@ -260,15 +273,30 @@ enum CRPDecoder { /// /// Returns `nil` when the payload holds nothing readable, so the caller acks it instead. /// Surfaced as `.firmware(version:)`, which the event bridge maps to `.firmwareVersion` — a - /// device-info event, *not* a connection-state one. That distinction matters: the query is part of - /// `CRPSyncEngine.runStartup`, which is also the ~30-minute background sync, so a reply that - /// bridged to "connected" would re-fire on every pass. + /// device-info event, *not* a connection-state one. That distinction matters: the query rides + /// `CRPSyncEngine`'s connect handshake, and a reply that bridged to "connected" would restamp the + /// device row as freshly connected. + /// + /// Validated rather than coerced, because whatever comes back is shown verbatim in Settings. + /// `String(decoding:as:)` cannot fail — it substitutes U+FFFD for invalid bytes — so a binary + /// payload would render as a row of replacement characters *presented as a firmware version*. + /// `String(bytes:encoding:)` returns nil instead, and the control-character check rejects the + /// binary payloads that happen to be valid UTF-8. A real version (`MOY-R1K3-2.1.6`) passes both. private static func decodeFirmwareVersion(_ payload: [UInt8]) -> [RingDecodedEvent]? { - // Trims NUL padding as well as whitespace: some firmwares pad the frame to a fixed width. - let version = String(decoding: payload, as: UTF8.self) - .trimmingCharacters(in: CharacterSet(charactersIn: " \0\t\r\n")) - if version.isEmpty { return nil } - return [.firmware(version: version)] + guard let raw = String(bytes: payload, encoding: .utf8) else { return nil } + // Trim only what firmwares actually pad with — NUL and whitespace. Deliberately narrower than + // the vendor's `trim { it <= ' ' }`: trimming *all* control bytes first would strip a binary + // payload's leading junk and let whatever printable byte followed through as a "version" + // (`01 02 03 41` → "A"). Padding comes off, then anything still holding a control byte is + // rejected outright rather than salvaged. + let trimmed = raw.trimmingCharacters( + in: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\0")) + ) + if trimmed.isEmpty { return nil } + if trimmed.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) { + return nil + } + return [.firmware(version: trimmed)] } /// The ring's own answer to "do you have SpO2 hardware?" (`group 2 / cmd 37`). Vendor `g1/a.V0` diff --git a/PulseLoop/RingProtocol/CRPDriver.swift b/PulseLoop/RingProtocol/CRPDriver.swift index 5bb0621..7481702 100644 --- a/PulseLoop/RingProtocol/CRPDriver.swift +++ b/PulseLoop/RingProtocol/CRPDriver.swift @@ -13,9 +13,10 @@ import Foundation /// (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`). +/// `CRPFrameAssembler`; `fdd1`/`2a37` pushes are self-contained. A driver is **not** rebuilt on every +/// link: only `beginConnect`/`adoptRememberedIdentity` call `RingBLEClient.installDriver`, while +/// auto-reconnect re-dials with a bare `central.connect`. So the assembler is reset in +/// `connectionDidStart()` rather than relying on a fresh instance (matches `LuckRingDriver`/`YCBTDriver`). @MainActor final class CRPDriver: WearableDriver { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) @@ -39,6 +40,29 @@ final class CRPDriver: WearableDriver { let batteryServiceUUID: CBUUID? = CRPUUIDs.batteryServiceCBUUID let batteryCharUUID: CBUUID? = CRPUUIDs.batteryLevelCBUUID + /// Hold `.connected` until `fdd3` is live. Without this the connection counts as up on whichever + /// notify characteristic reports `isNotifying` first — for CRP that is `fdd1` (the steps push), + /// never `fdd3`, which carries *every* command reply. `.connected` is what runs `runStartup`, so a + /// handshake begun too early would write the clock, firmware query, read-backs, timing config and + /// the whole history pull into a channel we aren't listening to yet, and each lost reply is + /// indistinguishable from a slow one. Only `fdd3` is required: `fdd1`/`fdd6`/`2a37` carry no reply + /// the handshake waits on, so gating on them would only delay the connect. + let requiredSubscriptionsBeforeConnected: [CBUUID] = [CRPUUIDs.cmdNotifyCBUUID] + + // MARK: Lifecycle + + /// Auto-reconnect re-uses this driver instance (see the type doc), so a frame half-assembled when + /// the old link dropped would be completed with bytes from the new one and decoded as real data. + func connectionDidStart() { + assembler.reset() + } + + /// Symmetric with `connectionDidStart`, and the one point that cannot race the reconnect: clear + /// the partial frame the moment the link goes away rather than trusting the next connect to run. + func connectionDidEnd() { + assembler.reset() + } + // MARK: Framing — the protocol/engine already build full CRP frames. func frame(_ command: Data) -> Data { command } diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index 22e1dd3..728ffb1 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -41,10 +41,20 @@ final class CRPSyncEngine: RingSyncEngine { /// platforms; it needs the read-back replies confirmed against hardware first. 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 = [] + /// One timing-history follow-up we've already asked for. Keyed on `day` as well as `cmd` — + /// today's queries are all `day 0`, but this engine already issues multi-day requests for sleep, + /// and a key without `day` would silently swallow day 1's frame-1 follow-up the moment the + /// timing vitals get the same backfill treatment. + private struct TimingFrameRequest: Hashable { + let cmd: Int + let day: Int + let frameIndex: Int + } + + /// Frame follow-ups already requested this poll pass, 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 @@ -54,14 +64,8 @@ final class CRPSyncEngine: RingSyncEngine { // 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). - // The 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old - // opcode was group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey`, not - // firmware. The real query is group 3 cmd 3 (`b1/l.k` → `d1/b.queryFirmwareVersion`), and it - // answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11. - send(CRPProtocol.queryFirmwareVersion()) if let profile { send(userInfoFrame(profile)) } - sendConnectionReadBacks() + sendConnectionQueries() // 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 @@ -73,24 +77,34 @@ final class CRPSyncEngine: RingSyncEngine { queryAllHistory() } - /// Whether this connection's read-backs have been sent. A fresh `CRPSyncEngine` is built per - /// connection (`RingBLEClient.installDriver` calls `driver.makeSyncEngine()` on connect), so - /// instance state gives "once per connection" for free. - private var readBacksSent = false + /// Whether the self-description queries have been sent on this engine instance. + /// + /// **"Once per connection" here means once per driver install, not once per GATT link.** Only + /// `beginConnect`/`adoptRememberedIdentity` call `RingBLEClient.installDriver` (which is what + /// builds this engine via `driver.makeSyncEngine()`); auto-reconnect re-dials with a bare + /// `central.connect` and keeps the same instance. So these queries survive a dropped link and are + /// not re-sent on the reconnect — which is the behaviour we want, since neither what the ring + /// supports nor the nights it holds change across a reconnect, and the `fdd2` channel is the + /// scarce resource. Don't "fix" this by resetting the flag in a lifecycle hook. + private var connectionQueriesSent = false /// Ask the ring to describe itself, once per connection. /// + /// Firmware version (`3/3`) answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11. The + /// 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old opcode was + /// group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey` (`b1/r.d`), not firmware. /// `querySupportSpO2Type` answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN; the timing-state /// queries report each all-day monitor's configured interval (0 = off). Together they are the /// evidence base for whether a silent history query means "the monitor is off" or "this ring - /// lacks the sensor" — stress (`2/47`), temperature (`2/22`) and firmware (formerly `7/1`) all - /// went unanswered on zaggash's ring, and these replies are how we tell those apart next capture. + /// lacks the sensor" — stress (`2/47`) and temperature (`2/22`) both went unanswered on zaggash's + /// ring, and these replies are how we tell those apart next capture. /// - /// Deliberately **not** part of the poll pass. `runStartup` doubles as the background re-sync, - /// but what a ring supports cannot change between syncs. Re-asking would add six writes to every - /// pass on a ring that funnels the handshake, timing config, history pull *and* on-demand - /// measures through the single `fdd2` channel — and a spot SpO2 needs ~48 s of that channel to - /// return a reading. + /// Deliberately **not** part of the poll pass. `runStartup` doubles as the background re-sync, but + /// neither a firmware string nor a sensor roster can change between syncs. Re-asking would add + /// seven writes to every pass on a ring that funnels the handshake, timing config, history pull + /// *and* on-demand measures through the single `fdd2` channel — and a spot SpO2 needs ~48 s of + /// that channel to return a reading. (Firmware used to be sent unconditionally here, which + /// contradicted that argument on the very next line.) /// /// **Call order matters: this must run BEFORE `applyTimingSettings`.** The state queries report /// each monitor's *current* interval, and `applyTimingSettings` force-enables everything moments @@ -98,9 +112,10 @@ final class CRPSyncEngine: RingSyncEngine { /// nothing — the whole point is to learn whether stress and temperature were silent because their /// monitor was off. `CRPSyncEngineTests` pins the ordering; if that assertion ever fails, fix the /// call site rather than the expectation. - private func sendConnectionReadBacks() { - if readBacksSent { return } - readBacksSent = true + private func sendConnectionQueries() { + if connectionQueriesSent { return } + connectionQueriesSent = true + send(CRPProtocol.queryFirmwareVersion()) send(CRPProtocol.querySupportSpO2Type()) send(CRPProtocol.queryTimingHeartRateState()) send(CRPProtocol.queryTimingHrvState()) @@ -124,8 +139,8 @@ final class CRPSyncEngine: RingSyncEngine { sendSleepBackfill() } - /// Whether this connection has already backfilled older nights. Same "fresh engine per - /// connection" trick as `readBacksSent`. + /// Whether older nights have already been backfilled on this engine instance. Same + /// once-per-driver-install scope as `connectionQueriesSent` — see there. private var sleepBackfillSent = false /// Pull the nights *before* today, once per connection. @@ -145,7 +160,11 @@ final class CRPSyncEngine: RingSyncEngine { private func sendSleepBackfill() { if sleepBackfillSent { return } sleepBackfillSent = true - for daysAgo in 1...crpSleepBackfillDays { send(CRPProtocol.queryHistorySleep(daysAgo: daysAgo)) } + // Half-open on purpose: `crpSleepBackfillDays` is documented as a knob to raise or lower, and + // `1...0` would trap at runtime if it were ever turned down to "today only". + for daysAgo in 1..<(crpSleepBackfillDays + 1) { + send(CRPProtocol.queryHistorySleep(daysAgo: daysAgo)) + } } /// The last frame index each timing vital emits before its day is complete (vendor terminal @@ -181,7 +200,8 @@ final class CRPSyncEngine: RingSyncEngine { 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 } + let request = TimingFrameRequest(cmd: cmd, day: day, frameIndex: nextIndex) + guard requestedTimingFrames.insert(request).inserted else { return } send(timingQuery(cmd: cmd, day: day, frameIndex: nextIndex)) } diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift index 162f75a..a457026 100644 --- a/PulseLoopTests/CRPDecoderTests.swift +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -179,6 +179,39 @@ final class CRPDecoderTests: XCTestCase { XCTAssertEqual(driver.ingest(Data(full.suffix(3)), from: fdd3).count, 1) } + /// The driver instance survives auto-reconnect (`didDisconnectPeripheral` re-dials with a bare + /// `central.connect`; only `installDriver` rebuilds it), so both lifecycle hooks must drop a + /// partial frame. Otherwise the new link's first bytes finish the old link's frame. + func testDriverDiscardsAPartialFrameAcrossAReconnect() { + let full = CRPProtocol.frame(group: 1, cmd: 9, payload: [0x50]) // size 7 + let resets: [(String, (CRPDriver) -> Void)] = [ + ("connectionDidStart", { $0.connectionDidStart() }), + ("connectionDidEnd", { $0.connectionDidEnd() }), + ] + for (name, reset) in resets { + let driver = CRPDriver(writer: nil) + XCTAssertTrue(driver.ingest(Data(full.prefix(4)), from: fdd3).isEmpty) + reset(driver) + XCTAssertTrue(driver.ingest(Data(full.suffix(3)), from: fdd3).isEmpty, + "\(name): the dropped link's tail must not complete a frame") + // The fresh link's own frames still decode. + XCTAssertEqual(driver.ingest(full, from: fdd3).count, 1, name) + } + } + + /// `.connected` fires on the first notify characteristic to report `isNotifying`, and that is + /// `fdd1` for CRP — never `fdd3`, which carries every command reply. Since `.connected` is what + /// runs `runStartup`, the handshake would otherwise write ~26 frames into a channel we aren't + /// listening to yet, and a lost reply is indistinguishable from a slow one. + func testDriverHoldsConnectedUntilTheCommandReplyChannelIsLive() { + let driver = CRPDriver(writer: nil) + XCTAssertEqual(driver.requiredSubscriptionsBeforeConnected, [CRPUUIDs.cmdNotifyCBUUID]) + // Must be a subset of notifyUUIDs or it could never be satisfied. + for uuid in driver.requiredSubscriptionsBeforeConnected { + XCTAssertTrue(driver.notifyUUIDs.contains(uuid), "\(uuid) is not a declared notify char") + } + } + // MARK: - Wear state (group 3 / cmd 7) /// `g1/a.java` decodes group3/cmd7 as `onWearStateChange(payload[0] > 0)`. `[00]` = not worn, @@ -415,6 +448,36 @@ final class CRPDecoderTests: XCTestCase { } } + /// Whatever this returns is shown verbatim in Settings, so a payload that isn't a version string + /// must ack rather than publish. `String(decoding:as:)` would have coerced both of these into + /// U+FFFD runs / control junk and presented them as a firmware version. + func testNonTextFirmwarePayloadsAreRejectedRatherThanCoerced() { + // Invalid UTF-8 (lone continuation bytes), and valid UTF-8 that is still binary junk. + for payload: [UInt8] in [[0xC3, 0x28, 0xA0, 0xFF], [0x01, 0x02, 0x03, 0x41]] { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, payload: payload) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck for \(payload)") + } + } + } + + // MARK: - Assembler lifecycle + + /// Auto-reconnect re-uses the same `CRPDriver`, so a frame left half-assembled when the old link + /// dropped would be completed with bytes from the new one and decoded as genuine data. Without the + /// reset the two halves below splice into one plausible-looking frame. + func testAssemblerResetDiscardsAPartialFrameFromADroppedLink() { + let a = CRPFrameAssembler() + let full = CRPProtocol.frame(group: 1, cmd: 9, payload: [1, 2, 3, 4]) // size 10 + XCTAssertNil(a.append(full.prefix(6))) // link drops mid-frame + a.reset() // connectionDidStart / connectionDidEnd + // The new link's leading bytes must not complete the old frame. + XCTAssertNil(a.append(full.suffix(4)), "continuation with no in-progress frame is noise") + // …and a whole frame after the reset still assembles normally. + XCTAssertEqual(a.append(full), full) + } + /// `CRPBloodOxygenType` defines exactly three values: 0 NOT_SUPPORT, 1 SLEEP_OXYGEN, /// 2 TIMING_OXYGEN. Only 1 and 2 are a claim of support. func testSpO2SupportGrantsCapabilitiesOnlyForTheTwoDocumentedTypes() { diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 1ef4ab5..8aab175 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -15,9 +15,9 @@ 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 read-backs, the all-day timing config and - /// the history pull, covered by their own tests below. + /// The connect handshake leads with set-time, then user info once a profile exists, then the + /// self-description queries. Everything after that is the all-day timing config and the history + /// pull, covered by their own tests below. func testRunStartupSendsSetTimeThenUserInfoOnceAProfileIsStored() { let w = FakeWriter() let engine = CRPSyncEngine(writer: w) @@ -29,7 +29,7 @@ final class CRPSyncEngineTests: XCTestCase { w.sent.removeAll() engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) engine.runStartup() - XCTAssertEqual(Array(w.opcodes.prefix(3)), [[1, 1], [3, 3], [1, 0]]) + XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [1, 0]]) } /// Nothing may target group 7 any more: every `b1/r` builder is a Gomore call, and the R11 @@ -62,14 +62,18 @@ final class CRPSyncEngineTests: XCTestCase { "read-backs must be asked before we impose a config") } - /// What a ring supports cannot change between syncs, and `runStartup` is also the background - /// re-sync — so re-asking would add six writes to every pass on a single-channel ring. - func testReadBacksAreSentOncePerConnectionNotPerPass() { + /// Neither a firmware string nor a sensor roster changes between syncs, and `runStartup` is also + /// the background re-sync — so re-asking would add seven writes to every pass on a single-channel + /// ring. Firmware is included: it used to be sent unconditionally, contradicting that argument. + func testConnectionQueriesAreSentOncePerConnectionNotPerPass() { let w = FakeWriter() let engine = CRPSyncEngine(writer: w) engine.runStartup() + XCTAssertTrue(w.opcodes.contains([3, 3]), "firmware asked on the first pass") + w.sent.removeAll() engine.runStartup() + XCTAssertFalse(w.opcodes.contains([3, 3]), "firmware must not repeat every pass") for cmd in [37, 6, 7, 8, 45, 21] { XCTAssertFalse(w.opcodes.contains([2, cmd]), "read-back group2/cmd\(cmd) must not repeat") } @@ -229,6 +233,21 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertEqual(w.sent.count, 1, "a new pass may re-request frame 1") } + /// The follow-up guard keys on `day` too. Today every timing query is `day 0`, but this engine + /// already issues multi-day requests for sleep, so a key without `day` would silently swallow + /// day 1's frame-1 follow-up the moment the timing vitals get the same backfill treatment. + func testFollowUpGuardDistinguishesDays() { + 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: 1, frameIndex: 0)) + XCTAssertEqual(w.sent.count, 2, "a different day is a different follow-up") + XCTAssertEqual(w.payloadByte(0, 6), 0) // day 0 + XCTAssertEqual(w.payloadByte(1, 6), 1) // day 1 + } + /// A non-timing event must not be mistaken for a history cursor. func testNonTimingEventsAreIgnoredByHandle() { let w = FakeWriter()