Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -344,7 +349,9 @@ final class EventPersistenceSubscriber {
// The rows are committed by now; the next sync re-checks against the database.
seenHistoryKeys.removeAll(keepingCapacity: true)
}
case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused, .workoutResumed, .workoutFinished, .coachTrace:
// `.wearState` is a live condition the measurement flow reacts to, not data — nothing to store.
case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused,
.workoutResumed, .workoutFinished, .coachTrace, .wearState:
break
}
// NB: no per-event save here — `scheduleFlush()` (called by `persist`) batches the save.
Expand Down
58 changes: 58 additions & 0 deletions PulseLoop/RingProtocol/CRPCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
@preconcurrency import CoreBluetooth

/// Coordinator for the CRP ("crrepa"/CRPsmart) `fdda`-profile family — official app Moyoung
/// "Da Rings" (`com.moyoung.ring`). Declares what `CRPDriver` can decode and how the ring is
/// recognised. See `CRPProtocol` and `decompiled-moyoung-official/`.
///
/// **Recognition / reachability.** The family's authoritative signal is the advertised `fdda`
/// service, matched below for completeness. In practice the CRP Colmi R11 advertises the generic name
/// `SMART_RING` with **no** service UUID pre-connect, so nothing matches it at scan and it falls back
/// to jring. The Android app re-routes to this driver once discovery reveals `fdda` post-connect;
/// iOS has no such post-connect driver swap, and instead — exactly as it separates the QRing vs
/// SmartHealth Colmi firmwares — relies on the user picking the "Colmi R11 (Da Rings app)" card
/// (`WearableModel.colmiR11CRP`), which routes `preferredFamily = .crp` to this coordinator up front.
///
/// **Bonding.** Unlike the Colmi-UART R11, the CRP ring connects GATT-only — the vendor app performs
/// no OS bond in its connect path (bonding there is a separate opt-in HID/camera feature). iOS's
/// CoreBluetooth has no explicit bond step in the connect path anyway, so there is nothing to gate.
@MainActor
final class CRPCoordinator: WearableCoordinator {
nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes)

static let deviceType: RingDeviceType = .crp

static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool {
// Only the family-exclusive `fdda` service claims a CRP ring at scan. The CRP R11 doesn't
// advertise it, so this is effectively never hit pre-connect — the user's carousel pick is
// the real entry point (see the class doc). Kept so a ring that *does* advertise `fdda` lands
// here rather than on the jring fallback.
advertisement.serviceUUIDs.contains(CRPUUIDs.serviceCBUUID)
}

/// Real-time vital capabilities backed by decoded group-1 replies (`g1/a.java` lines 664–712):
/// HR (cmd 9), HRV (cmd 10), SpO2 (cmd 11), stress (cmd 14), temperature (cmd 32).
///
/// The stored day timelines are decoded too: sleep (group-2/cmd-14) and the all-day "timing"
/// vital histories (HR/SpO2/HRV/stress, group-2/cmd 15/16/17/47) — see `CRPDecoder`. They are
/// pulled by `CRPSyncEngine.runStartup` and persisted through the event bridge; no capability
/// bit gates them, so none is claimed here.
///
/// `manualSpo2` is claimed alongside `manualHeartRate`: both surface a "Measure now" button in
/// Vitals, the start/stop commands are confirmed (`b1/h.d`), and cmd-11 results now decode.
///
/// Steps push (`fdd1`), battery (`2a19`), find-device also confirmed. Note: HR does NOT use the
/// standard `2a37` characteristic on CRP rings — all vital results come back as framed replies
/// on `fdd3` group 1.
let capabilities: Set<WearableCapability> = [
.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) }
}
Loading