Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "colmi-ring-2-pro.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions PulseLoop/RingProtocol/ColmiDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ struct ColmiDecoder {
return [.battery(percent: Int(v[1]))]

case ColmiCommandID.manualHeartRate:
// An on-demand HRV / stress / temperature run streams on the same opcode, told apart by
// its reading type in byte 1. Every other type keeps the heart-rate decode below.
if let vital = ColmiCommandID.spotVital(readingType: v[1]) {
return Self.decodeSpotVital(vital, errorCode: v[2], raw: Int(v[3]), now: now)
}
// 69 <?> <errorCode> <bpm>. errorCode: 0=ok, else worn-incorrectly/temporary.
// The manual measurement is a *continuous stream* that warms up emitting bpm 0 for ~25s
// before real readings — so a 0 with no error is warm-up, NOT a terminal "no reading".
Expand Down Expand Up @@ -340,6 +345,23 @@ struct ColmiDecoder {
return events
}

/// `69 <type> <errorCode> <value>`: a value of 0 with no error is warm-up (the ring streams zeros
/// for ~25 s before real values), not a reading.
private static func decodeSpotVital(_ vital: SpotVital, errorCode: UInt8, raw: Int, now: Date) -> [RingDecodedEvent] {
if errorCode != 0 { return [.spotVitalNoReading(vital: vital, timestamp: now)] }
guard raw > 0 else { return [] }
return [.spotVitalSample(vital: vital, value: spotVitalValue(vital, raw: raw), timestamp: now)]
}

/// A streamed on-demand value in its unit. Temperature is `raw / 10 + 20` °C, as QRing shows it
/// (`TemperatureActivity`: `value * 1.0f / 10 + 20`).
static func spotVitalValue(_ vital: SpotVital, raw: Int) -> Double {
switch vital {
case .temperature: return Double(raw) / 10 + 20
case .hrv, .stress: return Double(raw)
}
}

