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/CRPCoordinator.swift b/PulseLoop/RingProtocol/CRPCoordinator.swift new file mode 100644 index 0000000..1e9f385 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -0,0 +1,58 @@ +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) + } + + /// 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). + /// + /// 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, .manualSpo2, + .spo2, .stress, .hrv, .temperature, + .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..eb01466 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -0,0 +1,379 @@ +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 { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + 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) +/// - `fdd3` → framed `FD DA …` command replies (already reassembled by `CRPFrameAssembler`) +/// +/// 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 { + + /// `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, 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] { + 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))] + } + + /// 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. + 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 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 ack() + } + + /// Decode group-1 vital result replies. Confirmed against `g1/a.java` and `e1/f.java` (HR), + /// `e1/g.java` (HRV), `e1/d.java` (SpO2), `e1/h.java` (stress/physical strength), and the + /// vendor's `onMeasureComplete` flow for temperature (cmd 32). + /// + /// Layout: `payload[0]` is the metric value for all types. Plausibility guards prevent + /// garbage samples (HR 40–200, SpO2 70–100, stress 0–100, HRV 20–200). + private static func decodeVitalResult(cmd: Int, payload: [UInt8], now: Date) -> [RingDecodedEvent] { + guard !payload.isEmpty else { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDevice << 4) | (cmd & 0x0F)))] + } + let value = Int(payload[0]) + + switch cmd { + 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.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.cmdResultSpO2: + // SpO2 from `e1/d.b()`: byte2int(payload[0]). + guard value >= 70 && value <= 100 else { return [] } + return [.spo2Result(value: value, timestamp: now)] + + case CRPCommands.cmdResultStress: + // Stress/physical strength: byte2int(payload[0]). + guard value >= 0 && value <= 100 else { return [] } + return [.stressSample(value: 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. + return [.commandAck(commandId: UInt8(truncatingIfNeeded: (CRPCommands.groupDevice << 4) | (cmd & 0x0F)))] + } + } + + /// 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` + /// 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)))] + } + + 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/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..3827f2c --- /dev/null +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -0,0 +1,302 @@ +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. +/// +/// **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 + 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: 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. + 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) + + // 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 + 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. + 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) + } + + // 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 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 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 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 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 = CRPCommands.historyDayToday) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistorySleep, + payload: [UInt8(truncatingIfNeeded: daysAgo)]) + } + + static func queryHistoryTemp() -> Data { + frame(group: CRPCommands.groupHistory, 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 new file mode 100644 index 0000000..792eaf2 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -0,0 +1,181 @@ +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, 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 +/// 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? + + /// 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 + /// `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 + } + + 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()) + // Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report). + send(CRPProtocol.queryFirmwareVersion()) + if let profile { send(userInfoFrame(profile)) } + // 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. 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) + func startHeartRate() { send(CRPProtocol.measureHeartRate(true)) } + func stopHeartRate() { send(CRPProtocol.measureHeartRate(false)) } + + // MARK: - SpO2 (command verified; result parsing deferred, so 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)) + } + + // MARK: - Measurement settings + func setMeasurementSettings(_ settings: MeasurementSettings?) { + measurementSettings = settings + } + + func applyMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = 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)) } + 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 + /// 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/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/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/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 6fb60d7..bf34dc1 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() @@ -485,6 +493,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. @@ -537,12 +546,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() @@ -669,6 +681,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 .deviceStateChanged(.connected, _): lastSyncAt = Date() // Ring came back mid-workout: the new connection's engine doesn't know a stream was 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/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..767dab1 --- /dev/null +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -0,0 +1,400 @@ +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, +/// `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 + + // MARK: - Steps + + 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) + } + + // MARK: - Assembler + + 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]))) + } + + // 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. 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 { + 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.cmdResultSpO2, 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.cmdResultStress, 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. 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, 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() { + // 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) + 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) + } + + // 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/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..25e9b0a --- /dev/null +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -0,0 +1,163 @@ +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]) } + } + + /// 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() + // 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(Array(w.opcodes.prefix(3)), [[1, 1], [7, 1], [1, 0]]) + } + + 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 + } + + // 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) + } +} 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/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) 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)