diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 9a0c68e..8bc85a2 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) @@ -356,7 +361,9 @@ final class EventPersistenceSubscriber { // batched flush below saves the writes and fires the coalesced change signal. DailyCalorieEstimator.flushDirty(context: context) } - 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..c4c8635 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPCoordinator.swift @@ -0,0 +1,63 @@ +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. + /// SpO2 stays **unconditional** rather than bitmap-gated even though `CRPSyncEngine` now asks the + /// ring directly (`querySupportSpO2Type`, group 2 / cmd 37): the hardware is confirmed by a real + /// reading in zaggash's 2026-07-23 capture (`group 1 / cmd 11` payload `0x61` = 97 %), and + /// `refinedCapabilities` is additive-only, so gating it would only ever be a no-op or a + /// regression. The read-back is decoded for the raw-packet feed — see `CRPDecoder.decodeSpO2Support`. + /// + /// Steps push (`fdd1`), battery (`2a19`), find-device also confirmed. Note: HR does NOT use the + /// standard `2a37` characteristic on CRP rings — all vital results come back as framed replies + /// 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..07efcc1 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPDecoder.swift @@ -0,0 +1,455 @@ +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()`. +/// +/// **Must be reset when a link comes up.** Auto-reconnect re-uses the same `CRPDriver` instance — +/// `RingBLEClient.didDisconnectPeripheral` re-dials with a bare `central.connect`, and only +/// `beginConnect`/`adoptRememberedIdentity` ever call `installDriver` — so a frame left half-assembled +/// when the old link dropped would be completed with bytes from the new one and decoded as genuine. +/// The group-2 history frames are long and multi-notification, so the spliced result would be a +/// fabricated vital sample or sleep record, not an obvious parse failure. `CRPDriver.connectionDidStart` +/// calls `reset()`, matching `LuckRingDriver`/`YCBTDriver`. +final class CRPFrameAssembler { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private var buffer: [UInt8] = [] + private var expected = 0 + + /// Drop any partially-assembled frame. See the type doc — this is not optional bookkeeping. + func reset() { + buffer = [] + expected = 0 + } + + /// Feed one notification chunk. Returns the complete frame when the last chunk lands, else nil. + func append(_ chunk: Data) -> Data? { + if chunk.isEmpty { return nil } + 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; sleep/all-day history and the capability read-backs on + /// group 2; device identity and state pushes on group 3. Group 7 is the vendor's Gomore module, + /// not device info. + private static func decodeFramedReply(_ frame: Data, now: Date, calendar: Calendar) -> [RingDecodedEvent] { + let b = [UInt8](frame) + if b.count < CRPProtocol.headerSize { return [] } + let group = Int(b[4]) + let cmd = Int(b[5]) + let payload = b.count > CRPProtocol.headerSize ? Array(b[CRPProtocol.headerSize.. [RingDecodedEvent] { + [.commandAck(commandId: UInt8(truncatingIfNeeded: (group << 4) | (cmd & 0x0F)))] + } + + // Group 1: real-time vital results (decompiled `g1/a.java` lines 664–712). + if group == CRPCommands.groupDevice { + return decodeVitalResult(cmd: cmd, payload: payload, now: now) + } + + // Group 7: the vendor's Gomore module (`b1/r`). Nothing we send lands here any more; kept so + // an unsolicited Gomore frame in a capture is still recorded rather than dropped. + if group == CRPCommands.groupGomore { + return ack() + } + + // Group 2: sleep + the all-day "timing" vital timelines + temperature history + read-backs. + // cmd 14 → sleep (`e1/j`), confirmed against a hardware capture. + // cmd 15/16/17/47 → HR/HRV/SpO2/stress all-day timeline (`e1/{f,g,d,l}`), confirmed + // against zaggash's R11 capture (Android issue #29). + // cmd 37 → the ring's own SpO2-hardware answer. + // cmd 22 → temperature history, still an ack until a non-empty capture pins it. + if group == CRPCommands.groupHistory { + if cmd == CRPCommands.cmdQueryHistorySleep { + return decodeSleep(payload, now: now, calendar: calendar) + } + if cmd == CRPCommands.cmdQuerySupportSpO2Type { + return decodeSpO2Support(payload) + } + if let timing = decodeTimingHistory(cmd: cmd, payload: payload, now: now, calendar: calendar) { + return timing + } + return ack() + } + + // Group 3: device control, the firmware-version string (cmd 3), and the autonomous wear-state + // push (`g1/a.java` case 3→7, `onWearStateChange(payload[0] > 0)`). Confirmed against + // zaggash's R11: a spot measure returns nothing while `payload[0] == 0` (ring off the finger). + if group == CRPCommands.groupPower { + if cmd == CRPCommands.cmdWearState, let first = payload.first { + return [.wearingStatus(worn: first != 0, timestamp: now)] + } + if cmd == CRPCommands.cmdQueryFirmwareVersion, + let firmware = decodeFirmwareVersion(payload) { + // nil ⇒ nothing readable in the payload; fall through to the ack below. + return firmware + } + return ack() + } + + // 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 22) so the caller falls back to an ack. Layout, confirmed against + /// zaggash's R11 capture and the vendor parsers `e1/{f,g,d,l}.java`: + /// `[day][frameIndex][slot samples…]` — one 5-minute slot per sample, `0` = no reading. + /// HR/SpO2/stress use one byte per slot; HRV a little-endian 2-byte value. Each slot's absolute + /// 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 + } + + /// The firmware version string (`group 3 / cmd 3`). Vendor `g1/a.i1`: + /// `onVersion(new String(payload, StandardCharsets.UTF_8))` — a bare UTF-8 string with no length + /// prefix or terminator, e.g. `MOY-R1K3-2.1.6` on zaggash's R11 (Android issue #29). + /// + /// Returns `nil` when the payload holds nothing readable, so the caller acks it instead. + /// Surfaced as `.firmware(version:)`, which the event bridge maps to `.firmwareVersion` — a + /// device-info event, *not* a connection-state one. That distinction matters: the query rides + /// `CRPSyncEngine`'s connect handshake, and a reply that bridged to "connected" would restamp the + /// device row as freshly connected. + /// + /// Validated rather than coerced, because whatever comes back is shown verbatim in Settings. + /// `String(decoding:as:)` cannot fail — it substitutes U+FFFD for invalid bytes — so a binary + /// payload would render as a row of replacement characters *presented as a firmware version*. + /// `String(bytes:encoding:)` returns nil instead, and the control-character check rejects the + /// binary payloads that happen to be valid UTF-8. A real version (`MOY-R1K3-2.1.6`) passes both. + private static func decodeFirmwareVersion(_ payload: [UInt8]) -> [RingDecodedEvent]? { + guard let raw = String(bytes: payload, encoding: .utf8) else { return nil } + // Trim only what firmwares actually pad with — NUL and whitespace. Deliberately narrower than + // the vendor's `trim { it <= ' ' }`: trimming *all* control bytes first would strip a binary + // payload's leading junk and let whatever printable byte followed through as a "version" + // (`01 02 03 41` → "A"). Padding comes off, then anything still holding a control byte is + // rejected outright rather than salvaged. + let trimmed = raw.trimmingCharacters( + in: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\0")) + ) + if trimmed.isEmpty { return nil } + if trimmed.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) { + return nil + } + return [.firmware(version: trimmed)] + } + + /// The ring's own answer to "do you have SpO2 hardware?" (`group 2 / cmd 37`). Vendor `g1/a.V0` + /// hands `payload[0]` to `CRPBloodOxygenType`, which defines exactly three values: + /// **0 = NOT_SUPPORT, 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN** — `getInstance` returns nil for + /// anything else, so only 1 and 2 count as a claim of support. + /// + /// Treating "any non-zero" as support would be a real hazard on this ring: it uses `0xFF` as a + /// no-reading sentinel elsewhere (every failed spot SpO2 answers `group 1 / cmd 11 [FF]`), and a + /// `0xFF` here would otherwise read as a capability claim. + /// + /// **This is currently diagnostic, not capability-driving.** SpO2 is in `CRPCoordinator`'s + /// unconditional capabilities because it is hardware-confirmed: zaggash's 2026-07-23 capture has a + /// real reading (`group 1 / cmd 11` payload `0x61` = 97 %). `WearableCoordinator.refinedCapabilities` + /// is additive-only (`capabilities.union(bitmapGated ∩ derived)`), so a NOT_SUPPORT answer cannot + /// take SpO2 away — decoding it simply puts the ring's own answer in the raw-packet feed, where the + /// next capture can confirm or challenge what we assume. Acting on a NOT_SUPPORT would need a + /// subtractive mechanism that does not exist yet, and should not be invented without a ring that + /// actually reports one. + private static func decodeSpO2Support(_ payload: [UInt8]) -> [RingDecodedEvent] { + guard let type = payload.first else { + return [.commandAck(commandId: UInt8(truncatingIfNeeded: + (CRPCommands.groupHistory << 4) | (CRPCommands.cmdQuerySupportSpO2Type & 0x0F)))] + } + // Only the two documented "supported" values; 0 = NOT_SUPPORT and anything else is unknown. + // Report an empty set rather than nothing, so the feed records that the ring was asked. + let granted: Set = (type == 1 || type == 2) ? [.spo2, .manualSpo2] : [] + return [.supportFunctions(granted)] + } + + private struct SleepTransition { + 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..7481702 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPDriver.swift @@ -0,0 +1,81 @@ +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 driver is **not** rebuilt on every +/// link: only `beginConnect`/`adoptRememberedIdentity` call `RingBLEClient.installDriver`, while +/// auto-reconnect re-dials with a bare `central.connect`. So the assembler is reset in +/// `connectionDidStart()` rather than relying on a fresh instance (matches `LuckRingDriver`/`YCBTDriver`). +@MainActor +final class CRPDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + 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 + + /// Hold `.connected` until `fdd3` is live. Without this the connection counts as up on whichever + /// notify characteristic reports `isNotifying` first — for CRP that is `fdd1` (the steps push), + /// never `fdd3`, which carries *every* command reply. `.connected` is what runs `runStartup`, so a + /// handshake begun too early would write the clock, firmware query, read-backs, timing config and + /// the whole history pull into a channel we aren't listening to yet, and each lost reply is + /// indistinguishable from a slow one. Only `fdd3` is required: `fdd1`/`fdd6`/`2a37` carry no reply + /// the handshake waits on, so gating on them would only delay the connect. + let requiredSubscriptionsBeforeConnected: [CBUUID] = [CRPUUIDs.cmdNotifyCBUUID] + + // MARK: Lifecycle + + /// Auto-reconnect re-uses this driver instance (see the type doc), so a frame half-assembled when + /// the old link dropped would be completed with bytes from the new one and decoded as real data. + func connectionDidStart() { + assembler.reset() + } + + /// Symmetric with `connectionDidStart`, and the one point that cannot race the reconnect: clear + /// the partial frame the moment the link goes away rather than trusting the next connect to run. + func connectionDidEnd() { + assembler.reset() + } + + // MARK: Framing — the protocol/engine already build full CRP frames. + func frame(_ command: Data) -> Data { command } + + // 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..cdd36d5 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPProtocol.swift @@ -0,0 +1,384 @@ +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 toggles +/// on its own cmd (13) with `[1]`/`[0]`. (Per `d1/b.java` `enableTimingTemp`/`disableTimingTemp`, +/// both of which call `b1/i0.c(Bool)`.) +/// +/// **Resolve every opcode through its `d1/b.java` caller, never by position in the builder class.** +/// jadx alphabetises method names, so `b1/r`'s `a`/`b`/`c` order carries no meaning. Pairing methods +/// with opcodes positionally is what mislabelled the whole of group 7 as device info (see +/// `groupGomore`) and `3/1` as restart; both are corrected below. +enum CRPCommands { + // Group 1 — device config / measurement control. + static let groupDevice = 1 + 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]) — the SPOT toggle, not all-day + + // Group 1 — the ring answers a spot measure on the SAME cmd it was started with, so the + // result opcodes are aliases of the measure opcodes (vendor `g1/a.java` lines 664–712). + // 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 `[0]` on its own cmd. + static let cmdEnableTimingHR = 6 // b1/t.c: q.c(1,6, [interval]) + static let cmdEnableTimingHRV = 7 // b1/u.c: q.c(1,7, [interval]) + static let cmdEnableTimingSpO2 = 8 // b1/h.c: q.c(1,8, [interval]) + static let cmdEnableTimingStress = 39 // b1/h0.c: q.c(1,39, [interval]) + static let cmdEnableTimingTemp = 13 // b1/i0.c: q.c(1,13, [enable]) — all-day temp on/off + + // Group 7 is the vendor's **Gomore** group (the licensed activity-analytics module), NOT device + // info — every builder in `b1/r` resolves to a Gomore call in `d1/b.java`: + // q.b(7,0)=querySupportGomore q.b(7,1)=querySavedGomoreKey q.b(7,2)=queryGomoreEUID + // q.c(7,3,str)=sendGomoreKey q.b(7,13)=queryGomoreVersion + // The earlier constants here paired `b1/r`'s methods with opcodes positionally (a→0, b→1, c→13) + // and mislabelled all three as device info; `queryFirmwareVersion` was really + // `querySavedGomoreKey`, which is why zaggash's R11 answered none of the 23 sends (issue #29). + // Real device queries live on group 3 — see `groupPower`. Kept only so a capture containing + // these frames is still identifiable; nothing sends them. + static let groupGomore = 7 + static let cmdQuerySupportGomore = 0 // b1/r.e: q.b(7,0) → d1/b.querySupportGomore + static let cmdQuerySavedGomoreKey = 1 // b1/r.d: q.b(7,1) → d1/b.querySavedGomoreKey + static let cmdQueryGomoreVersion = 13 // b1/r.c: q.b(7,13) → d1/b.queryGomoreVersion + + // Group 2 — stored day history. The all-day "timing" vital timelines and sleep live HERE, not + // on group 7: the earlier group-7 opcodes were the device-info group and the ring answered every + // 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]) + /// Temperature history. **Not 48** — `q.b(2,48)` is the vendor's `querySleepState` (`b1/e0.d`, + /// `d1/b.java` line 650); the real temperature history is `i0.b(day, frameIndex)` = + /// `q.c(2,22, [day, idx])`, the same `[day, frameIndex]` shape as the other timing histories. We + /// queried 48 for months and the ring never answered — see zaggash's 2026-07-25 capture, 23 sends + /// and 0 replies. Its sample layout is still unconfirmed by a non-empty capture, so the reply + /// stays an ack for now. + static let cmdQueryHistoryTemp = 22 // b1/i0.b: q.c(2,22, [day, frameIndex]) + static let historyDayToday = 0 // CRPHistoryDay.TODAY; YESTERDAY = 1 + + // Group 2 — read-back queries. The ring can be *asked* what it supports and what is currently + // enabled, so the app doesn't have to guess (vendor `d1/b.java` querySupport*/queryTiming*State). + /// `b1/h.e`: q.b(2,37). Reply payload[0] is a `CRPBloodOxygenType`: 0 = NOT_SUPPORT, + /// 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN (`g1/a.V0` → `onSupportBloodOxygenType`). This is how a + /// ring reports its own SpO2 hardware instead of us inferring it from a marketing page. + static let cmdQuerySupportSpO2Type = 37 + /// The all-day monitor state queries. Each reply carries the configured interval in minutes + /// (`g1/a.{p1,r1,n1,t1}` → `onTimingInterval`); 0 means the monitor is off. + static let cmdQueryTimingHRState = 6 // b1/t.e: q.b(2,6) + static let cmdQueryTimingHRVState = 7 // b1/u.e: q.b(2,7) + static let cmdQueryTimingSpO2State = 8 // b1/h.f: q.b(2,8) + static let cmdQueryTimingTempState = 21 // b1/i0.a: q.b(2,21) → onTimingState(type, state) + static let cmdQueryTimingStressState = 45 // b1/h0.e: q.b(2,45) + + // Group 3 — device control, identity queries, and device-state pushes. (Named `groupPower` from + // when only the two power opcodes were known; the group is broader than the name.) Opcodes read + // off the `b1/l` builders via their `d1/b.java` callers. + static let groupPower = 3 + static let cmdFactoryReset = 0 // b1/l.v: q.b(3,0) → d1/b.reset + static let cmdShutDown = 1 // b1/l.y: q.b(3,1) → d1/b.shutDown (was mislabelled `cmdRestart`) + // Firmware identity — the pair the vendor's own "Firmware information" screen shows + // (`FirmwareInformationActivity`): version is a bare UTF-8 string, hash a hex code. + static let cmdQueryFirmwareVersion = 3 // b1/l.k: q.b(3,3) → d1/b.queryFirmwareVersion + static let cmdQueryFirmwareHash = 4 // b1/l.j: q.b(3,4) → d1/b.queryFirmwareHash + static let cmdQueryRealtimeBattery = 6 // b1/l.f: q.b(3,6) → d1/b.queryRealTimeBattery + static let cmdRestart = 14 // b1/l.w: q.b(3,14) → d1/b.restart + /// Autonomous push: `g1/a.java` decodes it as `onWearStateChange(payload[0] > 0)` — on-finger / + /// skin-contact detection. `[00]` = not worn, which is why an optical spot measure returns + /// nothing (Android issue #29 mis-diagnosis). + 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) + } + + // MARK: - Spot (manual) measurement toggles + // start(true)/stop(false) an on-demand reading; the ring reports back on the same cmd byte, + // decoded by `CRPDecoder.decodeVitalResult`. Mirrors the vendor's startMeasureX/stopMeasureX + // (`d1/b.java` → `b1/t.d`, `u.d`, `h.d`, `h0.d`, `i0.d`). + + static func measureHeartRate(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureHR, payload: [enable ? 1 : 0]) + } + + static func measureHRV(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureHRV, payload: [enable ? 1 : 0]) + } + + static func measureSpO2(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureSpO2, payload: [enable ? 1 : 0]) + } + + static func measureStress(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureStress, payload: [enable ? 1 : 0]) + } + + static func measureTemp(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdMeasureTemp, payload: [enable ? 1 : 0]) + } + + static func findDevice(_ enable: Bool) -> Data { + frame(group: CRPCommands.groupAction, cmd: CRPCommands.cmdFindDevice, payload: [enable ? 1 : 0]) + } + + 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 toggles on cmd 13 with `[1]`/`[0]` — `d1/b.enableTimingTemp`/`disableTimingTemp` both call + // `b1/i0.c(Bool)`. Cmd 32 (`b1/i0.d`) is the *spot* temp toggle, a different thing entirely. + static func enableTimingHeartRate(intervalMinutes: Int) -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingHR, payload: [UInt8(truncatingIfNeeded: intervalMinutes)]) + } + + 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, payload: [1]) + } + + static func disableTimingTemp() -> Data { + frame(group: CRPCommands.groupDevice, cmd: CRPCommands.cmdEnableTimingTemp, payload: [0]) + } + + // 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(day: Int = CRPCommands.historyDayToday, frameIndex: Int = 0) -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryHistoryTemp, + payload: [UInt8(truncatingIfNeeded: day), UInt8(truncatingIfNeeded: frameIndex)]) + } + + // MARK: - Read-back queries: let the ring tell us what it supports and what is enabled + + /// Ask whether this unit has SpO2 hardware at all. See `CRPCommands.cmdQuerySupportSpO2Type`. + static func querySupportSpO2Type() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQuerySupportSpO2Type) + } + + static func queryTimingHeartRateState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHRState) + } + + static func queryTimingHrvState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingHRVState) + } + + static func queryTimingSpO2State() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingSpO2State) + } + + static func queryTimingStressState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingStressState) + } + + static func queryTimingTempState() -> Data { + frame(group: CRPCommands.groupHistory, cmd: CRPCommands.cmdQueryTimingTempState) + } + + // MARK: - Device identity queries (group 3) + // `queryDeviceInfo`/`queryDeviceSN` are gone: they framed group-7 Gomore opcodes, which the ring + // never answers. Firmware version is the one the vendor's Firmware-information screen reads. + + static func queryFirmwareVersion() -> Data { + frame(group: CRPCommands.groupPower, cmd: CRPCommands.cmdQueryFirmwareVersion) + } +} diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift new file mode 100644 index 0000000..728ffb1 --- /dev/null +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -0,0 +1,281 @@ +import Foundation + +/// Nights before today to pull once per connection. See `CRPSyncEngine.sendSleepBackfill`. +private let crpSleepBackfillDays = 6 + +/// Per-connection orchestration for a CRP ("crrepa") ring. Ported in spirit from the Moyoung +/// "Da Rings" connect flow (`d1/b.java` + `b1` package builders): after the link is up the app sets +/// the clock and pushes user anthropometrics, then the ring streams current steps (`fdd1`) on its own +/// and answers measurement commands. +/// +/// Scope: clock + user-info handshake, spot HR + SpO2 (Measure button), all-day vital timing +/// enable/disable driven by `MeasurementSettings`, find-device. Steps and battery arrive as +/// autonomous pushes/reads (see `CRPDriver`). HRV / stress / temperature are all-day metrics — their +/// timing is enabled here and live results decode via `CRPDecoder`. Of the stored day timelines, +/// sleep (group-2/cmd-14) is decoded (`CRPDecoder.decodeSleep`, confirmed against a hardware +/// capture), and the group-2 all-day "timing" vital histories (HR/SpO2/HRV/stress) decode into +/// `.historyMeasurement` samples; their multi-frame replies reassemble via the next-frame follow-up +/// in `handle`. +/// +/// Factory reset / power off: the CRP command (`CRPProtocol.factoryReset`, group 3 / cmd 0) is known, +/// but iOS's `RingSyncEngine` exposes no factory-reset/power-off hook (the Colmi encoder has the +/// opcodes too, with no invocation path), so there is nothing to wire it into here — which is why +/// `CRPCoordinator` doesn't claim `.factoryReset` even though the Android coordinator does. +@MainActor +final class CRPSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + 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, and a fresh R11 + /// ships with every all-day monitor OFF, so it records nothing to sync. We therefore fall back to + /// `MeasurementSettings.allOnDefault` (matching how `ColmiSyncEngine` force-enables on connect) + /// so the day timeline actually accumulates. + /// + /// Note this is a *forced* default, not a read-back: `sendConnectionReadBacks` now asks the ring + /// for each monitor's current interval, but the replies are only surfaced as diagnostics — we + /// still impose a config rather than adopting the ring's. Matching the vendor here (query state, + /// apply the saved config, leave the ring alone otherwise) is a known open divergence on both + /// platforms; it needs the read-back replies confirmed against hardware first. + private var measurementSettings: MeasurementSettings? + + /// One timing-history follow-up we've already asked for. Keyed on `day` as well as `cmd` — + /// today's queries are all `day 0`, but this engine already issues multi-day requests for sleep, + /// and a key without `day` would silently swallow day 1's frame-1 follow-up the moment the + /// timing vitals get the same backfill treatment. + private struct TimingFrameRequest: Hashable { + let cmd: Int + let day: Int + let frameIndex: Int + } + + /// Frame follow-ups already requested this poll pass, so a ring that re-sends the same frame + /// can't trigger a request storm. Cleared at the start of every `queryAllHistory` pass so each + /// sync re-pulls the full timeline. + private var requestedTimingFrames: Set = [] + + init(writer: RingCommandWriter?) { + self.writer = writer + } + + func runStartup() { + // Set the device clock first (matches the vendor's connect handshake), then user info so + // the ring's step/calorie algorithm has real inputs. + send(CRPProtocol.setTime()) + if let profile { send(userInfoFrame(profile)) } + sendConnectionQueries() + // Enable all-day vital monitoring. A fresh ring has these OFF, so without this the ring + // stores no HR/SpO2/HRV/stress/temperature history and every history query below returns an + // empty reply (Android issue #29, zaggash's full-day capture). When the user has saved a + // 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() + } + + /// Whether the self-description queries have been sent on this engine instance. + /// + /// **"Once per connection" here means once per driver install, not once per GATT link.** Only + /// `beginConnect`/`adoptRememberedIdentity` call `RingBLEClient.installDriver` (which is what + /// builds this engine via `driver.makeSyncEngine()`); auto-reconnect re-dials with a bare + /// `central.connect` and keeps the same instance. So these queries survive a dropped link and are + /// not re-sent on the reconnect — which is the behaviour we want, since neither what the ring + /// supports nor the nights it holds change across a reconnect, and the `fdd2` channel is the + /// scarce resource. Don't "fix" this by resetting the flag in a lifecycle hook. + private var connectionQueriesSent = false + + /// Ask the ring to describe itself, once per connection. + /// + /// Firmware version (`3/3`) answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11. The + /// 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old opcode was + /// group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey` (`b1/r.d`), not firmware. + /// `querySupportSpO2Type` answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN; the timing-state + /// queries report each all-day monitor's configured interval (0 = off). Together they are the + /// evidence base for whether a silent history query means "the monitor is off" or "this ring + /// lacks the sensor" — stress (`2/47`) and temperature (`2/22`) both went unanswered on zaggash's + /// ring, and these replies are how we tell those apart next capture. + /// + /// Deliberately **not** part of the poll pass. `runStartup` doubles as the background re-sync, but + /// neither a firmware string nor a sensor roster can change between syncs. Re-asking would add + /// seven writes to every pass on a ring that funnels the handshake, timing config, history pull + /// *and* on-demand measures through the single `fdd2` channel — and a spot SpO2 needs ~48 s of + /// that channel to return a reading. (Firmware used to be sent unconditionally here, which + /// contradicted that argument on the very next line.) + /// + /// **Call order matters: this must run BEFORE `applyTimingSettings`.** The state queries report + /// each monitor's *current* interval, and `applyTimingSettings` force-enables everything moments + /// later. Ask afterwards and every reply describes the state we just imposed, which answers + /// nothing — the whole point is to learn whether stress and temperature were silent because their + /// monitor was off. `CRPSyncEngineTests` pins the ordering; if that assertion ever fails, fix the + /// call site rather than the expectation. + private func sendConnectionQueries() { + if connectionQueriesSent { return } + connectionQueriesSent = true + send(CRPProtocol.queryFirmwareVersion()) + send(CRPProtocol.querySupportSpO2Type()) + send(CRPProtocol.queryTimingHeartRateState()) + send(CRPProtocol.queryTimingHrvState()) + send(CRPProtocol.queryTimingSpO2State()) + send(CRPProtocol.queryTimingStressState()) + send(CRPProtocol.queryTimingTempState()) + } + + /// Request the stored all-day timelines the ring has accumulated: the group-2 "timing" vital + /// timelines (HR/SpO2/HRV/stress), temperature, and sleep. Vendor `u3/g1.java` fires the same set + /// on its sync pass. Each timing query pulls frame 0; the reply drives `handle` to pull the next + /// 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()) + sendSleepBackfill() + } + + /// Whether older nights have already been backfilled on this engine instance. Same + /// once-per-driver-install scope as `connectionQueriesSent` — see there. + private var sleepBackfillSent = false + + /// Pull the nights *before* today, once per connection. + /// + /// The poll pass above only ever asks for `daysAgo = 0`, so the app's stored history could only + /// ever grow one night at a time from whenever the user installed. Asking for the ring's own + /// back-catalogue is what actually restores a user's history. + /// + /// Safe to send blind. Each reply is self-describing: `payload[0]` is the ring's own day index, + /// so `CRPDecoder.decodeSleep` dates a night from the reply rather than from what we asked for, + /// and a day the ring has no record of simply produces no reply — the same nothing we get today. + /// + /// Once per connection, and deliberately short of the decoder's 14-day ceiling: `runStartup` is + /// also the background sync, and this ring funnels the handshake, timing config, history pull + /// *and* on-demand measures through one `fdd2` channel (a spot SpO2 needs ~48 s of it). A week is + /// the useful-recovery/quiet-channel trade; raise it once hardware shows the ring answers deeper. + private func sendSleepBackfill() { + if sleepBackfillSent { return } + sleepBackfillSent = true + // Half-open on purpose: `crpSleepBackfillDays` is documented as a knob to raise or lower, and + // `1...0` would trap at runtime if it were ever turned down to "today only". + for daysAgo in 1..<(crpSleepBackfillDays + 1) { + send(CRPProtocol.queryHistorySleep(daysAgo: daysAgo)) + } + } + + /// The last frame index each timing vital emits before its day is complete (vendor terminal + /// 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. + let request = TimingFrameRequest(cmd: cmd, day: day, frameIndex: nextIndex) + guard requestedTimingFrames.insert(request).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 + /// Takes a non-optional `MeasurementSettings` because that is `RingSyncEngine`'s requirement. + /// It used to take `MeasurementSettings?`, which is a *different* signature — so it satisfied + /// nothing, the protocol's no-op default extension supplied conformance instead, and every + /// `RingSyncCoordinator` call landed there. The user's saved config was silently discarded and + /// `runStartup` always fell back to `.allOnDefault`. + func setMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + } + + 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 fdec546..24924a1 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -47,9 +47,16 @@ final class RingBLEClient: NSObject { ColmiCoordinator.self, LuckRingCoordinator.self, TK5Coordinator.self, - // Last is the zero-risk slot: RWfit matches only family-exclusive signals (the `A00A` - // service; company IDs `0x05D6`/`0x06D6`) that no coordinator above claims, and it matches - // no names at all, so it can neither shadow nor be shadowed. + // The last two are the zero-risk slots: each matches only family-exclusive signals that no + // coordinator above claims, neither matches any name, and their signals are disjoint — so + // neither can shadow or be shadowed, and their order relative to each other is free. + // + // CRP matches the `fdda` service — which the CRP R11 doesn't even advertise pre-connect, so + // 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 re-route like Android's. + CRPCoordinator.self, + // Last is RWfit's documented slot, pinned by `testRWfitIsRegisteredLast`: the `A00A` service + // and company IDs `0x05D6`/`0x06D6`, no name matching at all. RWfitCoordinator.self, ] 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 35fb67a..7e3e1a6 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -226,6 +226,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() @@ -488,6 +496,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. @@ -540,12 +549,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() @@ -672,6 +684,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 a4941cc..a0e0a06 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -245,6 +245,9 @@ struct DeviceHeroCard: View { case .ycbt: return "r10m" // No RWfit hardware captured yet, so no product art — the generic ring is the honest choice. case .rwfit: return nil + // 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 985ff82..9617a54 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -26,6 +26,12 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { /// advertisement cannot tell them apart; the driver picks the framing after service discovery. /// See `RWfitCoordinator`. case rwfit + /// 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 { @@ -37,6 +43,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .luckRing: return "LuckRing" case .ycbt: return "YCBT / SmartHealth ring" case .rwfit: return "RWfit ring" + case .crp: return "Colmi / Moyoung ring (CRP)" } } } diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 01afd42..1a6b797 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -55,7 +55,9 @@ enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { case .colmiSmartHealth: self = .smartHealth // RWfit's two firmwares differ in *wire framing*, not app — one family, and the driver // detects the framing from the GATT, so there is nothing for the user to declare. - case .jring, .tk5, .luckRing, .ycbt, .rwfit: return nil + // CRP is the converse: the app *is* the distinction, but it's declared by picking the + // "Colmi R11 (Da Rings app)" card, so by the time we're here the family is already settled. + case .jring, .tk5, .luckRing, .ycbt, .rwfit, .crp: return nil } } @@ -122,6 +124,10 @@ extension RingDeviceType { // Reconstructed entirely from the vendor app's decompiled source, no hardware seen yet — // every layout is cited in docs/hardware/rwfit.md and awaits the first diagnostics capture. case .rwfit: return .limited + // Reconstructed from the decompiled "Da Rings" app. Parts are capture-confirmed against + // zaggash's ring (sleep, the all-day vital timelines, a real SpO₂ reading), but the newest + // opcodes aren't yet — so it keeps the "Limited support" badge until a full validation pass. + case .crp: return .limited } } } @@ -220,6 +226,18 @@ extension WearableModel { advertisedNamePatterns: [] ) + /// 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}$") @@ -352,8 +370,9 @@ extension WearableModel { r10m, tk5, luckRingTK18, - // Position is irrelevant for matching — the RWfit card has no name patterns to race. + // Position is irrelevant for matching — neither card has any name patterns to race. rwfitRing, + colmiR11CRP, ] static func model(id: String?) -> WearableModel? { diff --git a/PulseLoopTests/CRPDecoderTests.swift b/PulseLoopTests/CRPDecoderTests.swift new file mode 100644 index 0000000..a457026 --- /dev/null +++ b/PulseLoopTests/CRPDecoderTests.swift @@ -0,0 +1,536 @@ +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) + } + + /// The driver instance survives auto-reconnect (`didDisconnectPeripheral` re-dials with a bare + /// `central.connect`; only `installDriver` rebuilds it), so both lifecycle hooks must drop a + /// partial frame. Otherwise the new link's first bytes finish the old link's frame. + func testDriverDiscardsAPartialFrameAcrossAReconnect() { + let full = CRPProtocol.frame(group: 1, cmd: 9, payload: [0x50]) // size 7 + let resets: [(String, (CRPDriver) -> Void)] = [ + ("connectionDidStart", { $0.connectionDidStart() }), + ("connectionDidEnd", { $0.connectionDidEnd() }), + ] + for (name, reset) in resets { + let driver = CRPDriver(writer: nil) + XCTAssertTrue(driver.ingest(Data(full.prefix(4)), from: fdd3).isEmpty) + reset(driver) + XCTAssertTrue(driver.ingest(Data(full.suffix(3)), from: fdd3).isEmpty, + "\(name): the dropped link's tail must not complete a frame") + // The fresh link's own frames still decode. + XCTAssertEqual(driver.ingest(full, from: fdd3).count, 1, name) + } + } + + /// `.connected` fires on the first notify characteristic to report `isNotifying`, and that is + /// `fdd1` for CRP — never `fdd3`, which carries every command reply. Since `.connected` is what + /// runs `runStartup`, the handshake would otherwise write ~26 frames into a channel we aren't + /// listening to yet, and a lost reply is indistinguishable from a slow one. + func testDriverHoldsConnectedUntilTheCommandReplyChannelIsLive() { + let driver = CRPDriver(writer: nil) + XCTAssertEqual(driver.requiredSubscriptionsBeforeConnected, [CRPUUIDs.cmdNotifyCBUUID]) + // Must be a subset of notifyUUIDs or it could never be satisfied. + for uuid in driver.requiredSubscriptionsBeforeConnected { + XCTAssertTrue(driver.notifyUUIDs.contains(uuid), "\(uuid) is not a declared notify char") + } + } + + // MARK: - Wear state (group 3 / cmd 7) + + /// `g1/a.java` decodes group3/cmd7 as `onWearStateChange(payload[0] > 0)`. `[00]` = not worn, + /// 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) + } + + // MARK: - Firmware version + capability read-backs + + /// `group 3 / cmd 3` carries a bare UTF-8 string with no length prefix or terminator + /// (`g1/a.i1`: `onVersion(new String(payload, UTF_8))`) — `MOY-R1K3-2.1.6` on zaggash's R11. + func testFirmwareVersionDecodesAsABareUTF8String() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: Array("MOY-R1K3-2.1.6".utf8)) + guard case let .firmware(version) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected firmware") + } + XCTAssertEqual(version, "MOY-R1K3-2.1.6") + } + + /// Some firmwares pad the frame to a fixed width; NUL padding must not survive into the UI. + func testFirmwareVersionTrimsNulPadding() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: Array("V1.2".utf8) + [0, 0, 0]) + guard case let .firmware(version) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected firmware") + } + XCTAssertEqual(version, "V1.2") + } + + /// An empty/all-padding payload has no version in it — ack rather than publish an empty string. + func testEmptyFirmwarePayloadFallsBackToAnAck() { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, + payload: [0, 0]) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + + /// Whatever this returns is shown verbatim in Settings, so a payload that isn't a version string + /// must ack rather than publish. `String(decoding:as:)` would have coerced both of these into + /// U+FFFD runs / control junk and presented them as a firmware version. + func testNonTextFirmwarePayloadsAreRejectedRatherThanCoerced() { + // Invalid UTF-8 (lone continuation bytes), and valid UTF-8 that is still binary junk. + for payload: [UInt8] in [[0xC3, 0x28, 0xA0, 0xFF], [0x01, 0x02, 0x03, 0x41]] { + let frame = CRPProtocol.frame(group: CRPCommands.groupPower, + cmd: CRPCommands.cmdQueryFirmwareVersion, payload: payload) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck for \(payload)") + } + } + } + + // MARK: - Assembler lifecycle + + /// Auto-reconnect re-uses the same `CRPDriver`, so a frame left half-assembled when the old link + /// dropped would be completed with bytes from the new one and decoded as genuine data. Without the + /// reset the two halves below splice into one plausible-looking frame. + func testAssemblerResetDiscardsAPartialFrameFromADroppedLink() { + let a = CRPFrameAssembler() + let full = CRPProtocol.frame(group: 1, cmd: 9, payload: [1, 2, 3, 4]) // size 10 + XCTAssertNil(a.append(full.prefix(6))) // link drops mid-frame + a.reset() // connectionDidStart / connectionDidEnd + // The new link's leading bytes must not complete the old frame. + XCTAssertNil(a.append(full.suffix(4)), "continuation with no in-progress frame is noise") + // …and a whole frame after the reset still assembles normally. + XCTAssertEqual(a.append(full), full) + } + + /// `CRPBloodOxygenType` defines exactly three values: 0 NOT_SUPPORT, 1 SLEEP_OXYGEN, + /// 2 TIMING_OXYGEN. Only 1 and 2 are a claim of support. + func testSpO2SupportGrantsCapabilitiesOnlyForTheTwoDocumentedTypes() { + for type in [1, 2] { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQuerySupportSpO2Type, + payload: [UInt8(type)]) + guard case let .supportFunctions(caps) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected supportFunctions for type \(type)") + } + XCTAssertEqual(caps, [.spo2, .manualSpo2]) + } + } + + /// `0xFF` is this ring's no-reading sentinel (a failed spot SpO2 answers `1/11 [FF]`). Treating + /// "any non-zero" as support would read that sentinel as a capability claim. + func testSpO2SupportRejectsNotSupportAndTheFFSentinel() { + for type: UInt8 in [0, 0xFF] { + let frame = CRPProtocol.frame(group: CRPCommands.groupHistory, + cmd: CRPCommands.cmdQuerySupportSpO2Type, + payload: [type]) + guard case let .supportFunctions(caps) = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected supportFunctions for type \(type)") + } + XCTAssertTrue(caps.isEmpty, "type \(type) must not claim SpO2") + } + } + + /// Group 7 is the vendor's Gomore module. Nothing we send lands there, but an unsolicited frame + /// should still be recorded rather than dropped. + func testGomoreGroupRepliesAreAcked() { + let frame = CRPProtocol.frame(group: CRPCommands.groupGomore, + cmd: CRPCommands.cmdQuerySupportGomore, payload: [1]) + guard case .commandAck = CRPDecoder.decode(frame, from: fdd3).first else { + return XCTFail("expected commandAck") + } + } + + /// A night that starts before midnight: the first record reads later on the clock than the last, + /// so the anchor rolls back a day rather than placing the night in the wrong evening. + func testSleepAnchorsAnEveningStartBeforeMidnight() { + 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..689d731 --- /dev/null +++ b/PulseLoopTests/CRPProtocolTests.swift @@ -0,0 +1,109 @@ +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])) + } + + // MARK: - Opcodes corrected against their `d1/b.java` callers + + /// `b1/l.k` → `d1/b.queryFirmwareVersion` = `q.b(3,3)`. The old `7/1` was `b1/r.d`, the vendor's + /// `querySavedGomoreKey` — which is why the R11 answered none of the 23 sends (Android issue #29). + func testFirmwareVersionQueryIsGroup3Cmd3() { + XCTAssertEqual(CRPProtocol.queryFirmwareVersion(), Data([0xFD, 0xDA, 0x10, 6, 3, 3])) + } + + /// `b1/i0.b` → `q.c(2,22,[day,idx])`, the same `[day, frameIndex]` shape as the other timing + /// histories. `q.b(2,48)` is `b1/e0.d` = `querySleepState`, not temperature. + func testTemperatureHistoryIsGroup2Cmd22WithDayAndFrameIndex() { + XCTAssertEqual(CRPProtocol.queryHistoryTemp(), Data([0xFD, 0xDA, 0x10, 8, 2, 22, 0, 0])) + XCTAssertEqual(CRPProtocol.queryHistoryTemp(day: 1, frameIndex: 2), + Data([0xFD, 0xDA, 0x10, 8, 2, 22, 1, 2])) + } + + /// Both temp toggles ride cmd 13 with an enable byte — `d1/b.enableTimingTemp` and + /// `disableTimingTemp` both call `b1/i0.c(Bool)`. Cmd 32 (`b1/i0.d`) is the *spot* toggle; + /// sending it as a disable would have started a one-shot measurement instead. + func testTempAllDayTogglesShareCmd13AndDifferOnlyInTheEnableByte() { + XCTAssertEqual(CRPProtocol.enableTimingTemp(), Data([0xFD, 0xDA, 0x10, 7, 1, 13, 1])) + XCTAssertEqual(CRPProtocol.disableTimingTemp(), Data([0xFD, 0xDA, 0x10, 7, 1, 13, 0])) + XCTAssertEqual(CRPProtocol.measureTemp(true), Data([0xFD, 0xDA, 0x10, 7, 1, 32, 1])) + } + + /// The read-backs that let the ring describe itself, all on group 2 with no payload. + func testReadBackQueriesUseTheirVendorOpcodes() { + XCTAssertEqual(CRPProtocol.querySupportSpO2Type(), Data([0xFD, 0xDA, 0x10, 6, 2, 37])) // b1/h.e + XCTAssertEqual(CRPProtocol.queryTimingHeartRateState(), Data([0xFD, 0xDA, 0x10, 6, 2, 6])) // b1/t.e + XCTAssertEqual(CRPProtocol.queryTimingHrvState(), Data([0xFD, 0xDA, 0x10, 6, 2, 7])) // b1/u.e + XCTAssertEqual(CRPProtocol.queryTimingSpO2State(), Data([0xFD, 0xDA, 0x10, 6, 2, 8])) // b1/h.f + XCTAssertEqual(CRPProtocol.queryTimingStressState(), Data([0xFD, 0xDA, 0x10, 6, 2, 45])) // b1/h0.e + XCTAssertEqual(CRPProtocol.queryTimingTempState(), Data([0xFD, 0xDA, 0x10, 6, 2, 21])) // b1/i0.a + } + + /// The remaining spot-measure toggles, each `[enable]` on its own group-1 cmd. + func testSpotMeasureTogglesCoverEveryVital() { + XCTAssertEqual(CRPProtocol.measureHRV(true), Data([0xFD, 0xDA, 0x10, 7, 1, 10, 1])) + XCTAssertEqual(CRPProtocol.measureStress(false), Data([0xFD, 0xDA, 0x10, 7, 1, 14, 0])) + } +} diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift new file mode 100644 index 0000000..8aab175 --- /dev/null +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -0,0 +1,261 @@ +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 leads with set-time, then user info once a profile exists, then the + /// self-description queries. Everything after that is the all-day timing config and the history + /// pull, covered by their own tests below. + func testRunStartupSendsSetTimeThenUserInfoOnceAProfileIsStored() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + // set-time, then the firmware query that keeps the UI off "Firmware: reading". Firmware is + // group 3 / cmd 3 (`b1/l.k`) — the old group-7 opcode was the vendor's Gomore module. + XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [3, 3]]) + + w.sent.removeAll() + engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) + engine.runStartup() + XCTAssertEqual(Array(w.opcodes.prefix(2)), [[1, 1], [1, 0]]) + } + + /// Nothing may target group 7 any more: every `b1/r` builder is a Gomore call, and the R11 + /// answered none of the 23 sends in zaggash's 2026-07-25 capture (Android issue #29). + func testNothingIsSentToTheGomoreGroup() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) + engine.runStartup() + XCTAssertFalse(w.opcodes.contains { $0[0] == 7 }, "group 7 is Gomore, not device info") + } + + // MARK: - Connection read-backs + + /// The read-backs must precede `applyTimingSettings`: they report each monitor's *current* + /// interval, and the timing config force-enables everything moments later. Asked afterwards, + /// every reply would describe the state we just imposed. + func testReadBacksAreSentBeforeTheTimingConfig() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + // Read-backs: SpO2 support 2/37, then the monitor-state queries 2/6, 2/7, 2/8, 2/45, 2/21. + for cmd in [37, 6, 7, 8, 45, 21] { + XCTAssertTrue(w.opcodes.contains([2, cmd]), "expected read-back group2/cmd\(cmd)") + } + guard let lastReadBack = w.opcodes.lastIndex(where: { $0[0] == 2 && [37, 6, 7, 8, 45, 21].contains($0[1]) }), + let firstTimingConfig = w.opcodes.firstIndex(where: { $0[0] == 1 && [6, 7, 8, 39, 13].contains($0[1]) }) + else { return XCTFail("missing read-backs or timing config") } + XCTAssertLessThan(lastReadBack, firstTimingConfig, + "read-backs must be asked before we impose a config") + } + + /// Neither a firmware string nor a sensor roster changes between syncs, and `runStartup` is also + /// the background re-sync — so re-asking would add seven writes to every pass on a single-channel + /// ring. Firmware is included: it used to be sent unconditionally, contradicting that argument. + func testConnectionQueriesAreSentOncePerConnectionNotPerPass() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + XCTAssertTrue(w.opcodes.contains([3, 3]), "firmware asked on the first pass") + + w.sent.removeAll() + engine.runStartup() + XCTAssertFalse(w.opcodes.contains([3, 3]), "firmware must not repeat every pass") + for cmd in [37, 6, 7, 8, 45, 21] { + XCTAssertFalse(w.opcodes.contains([2, cmd]), "read-back group2/cmd\(cmd) must not repeat") + } + } + + // MARK: - Sleep backfill + + /// The poll pass only asks for `daysAgo = 0`, so without a backfill the app's stored history + /// could only grow one night at a time. Each reply is self-describing, so this is safe to send + /// blind — a day the ring has no record of simply produces no reply. + func testSleepBackfillPullsThePriorWeekOncePerConnection() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + let sleepDays = w.opcodes.indices + .filter { w.opcodes[$0] == [2, 14] } + .map { w.payloadByte($0, 6) } + XCTAssertEqual(sleepDays, [0, 1, 2, 3, 4, 5, 6], "today plus six nights of backfill") + + w.sent.removeAll() + engine.runStartup() + let secondPass = w.opcodes.indices + .filter { w.opcodes[$0] == [2, 14] } + .map { w.payloadByte($0, 6) } + XCTAssertEqual(secondPass, [0], "the backfill is once per connection; the pass re-pulls today only") + } + + func testHeartRateStartAndStopEnqueueGroup1Cmd9() { + 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, 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)") + } + } + + /// A saved config must actually reach the ring. `setMeasurementSettings` once took an Optional — + /// a signature that satisfied no protocol requirement, so `RingSyncCoordinator`'s call hit the + /// no-op default extension and the user's config was silently dropped in favour of `.allOnDefault`. + func testSavedMeasurementSettingsAreHonouredOverTheForcedDefault() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + var settings = MeasurementSettings.allOnDefault + settings.spo2Enabled = false + engine.setMeasurementSettings(settings) + engine.runStartup() + guard let index = w.opcodes.firstIndex(of: [1, 8]) else { return XCTFail("no SpO2 timing command") } + XCTAssertEqual(w.payloadByte(index, 6), 0, "a disabled vital sends interval 0, not the default") + } + + /// The history pull uses the group-2 opcodes. Temperature is cmd **22** (`b1/i0.b`) — cmd 48 is + /// the vendor's `querySleepState`, which is why the ring never answered the old query. + func testRunStartupQueriesHistoryOnGroupTwo() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + for cmd in [15, 17, 16, 47, 22, 14] { // HR, SpO2, HRV, stress, temp, sleep + XCTAssertTrue(w.opcodes.contains([2, cmd]), "expected group2/cmd\(cmd) history query") + } + XCTAssertFalse(w.opcodes.contains([2, 48]), "cmd 48 is querySleepState, not temperature history") + } + + /// 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") + } + + /// The follow-up guard keys on `day` too. Today every timing query is `day 0`, but this engine + /// already issues multi-day requests for sleep, so a key without `day` would silently swallow + /// day 1's frame-1 follow-up the moment the timing vitals get the same backfill treatment. + func testFollowUpGuardDistinguishesDays() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.runStartup() + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: 15, day: 0, frameIndex: 0)) + engine.handle(.timingHistoryFrame(cmd: 15, day: 1, frameIndex: 0)) + XCTAssertEqual(w.sent.count, 2, "a different day is a different follow-up") + XCTAssertEqual(w.payloadByte(0, 6), 0) // day 0 + XCTAssertEqual(w.payloadByte(1, 6), 1) // day 1 + } + + /// A non-timing event must not be mistaken for a history cursor. + func testNonTimingEventsAreIgnoredByHandle() { + let w = FakeWriter() + 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 4588fc9..3e6a755 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -740,6 +740,57 @@ final class PairingMatchingTests: XCTestCase { "rwfit-ring") } + // 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() { @@ -756,6 +807,7 @@ final class PairingMatchingTests: XCTestCase { func testLimitedSupportFamiliesCarryTheBadge() { let limitedByDefault: Set = [ WearableModel.tk5.id, WearableModel.luckRingTK18.id, WearableModel.rwfitRing.id, + WearableModel.colmiR11CRP.id, ] for model in WearableModel.catalog { let expected: WearableSupportLevel = limitedByDefault.contains(model.id) ? .limited : .full diff --git a/PulseLoopTests/YCBTDecoderTests.swift b/PulseLoopTests/YCBTDecoderTests.swift index 5df703f..b31773f 100644 --- a/PulseLoopTests/YCBTDecoderTests.swift +++ b/PulseLoopTests/YCBTDecoderTests.swift @@ -162,8 +162,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)