static func sleepStage(_ type: UInt8) -> SleepStage {
switch type {
case ColmiCommandID.sleepLight: return .light
Expand Down
13 changes: 13 additions & 0 deletions PulseLoop/RingProtocol/ColmiEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
}

/// User profile. Sensible neutral defaults; PulseLoop can wire real profile values later.
func userPreferences(metric: Bool = true, gender: UInt8 = 0x02, age: UInt8 = 25, heightCm: UInt8 = 175, weightKg: UInt8 = 70) -> [UInt8] {

Check warning on line 38 in PulseLoop/RingProtocol/ColmiEncoder.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 140 characters or less; currently it has 142 characters (line_length)
[
ColmiCommandID.preferences,
ColmiCommandID.prefWrite,
Expand Down Expand Up @@ -79,6 +79,19 @@
[ColmiCommandID.manualHeartRate, enable ? 0x01 : 0x02]
}

/// On-demand HRV / stress / temperature. Start is QRing's `StartHeartRateReq.getSimpleReq(type)` =
/// `[0x69, type, 0x25]`. The HRV and stress stops carry the last raw value the ring streamed
/// (`StopHeartRateReq.stopHrv(b)` / `stopPressure(b)`), so the ring's own log records it; the ring
/// echoes its final value back in its `0x6A` acknowledgement. Temperature does not: QRing's
/// `stopTemperatureCheck()` is a fixed `[0x6A, 0x0B, 0, 0]` at every call site in
/// `TemperatureActivity`, so `lastRaw` is ignored for it.
func spotVital(_ vital: SpotVital, enable: Bool, lastRaw: Int = 0) -> [UInt8] {
let type = ColmiCommandID.readingType(vital)
guard !enable else { return [ColmiCommandID.manualHeartRate, type, 0x25] }
let value: UInt8 = vital == .temperature ? 0 : UInt8(clamping: lastRaw)
return [ColmiCommandID.realtimeStop, type, value, 0x00]
}

func realtimeHeartRate(enable: Bool) -> [UInt8] {
[ColmiCommandID.realtimeHeartRate, enable ? 0x01 : 0x02]
}
Expand Down
20 changes: 20 additions & 0 deletions PulseLoop/RingProtocol/ColmiProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ enum ColmiCommandID {
static let syncActivity: UInt8 = 0x43
static let findDevice: UInt8 = 0x50
static let manualHeartRate: UInt8 = 0x69
/// `CMD_STOP_REAL_TIME`: stops whatever `0x69` started — and with it the ring's whole realtime
/// engine (QRing `StopHeartRateReq`, opcode 106).
static let realtimeStop: UInt8 = 0x6a
static let notification: UInt8 = 0x73
static let bigDataV2: UInt8 = 0xbc
static let factoryReset: UInt8 = 0xff
Expand All @@ -63,6 +66,23 @@ enum ColmiCommandID {
static let bigDataSleep: UInt8 = 0x27
static let bigDataSpo2: UInt8 = 0x2a

// `0x69`/`0x6A` reading types for on-demand `SpotVital`s (QRing `StartHeartRateReq.getSimpleReq`).
static let readingTypeStress: UInt8 = 0x08
static let readingTypeHRV: UInt8 = 0x0a
static let readingTypeTemperature: UInt8 = 0x0b

static func readingType(_ vital: SpotVital) -> UInt8 {
switch vital {
case .hrv: return readingTypeHRV
case .stress: return readingTypeStress
case .temperature: return readingTypeTemperature
}
}

static func spotVital(readingType: UInt8) -> SpotVital? {
SpotVital.allCases.first { self.readingType($0) == readingType }
}

// sleep stage types
static let sleepLight: UInt8 = 0x02
static let sleepDeep: UInt8 = 0x03
Expand Down
15 changes: 15 additions & 0 deletions PulseLoop/RingProtocol/ColmiSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@
case .activity:
daysAgo = 0; stage = .heartRate; requestHeartRate()
case .heartRate:
if isVitalsBackfill { stage = .spo2; requestSpo2() }

Check warning on line 195 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .stress; requestStress() }
case .stress:
stage = .spo2; requestSpo2()
case .spo2:
if isVitalsBackfill { finishSync() }

Check warning on line 200 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .sleep; requestSleep() }
case .sleep:
daysAgo = 0; stage = .hrv; requestHRV()
Expand Down Expand Up @@ -297,11 +297,11 @@

switch stage {
case .activity:
if daysAgo < 7 { daysAgo += 1; requestActivity() }

Check warning on line 300 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { daysAgo = 0; stage = .heartRate; requestHeartRate() }
case .heartRate:
if isVitalsBackfill { stage = .spo2; requestSpo2() }

Check warning on line 303 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else if daysAgo < 7 { daysAgo += 1; requestHeartRate() }

Check warning on line 304 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .stress; requestStress() }
case .stress:
stage = .spo2; requestSpo2()
Expand Down Expand Up @@ -389,6 +389,21 @@
writer?.enqueue(Data(encoder.manualHeartRate(enable: true)))
}

// On-demand HRV / stress / temperature (`0x69` types 0a/08/0b) — QRing's manual measure screens.
var streamsSpotVitals: Bool { true }

func startSpotVital(_ vital: SpotVital) {
writer?.enqueue(Data(encoder.spotVital(vital, enable: true)))
}

/// `0x6A` stops the ring's whole realtime engine, the manual HR stream included, so drop that
/// bookkeeping with it; the coordinator restarts a running workout's stream right after.
func stopSpotVital(_ vital: SpotVital, settled: Double?) {
manualHRActive = false
let lastRaw = settled.map { Int($0.rounded()) } ?? 0 // ignored for temperature
writer?.enqueue(Data(encoder.spotVital(vital, enable: false, lastRaw: lastRaw)))
}

func syncVitalsHistory() {
startVitalsBackfill()
}
Expand Down
22 changes: 20 additions & 2 deletions PulseLoop/RingProtocol/RingBLEClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
/// are serialized (one outstanding `withResponse` write at a time) to keep framed responses in order.
@MainActor
@Observable
final class RingBLEClient: NSObject {

Check warning on line 31 in PulseLoop/RingProtocol/RingBLEClient.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Class body should span 400 lines or less excluding comments and whitespace: currently spans 403 lines (type_body_length)
nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes)

/// Registry of supported wearables. First coordinator whose `matches` claims a peripheral wins.
Expand Down Expand Up @@ -447,6 +447,21 @@
activeSyncEngine = driver.makeSyncEngine()
activeDeviceType = coordinatorType.deviceType
activeCapabilities = coordinator.refinedCapabilities(bitmapDerived: lastKnownCapabilities)
.union(modelExtraCapabilities)
}

/// The connected model's own capabilities beyond its family's (`WearableModel.extraCapabilities`).
///
/// Granted only when the ring's advertised name identifies that model. `WearableModel.resolve`
/// falls back to the carousel choice (and state restoration to the remembered model), which is fine
/// for the name and art but not for features: a ring matched some other way while the carousel sits
/// on the Ring 2 Pro would otherwise get HRV / stress / temperature controls it was never shown to
/// support, each running its 75 s ceiling before failing.
private var modelExtraCapabilities: Set<WearableCapability> {
guard let model = WearableModel.model(advertisedName: activeAdvertisedName),
model.id == activeWearableModelID,
model.family == activeDeviceType else { return [] }
return model.extraCapabilities
}

/// Re-adopt the remembered ring's identity: its family **and** the exact catalog model.
Expand All @@ -459,8 +474,11 @@
func adoptRememberedIdentity() {
// The remembered family already *is* the user's choice — `beginConnect` persisted whichever
// coordinator it selected — so a restored session re-adopts the right driver for free.
installDriver(Self.coordinatorType(preferredFamily: nil, autoMatched: lastKnownDeviceType))
// Name and model go first: `installDriver` grants per-model extras only when the ring's own
// (here: cached GAP) name identifies the remembered model (`modelExtraCapabilities`).
activeAdvertisedName = peripheral?.name
activeWearableModelID = lastKnownWearableModelID
installDriver(Self.coordinatorType(preferredFamily: nil, autoMatched: lastKnownDeviceType))
}

/// Fold the ring's self-reported capability bitmap into the active capability set.
Expand All @@ -476,7 +494,7 @@
/// churn the bus and the store for nothing.
private func applySupportFunctions(_ derived: Set<WearableCapability>) {
guard let coordinator = activeCoordinator, let type = activeDeviceType else { return }
let refined = coordinator.refinedCapabilities(bitmapDerived: derived)
let refined = coordinator.refinedCapabilities(bitmapDerived: derived).union(modelExtraCapabilities)
// Remember the answer even when it changes nothing right now: this is what seeds the *next*
// connect, before that connect's own bitmap has arrived (see `lastKnownCapabilities`).
UserDefaults.standard.set(refined.csv, forKey: Self.lastCapabilitiesKey)
Expand Down Expand Up @@ -1045,4 +1063,4 @@
pumpWrites()
}
}
}

Check warning on line 1066 in PulseLoop/RingProtocol/RingBLEClient.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 710 (file_length)
5 changes: 4 additions & 1 deletion PulseLoop/RingProtocol/RingEventBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ enum RingEventBridge {
/// This matters more for history than for live data: a ring replays its *entire* log on every sync
/// and history rows upsert on (kind, timestamp), so one misdecoded record doesn't just flicker —
/// it re-persists on every future sync until the ring forgets it.
private static func isPlausible(kind: MeasurementKind, value: Double) -> Bool {
///
/// Also the band an on-demand spot reading settles within (`SpotVitalWindow`), so a streamed value
/// and a stored one can never be judged by two copies of the same ranges.
static func isPlausible(kind: MeasurementKind, value: Double) -> Bool {
switch kind {
case .heartRate: return hrRange.contains(Int(value))
case .spo2: return spo2Range.contains(Int(value))
Expand Down
36 changes: 36 additions & 0 deletions PulseLoop/RingProtocol/RingProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ struct RingPacket {
}
}

/// A vital the ring measures on demand by **streaming** values until it is stopped — QRing's manual
/// HRV, stress and temperature screens (`HrvActivity`, `DayPressureFragment`, `TemperatureActivity`
/// in decompiled-qring-official/). Distinct from the all-day history of the same kinds.
///
/// Everything protocol-side that varies per vital lives here: the measurement kind it is stored as and
/// the capability that offers its control.
enum SpotVital: CaseIterable, Sendable {
case hrv, stress, temperature

var kind: MeasurementKind {
switch self {
case .hrv: return .hrv
case .stress: return .stress
case .temperature: return .temperature
}
}

var capability: WearableCapability {
switch self {
case .hrv: return .manualHrv
case .stress: return .manualStress
case .temperature: return .manualTemperature
}
}
}

enum RingDecodedEvent: Sendable {
case activityUpdate(timestamp: Date, steps: Int, distanceMeters: Double, calories: Double)
/// One intraday activity bucket (e.g. a Colmi quarter-hour history sample). Unlike the cumulative
Expand Down Expand Up @@ -151,6 +177,14 @@ enum RingDecodedEvent: Sendable {
/// that already said no for the full 45-second window before reporting a generic failure.
case measurementRejected(mode: UInt8)
case rwfitMeasurementStatus(type: UInt8, status: UInt8)
/// One streamed value of an on-demand `SpotVital` run (Colmi `0x69 <type> 00 <value>`). Produces no
/// `PulseEvent`: the run's values converge on a reading, so storing each would leave a train of rows
/// that were never the user's reading. `RingSyncCoordinator` settles them off the raw-packet feed
/// and publishes the one reading.
case spotVitalSample(vital: SpotVital, value: Double, timestamp: Date)
/// The ring ended an on-demand `SpotVital` run with an error (not worn / no contact). A verdict on
/// the measurement, not data, so — like `.measurementRejected` — it produces no `PulseEvent`.
case spotVitalNoReading(vital: SpotVital, timestamp: Date)
/// 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
Expand Down Expand Up @@ -193,6 +227,8 @@ enum RingDecodedEvent: Sendable {
case .wearingStatus: return "wearing_status"
case .measurementRejected: return "measurement_rejected"
case .rwfitMeasurementStatus: return "rwfit_measurement_status"
case .spotVitalSample: return "spot_vital_sample"
case .spotVitalNoReading: return "spot_vital_no_reading"
case .timingHistoryFrame: return "timing_history_frame"
case .timeSyncAck: return "time_sync_ack"
case .commandAck: return "command_ack"
Expand Down
91 changes: 88 additions & 3 deletions PulseLoop/Services/RingSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,12 @@ final class RingSyncCoordinator {

private(set) var hrState: MeasureState = .idle
private(set) var spo2State: MeasureState = .idle
private(set) var hrvState: MeasureState = .idle
/// One state per `SpotVital`. HRV's is also `hrvState`, which the first-value HRV leg drives.
private(set) var spotVitalStates: [SpotVital: MeasureState] = [:]
private(set) var hrvState: MeasureState {
get { vitalState(.hrv) }
set { spotVitalStates[.hrv] = newValue }
}
/// `.done` carries the systolic value; read `latestBloodPressureValue` for the full pair.
private(set) var bpState: MeasureState = .idle
/// Combined sweep (all vitals at once). `.done` carries HR; the caller keeps the full reading.
Expand Down Expand Up @@ -238,6 +243,10 @@ final class RingSyncCoordinator {
/// 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()
/// The streamed values of an on-demand HRV / stress / temperature run — see `SpotVitalWindow`.
private var spotVitalWindow = SpotVitalWindow()
/// The ring ended the `SpotVital` run in flight with an error before any value arrived.
private var spotVitalNoReading: SpotVital?
/// True once the current measurement has produced a real (post-warm-up) bpm. Drives the live value
/// in the measurement sheet, and keeps a stale `latestHRValue` from passing for a fresh reading.
var measurementReceivedReading: Bool { hrWindow.receivedReading }
Expand Down Expand Up @@ -281,6 +290,9 @@ final class RingSyncCoordinator {
/// HRV needs a stretch of beats to stabilize, so give it a longer on-finger window. (A ring without
/// an HRV sensor no longer waits it out — it now refuses the `03 2f` start and we fail immediately.)
private let hrvMeasureSeconds: UInt64 = 45
/// Ceiling for a streamed HRV / stress / temperature run — QRing's `CountDownTimer(75000L, …)`. A
/// run normally ends ~30 s in, once the ring goes quiet (`SpotVitalWindow`).
private let spotVitalMeasureSeconds: UInt64 = 75
/// BP rides the same PPG warm-up as SpO2 and the ring's estimator settles slowly (observed: ~12 s).
private let bpMeasureSeconds: UInt64 = 45
/// The combined sweep computes every metric, so give it the longest window (observed: the ring
Expand Down Expand Up @@ -596,6 +608,9 @@ final class RingSyncCoordinator {
/// the abort below: the `03 2f` refusal ends the measurement in the time one reply takes to arrive.
@discardableResult
func measureHRV() async -> Int? {
if engine?.streamsSpotVitals == true {
return await measureSpotVital(.hrv).map { Int($0.rounded()) }
}
guard hrvState != .measuring else { return nil }
if client.activeDeviceType == .rwfit,
[hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil }
Expand Down Expand Up @@ -624,6 +639,64 @@ final class RingSyncCoordinator {
return result
}

func vitalState(_ vital: SpotVital) -> MeasureState { spotVitalStates[vital] ?? .idle }

/// An on-demand HRV / stress / temperature run on a family that streams them (Colmi `0x69` types
/// 0a/08/0b — QRing's manual measure screens). Ends on `SpotVitalWindow`'s rule — the ring has gone
/// quiet after streaming — or at the ceiling, taking whatever last value arrived; fails on the
/// ring's not-worn error or a dropped link. The streamed values are never stored (they reach us only
/// as `.spotVitalSample` on the raw-packet feed); the one settled reading is published once at the
/// end, so it is stored as one row.
@discardableResult
func measureSpotVital(_ vital: SpotVital) async -> Double? {
guard vitalState(vital) != .measuring else { return nil }
spotVitalStates[vital] = .idle // no stale verdict on a refusal below
guard client.state == .connected, let engine, engine.streamsSpotVitals else {
spotVitalStates[vital] = .failed
return nil
}
// Refused during a workout: `0x69 <type>` takes over the ring's single realtime engine, so the
// workout would lose its live bpm for the whole run.
guard !workoutHRActive else { return nil }
// Refused while another spot measurement holds the sensor (Android's `measureVitalOnly`): each
// run's `0x6A` stop tears down the ring's one realtime engine, ending the other run with it.
guard ![hrState, spo2State, bpState].contains(.measuring),
!spotVitalStates.values.contains(.measuring) else { return nil }
spotVitalStates[vital] = .measuring
measureNotWorn = false
spotVitalNoReading = nil
spotVitalWindow.begin(vital)
engine.startSpotVital(vital)

var aborted = false
for _ in 0..<(Int(spotVitalMeasureSeconds) * 2) { // 0.5 s granularity
if Task.isCancelled || spotVitalNoReading == vital { aborted = true; break }
if client.state != .connected { aborted = true; break }
// A workout started inside the run and now wants the realtime engine back.
if workoutHRActive { aborted = true; break }
if spotVitalWindow.isSettled(at: Date()) { break }
try? await Task.sleep(nanoseconds: 500_000_000)
}
let result = aborted ? nil : spotVitalWindow.settled

// The stop hands the ring back the reading, as QRing's does, and also tears down the workout's
// realtime stream; bring that straight back.
self.engine?.stopSpotVital(vital, settled: result)
restartWorkoutHeartRateIfActive()
spotVitalStates[vital] = result.map { .done(Int($0.rounded())) } ?? .failed
if let result {
let now = Date()
let event: PulseEvent
switch vital {
case .hrv: event = .hrvSample(value: Int(result.rounded()), timestamp: now)
case .stress: event = .stressSample(value: Int(result.rounded()), timestamp: now)
case .temperature: event = .temperatureSample(celsius: result, timestamp: now)
}
await PulseEventBus.shared.publish(event)
}
return result
}

/// Spot blood-pressure reading. The engine starts the ring's BP mode (jring: `0x23 01`); the
/// systolic/diastolic pair arrives together in the ring's combined-sensor packet, so we poll on
/// the systolic value and read the pair. Capability-gated to `.manualBloodPressure`.
Expand Down Expand Up @@ -759,8 +832,20 @@ final class RingSyncCoordinator {
// `.measurementRejected` has no `PulseEvent` of its own — it is a verdict on a command, not
// data — so the raw-packet feed (which carries every decoded frame) is where a measurement
// hears the ring say no.
guard case let .measurementRejected(mode) = decoded else { break }
spot.noteRejected(mode: mode)
switch decoded {
case let .measurementRejected(mode):
spot.noteRejected(mode: mode)
case let .spotVitalSample(vital, value, timestamp):
if vitalState(vital) == .measuring { spotVitalWindow.collect(vital, value: value, at: timestamp) }
case let .spotVitalNoReading(vital, _):
// Only before any value: a late error must not turn a reading into a failure.
if vitalState(vital) == .measuring, !spotVitalWindow.receivedReading {
spotVitalNoReading = vital
measureNotWorn = true
}
default:
break
}
default:
mirrorLiveValue(event)
}
Expand Down
Loading
Loading