diff --git a/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/Contents.json b/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/Contents.json new file mode 100644 index 00000000..36462cd9 --- /dev/null +++ b/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "colmi-ring-2-pro.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/colmi-ring-2-pro.png b/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/colmi-ring-2-pro.png new file mode 100644 index 00000000..149e07e1 Binary files /dev/null and b/PulseLoop/Assets.xcassets/colmi-ring-2-pro.imageset/colmi-ring-2-pro.png differ diff --git a/PulseLoop/RingProtocol/ColmiDecoder.swift b/PulseLoop/RingProtocol/ColmiDecoder.swift index 3dd257c0..cf0f8c41 100644 --- a/PulseLoop/RingProtocol/ColmiDecoder.swift +++ b/PulseLoop/RingProtocol/ColmiDecoder.swift @@ -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: 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". @@ -340,6 +345,23 @@ struct ColmiDecoder { return events } + /// `69 `: 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 diff --git a/PulseLoop/RingProtocol/ColmiEncoder.swift b/PulseLoop/RingProtocol/ColmiEncoder.swift index 4bbec61e..778f1a93 100644 --- a/PulseLoop/RingProtocol/ColmiEncoder.swift +++ b/PulseLoop/RingProtocol/ColmiEncoder.swift @@ -79,6 +79,19 @@ struct ColmiEncoder { [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] } diff --git a/PulseLoop/RingProtocol/ColmiProtocol.swift b/PulseLoop/RingProtocol/ColmiProtocol.swift index f3b4911d..78cb431a 100644 --- a/PulseLoop/RingProtocol/ColmiProtocol.swift +++ b/PulseLoop/RingProtocol/ColmiProtocol.swift @@ -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 @@ -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 diff --git a/PulseLoop/RingProtocol/ColmiSyncEngine.swift b/PulseLoop/RingProtocol/ColmiSyncEngine.swift index 1d44ae4c..92d476a6 100644 --- a/PulseLoop/RingProtocol/ColmiSyncEngine.swift +++ b/PulseLoop/RingProtocol/ColmiSyncEngine.swift @@ -389,6 +389,21 @@ final class ColmiSyncEngine: RingSyncEngine { 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() } diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index f7c2cf7d..6cf238e2 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -447,6 +447,21 @@ final class RingBLEClient: NSObject { 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 { + 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. @@ -459,8 +474,11 @@ final class RingBLEClient: NSObject { 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. @@ -476,7 +494,7 @@ final class RingBLEClient: NSObject { /// churn the bus and the store for nothing. private func applySupportFunctions(_ derived: Set) { 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) diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index bd1d4ec8..ee06f4ee 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -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)) diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index 31c41714..b55d06fc 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -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 @@ -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 00 `). 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 @@ -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" diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 569447fd..620c3a73 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -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. @@ -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 } @@ -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 @@ -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 } @@ -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 ` 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`. @@ -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) } diff --git a/PulseLoop/Services/SpotVitalWindow.swift b/PulseLoop/Services/SpotVitalWindow.swift new file mode 100644 index 00000000..4f8139c8 --- /dev/null +++ b/PulseLoop/Services/SpotVitalWindow.swift @@ -0,0 +1,51 @@ +import Foundation + +/// The streamed values of one on-demand HRV / stress / temperature measurement, and the rule for when +/// they have settled into a reading. +/// +/// ## The vendor's rule +/// +/// QRing's three measure screens (`HrvActivity`, `DayPressureFragment`, `TemperatureActivity` in +/// decompiled-qring-official/) share one shape: a 75 s countdown, then every value above zero replaces +/// the one on screen and re-posts the finishing step one second ahead (`removeCallbacks` + +/// `postDelayed(…, 1000)`). The measurement therefore ends **one second after the ring stops +/// streaming**, on the last value it streamed. +/// +/// A Ring 2 Pro capture shows the ring doing its part: after ~25 s of zero-valued warm-up frames it +/// streams a value about every 0.5 s for a few seconds (HRV: eleven values over 5.5 s), then goes +/// quiet, and QRing stops ~1.2 s later. The ring's `0x6A` stop acknowledgement echoes that same last +/// value — so the last sample is also the ring's own answer. +struct SpotVitalWindow { + /// QRing's `postDelayed(…, 1000)`. + static let quietInterval: TimeInterval = 1 + + private var vital: SpotVital? + private var last: Double? + private var lastAt: Date? + + mutating func begin(_ vital: SpotVital) { + self.vital = vital + last = nil + lastAt = nil + } + + /// Keep `value` if it belongs to the run in flight and is plausible; returns whether it was kept. + @discardableResult + mutating func collect(_ vital: SpotVital, value: Double, at time: Date) -> Bool { + guard vital == self.vital, RingEventBridge.isPlausible(kind: vital.kind, value: value) else { return false } + last = value + lastAt = time + return true + } + + var receivedReading: Bool { last != nil } + + /// The last plausible value streamed, or nil if none arrived. + var settled: Double? { last } + + /// True once a value has arrived and the ring has been quiet for `quietInterval` since. + func isSettled(at now: Date) -> Bool { + guard last != nil, let lastAt else { return false } + return now.timeIntervalSince(lastAt) >= Self.quietInterval + } +} diff --git a/PulseLoop/Views/MeasurementDemoData.swift b/PulseLoop/Views/MeasurementDemoData.swift index c05fcea3..9e5c9f90 100644 --- a/PulseLoop/Views/MeasurementDemoData.swift +++ b/PulseLoop/Views/MeasurementDemoData.swift @@ -13,6 +13,8 @@ import Foundation enum MeasurementDemoData { struct Reading { var value: Int? + /// A reading with a decimal (temperature, °C), shown in place of `value`. + var decimal: Double? /// Diastolic, for blood pressure. var secondary: Int? /// Populated for the combined sweep. @@ -29,11 +31,21 @@ enum MeasurementDemoData { } switch kind { - case .hr, .spo2, .hrv: - let metric: MeasurementKind = kind == .hr ? .heartRate : (kind == .spo2 ? .spo2 : .hrv) + case .hr, .spo2, .hrv, .stress: + let metric: MeasurementKind = switch kind { + case .hr: .heartRate + case .spo2: .spo2 + case .stress: .stress + default: .hrv + } MetricsService.insertMockMeasurement(kind: metric, context: context) return Reading(value: latest(metric)) + case .temperature: + MetricsService.insertMockMeasurement(kind: .temperature, context: context) + let celsius = MetricsService.fetchMeasurements(context).first { $0.kind == .temperature }?.value + return Reading(value: nil, decimal: celsius) + case .bloodPressure: // BP is stored as two rows so each trends independently — mock both. MetricsService.insertMockMeasurement(kind: .bloodPressureSystolic, context: context) diff --git a/PulseLoop/Views/MeasurementKindPresentation.swift b/PulseLoop/Views/MeasurementKindPresentation.swift index 0f0072a4..5d6fe2d2 100644 --- a/PulseLoop/Views/MeasurementKindPresentation.swift +++ b/PulseLoop/Views/MeasurementKindPresentation.swift @@ -11,6 +11,8 @@ extension MeasurementSheet.Kind { case .hr: return "Heart Rate" case .spo2: return "Blood Oxygen" case .hrv: return "Heart Rate Variability" + case .stress: return "Stress" + case .temperature: return "Temperature" case .bloodPressure: return "Blood Pressure" case .vitals: return "Vitals" } @@ -21,6 +23,9 @@ extension MeasurementSheet.Kind { case .hr, .vitals: return "bpm" case .spo2: return "%" case .hrv: return "ms" + case .stress: return "/ 100" + // The sheet shows the user's unit (`UnitsFormatter.temperatureUnit`); this is the stored one. + case .temperature: return "°C" case .bloodPressure: return "mmHg" } } @@ -30,6 +35,8 @@ extension MeasurementSheet.Kind { case .hr, .vitals: return PulseColors.heartRate case .spo2: return PulseColors.spo2 case .hrv: return PulseColors.hrv + case .stress: return PulseColors.stress + case .temperature: return PulseColors.temperature case .bloodPressure: return PulseColors.bloodPressure } } @@ -39,6 +46,8 @@ extension MeasurementSheet.Kind { case .hr, .vitals: return "heart.fill" case .spo2: return "lungs.fill" case .hrv: return "waveform.path.ecg" + case .stress: return "brain.head.profile" + case .temperature: return "thermometer.medium" case .bloodPressure: return "heart.text.square" } } @@ -49,6 +58,8 @@ extension MeasurementSheet.Kind { case .hr: return "Rest your wrist on a flat surface and keep still." case .spo2: return "Keep the ring pressed firmly to your skin and breathe normally." case .hrv: return "Sit still and breathe normally — HRV needs a steady stretch of beats." + case .stress: return "Sit still and breathe normally — stress is read from a steady stretch of beats." + case .temperature: return "Keep the ring snug and your hand still." case .bloodPressure: return "Sit upright, rest your hand at heart height, and stay still." case .vitals: return "Sit upright, rest your hand at heart height, and stay still. This takes about a minute." } @@ -60,6 +71,8 @@ extension MeasurementSheet.Kind { case .hr: return "Finding your pulse…" case .spo2: return "Reading blood oxygen…" case .hrv: return "Reading heart rate variability…" + case .stress: return "Measuring stress…" + case .temperature: return "Measuring temperature…" case .bloodPressure: return "Measuring blood pressure…" case .vitals: return "Measuring your vitals…" } @@ -76,6 +89,10 @@ extension MeasurementSheet.Kind { return "Couldn't get a blood-oxygen reading. Wear the ring snugly and keep still, then try again." case .hrv: return "Couldn't get an HRV reading. Wear the ring snugly, keep still, and try again." + case .stress: + return "Couldn't get a stress reading. Wear the ring snugly, keep still, and try again." + case .temperature: + return "Couldn't get a temperature reading. Wear the ring snugly, keep still, and try again." case .bloodPressure: return "Couldn't get a blood-pressure reading. Wear the ring snugly, rest your hand at heart height, and try again." case .vitals: diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index d13ef0e3..0015d92a 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import Combine #if canImport(UIKit) import UIKit @@ -22,7 +23,7 @@ import UIKit struct MeasurementSheet: View { /// `.vitals` is one sweep that returns every metric the ring computes (jring's `0x24` packet); /// the rest are single-metric spot readings on devices that measure one thing at a time. - enum Kind: Hashable { case hr, spo2, hrv, bloodPressure, vitals } + enum Kind: Hashable { case hr, spo2, hrv, bloodPressure, vitals, stress, temperature } /// The measurement lifecycle. `searching` is the working state — counting down (HR) or up (rest). enum Stage { case preparing, searching, locking, result, error } @@ -35,10 +36,13 @@ struct MeasurementSheet: View { @Environment(RingBLEClient.self) private var ble @Environment(RingSyncCoordinator.self) private var coordinator @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// For the user's temperature unit — the one reading shown in something other than its stored unit. + @Query private var profiles: [UserProfile] // MARK: State @State private var stage: Stage = .preparing - @State private var value: Int? + /// A `Double` because temperature has a decimal; every other kind holds a whole number. + @State private var value: Double? /// Diastolic, for blood pressure — the only single reading that is a pair. @State private var secondaryValue: Int? /// Populated for `.vitals`: every metric the sweep returned. @@ -77,7 +81,8 @@ struct MeasurementSheet: View { private var color: Color { kind.tint } private var name: String { kind.title } - private var unit: String { kind.unit } + private var units: UnitsPreference { profiles.first?.units ?? .metric } + private var unit: String { kind == .temperature ? UnitsFormatter.temperatureUnit(units) : kind.unit } private var instruction: String { kind.instruction } /// The one measurement with a fixed, known duration — so the only one we can honestly count down. @@ -93,8 +98,10 @@ struct MeasurementSheet: View { /// The big number in the ring. Blood pressure shows the systolic/diastolic pair. private var readingText: String? { guard let value else { return nil } - if kind == .bloodPressure, let secondaryValue { return "\(value)/\(secondaryValue)" } - return "\(value)" + if kind == .temperature { return UnitsFormatter.temperature(celsius: value, units: units).value } + let whole = Int(value.rounded()) + if kind == .bloodPressure, let secondaryValue { return "\(whole)/\(secondaryValue)" } + return "\(whole)" } /// Explicit Done (vs auto-dismiss) is required under VoiceOver or Switch Control — and for the @@ -469,15 +476,17 @@ struct MeasurementSheet: View { return } - let result: Int? + let result: Double? switch kind { - case .hr: result = await coordinator.measureHR() - case .spo2: result = await coordinator.measureSpO2() - case .hrv: result = await coordinator.measureHRV() + case .hr: result = await coordinator.measureHR().map(Double.init) + case .spo2: result = await coordinator.measureSpO2().map(Double.init) + case .hrv: result = await coordinator.measureHRV().map(Double.init) + case .stress: result = await coordinator.measureSpotVital(.stress) + case .temperature: result = await coordinator.measureSpotVital(.temperature) case .bloodPressure: let reading = await coordinator.measureBloodPressure() secondaryValue = reading?.diastolic - result = reading?.systolic + result = (reading?.systolic).map(Double.init) case .vitals: return // handled above } if Task.isCancelled { return } @@ -507,7 +516,7 @@ struct MeasurementSheet: View { if kind == .vitals { await revealVitals() } else { - await reveal(demo.value) + await reveal(demo.decimal ?? demo.value.map(Double.init)) } } @@ -526,7 +535,7 @@ struct MeasurementSheet: View { /// Land a finished single-metric reading: fill the ring, show the number, and (unless the user is /// on VoiceOver) close the sheet behind them. @MainActor - private func reveal(_ result: Int?) async { + private func reveal(_ result: Double?) async { if Task.isCancelled { return } guard let result else { enterError(); return } // A BP reading without its diastolic half is not a usable reading. diff --git a/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index 9f6cfbc8..cfadaf77 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -96,10 +96,13 @@ struct VitalsView: View { (.manualHeartRate, "Measure HR", .hr), (.manualSpo2, "Measure SpO₂", .spo2), (.manualHrv, "Measure HRV", .hrv), + (.manualStress, "Measure Stress", .stress), + (.manualTemperature, "Measure Temp", .temperature), ] let available = spots.filter { caps.contains($0.0) } if !available.isEmpty { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: available.count), spacing: 8) { + // At most three to a row: a Ring 2 Pro offers five, which would not fit on one line. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(available.count, 3)), spacing: 8) { ForEach(available, id: \.2) { _, label, kind in QuickActionButton(label: label, accent: kind == .hr) { measuring = kind } } diff --git a/PulseLoop/Wearables/WearableCapability.swift b/PulseLoop/Wearables/WearableCapability.swift index 899e22d6..d60dbf7b 100644 --- a/PulseLoop/Wearables/WearableCapability.swift +++ b/PulseLoop/Wearables/WearableCapability.swift @@ -53,6 +53,12 @@ enum WearableCapability: String, CaseIterable, Codable, Sendable { // `0xbc 0x2a`). Drives the workout vitals plan: a device with no `manualSpo2` but with this // capability shows the latest logged value instead of pretending it can spot-measure. case spo2History + + // On-demand stress and temperature, streamed until stopped like on-demand HRV on the Colmi Ring 2 + // Pro (`0x69` types `08`/`0b`, QRing's manual measure screens). Granted per model, never per family + // (`WearableModel.extraCapabilities`): nothing on the wire says which Colmi rings support them. + case manualStress + case manualTemperature } extension Set where Element == WearableCapability { diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index ac6998dd..8b87b27b 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -215,6 +215,12 @@ protocol RingSyncEngine: AnyObject { /// (YCBT) implement these; the default is a no-op (see the extension below). func startHRV() func stopHRV() + /// True when this family measures HRV / stress / temperature on demand by streaming values until + /// stopped (`startSpotVital`) rather than through `startHRV`'s first-value poll. Default false. + var streamsSpotVitals: Bool { get } + func startSpotVital(_ vital: SpotVital) + /// Stop what `startSpotVital` began; `settled` is the reading the app settled on (nil = none). + func stopSpotVital(_ vital: SpotVital, settled: Double?) func findDevice() func setGoal(steps: Int) @@ -280,6 +286,11 @@ extension RingSyncEngine { func startHRV() {} func stopHRV() {} + /// Default: no family but Colmi streams on-demand HRV / stress / temperature. + var streamsSpotVitals: Bool { false } + func startSpotVital(_ vital: SpotVital) {} + func stopSpotVital(_ vital: SpotVital, settled: Double?) {} + /// Default: devices without a blood-pressure sweep (Colmi) have nothing to measure. func startBloodPressure() {} func stopBloodPressure() {} diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 7a7ca499..30745b4e 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -33,6 +33,13 @@ struct WearableModel: Identifiable { /// indistinguishable from jring until GATT discovery, so its explicit card accepts only the two /// classifications that can legitimately describe that hardware (`.jring` and `.crp`). var forcedFamilyScanMatches: Set = [] + /// Capabilities this model has beyond its family coordinator's, added to the connected ring's set + /// when this model is resolved — for features the family's protocol carries but nothing on the wire + /// says a given ring supports (QRing decides those from a server-side per-model feature list, not + /// from the `0x3C` reply). Granted only when the ring's advertised name identifies this model, + /// never from the carousel fallback (`RingBLEClient.modelExtraCapabilities`). Add a model only once + /// it is shown to work on real hardware. + var extraCapabilities: Set = [] /// Maturity of this model's driver, mirrored from its `family` (the real source of truth). var supportLevel: WearableSupportLevel { family.supportLevel } @@ -159,6 +166,14 @@ extension WearableModel { "colmi-r11", "Colmi R11", brand: "Colmi", pattern: "^R11C_[0-9A-F]{4}$", imageName: "yawell-r11" ) static let colmiR12 = colmiFamily("colmi-r12", "Colmi R12", brand: "Colmi", pattern: "^COLMI R12_.*") + /// Advertises its full marketing name with no `_` serial (hardware capture, 2026-10-03) and + /// reports hardware `RY10H…`. QRing drives it over the same UART/big-data pair as the rest of this + /// family and does not OS-bond it. On-demand HRV / stress / temperature (`0x69` types 0a/08/0b) + /// were captured from QRing on it and verified on hardware in the Android port (PulseLoopAndroid #100). + static let colmiRing2Pro = colmiFamily( + "colmi-ring-2-pro", "Colmi Ring 2 Pro", brand: "Colmi", pattern: "^COLMI Ring 2 Pro$", + extraCapabilities: [.manualHrv, .manualStress, .manualTemperature] + ) /// The R99 — the one SmartHealth-Colmi we have actually looked at (the owner's, captured with nRF /// Connect as `R99 54DC`). It stays under the **Colmi** brand tab because that is where its owner @@ -275,14 +290,16 @@ extension WearableModel { _ name: String, brand: String, pattern: String, - imageName: String? = nil + imageName: String? = nil, + extraCapabilities: Set = [] ) -> WearableModel { // Asset-catalog image name matches the model id (see PulseLoop/Assets.xcassets). WearableModel( id: id, displayName: name, brand: brand, family: .colmiR02, tint: PulseColors.hrv, blurb: qringBlurb, advertisedNamePatterns: [pattern], imageName: imageName ?? id, - appVariants: colmiAppVariants + appVariants: colmiAppVariants, + extraCapabilities: extraCapabilities ) } @@ -378,7 +395,7 @@ extension WearableModel { /// alphabetically, so this array's order is not user-visible. static let catalog: [WearableModel] = [ jring, - colmiR02, colmiR03, colmiR06, colmiR07, colmiR08, colmiR09, colmiR10, colmiR11, colmiR12, + colmiR02, colmiR03, colmiR06, colmiR07, colmiR08, colmiR09, colmiR10, colmiR11, colmiR12, colmiRing2Pro, colmiR99, yawellR05, yawellR10, yawellR11, h59, // Ahead of the YCBT siblings: `model(advertisedName:)` takes the first pattern that matches, and diff --git a/PulseLoopTests/ColmiDecoderTests.swift b/PulseLoopTests/ColmiDecoderTests.swift index 05589780..6598b3b2 100644 --- a/PulseLoopTests/ColmiDecoderTests.swift +++ b/PulseLoopTests/ColmiDecoderTests.swift @@ -270,6 +270,27 @@ final class ColmiDecoderTests: XCTestCase { XCTAssertEqual(stages.filter { $0 == .rem }.count, 30) } + /// A Ring 2 Pro full night, captured 2026-10-07 09:33 (HCI snoop, PulseLoop Android sync). A session + /// over 180 minutes lands on the main-sleep action (`0x27`); Android and QRing both showed it as + /// 2:30 AM to 9:14 AM, 6h 44m, with this exact stage split. + func testRing2ProCapturedNightDecodes() { + let payload: [UInt8] = [ + 0x01, 0x00, 0x22, 0x96, 0x00, 0x2a, 0x02, + 0x02, 0x24, 0x03, 0x1e, 0x02, 0x0f, 0x04, 0x11, 0x02, 0x21, 0x03, 0x10, 0x02, 0x17, + 0x04, 0x13, 0x02, 0x33, 0x03, 0x0b, 0x02, 0x38, 0x04, 0x11, 0x02, 0x20, 0x03, 0x18, + 0x02, 0x18, + ] + let events = decoder.decodeBigData(bigData(type: ColmiCommandID.bigDataSleep, payload: payload), calendar: calendar) + guard events.count == 1, case let .sleepTimeline(start, stages) = events[0] else { + return XCTFail("expected one sleepTimeline, got \(events.map(\.kind))") + } + XCTAssertEqual(start, calendar.date(byAdding: .minute, value: 150, to: calendar.startOfDay(for: Date()))) + XCTAssertEqual(stages.count, 404) + XCTAssertEqual(stages.filter { $0 == .light }.count, 270) + XCTAssertEqual(stages.filter { $0 == .deep }.count, 81) + XCTAssertEqual(stages.filter { $0 == .rem }.count, 53) + } + // MARK: Reassembly across split notifications (via the driver) @MainActor @@ -405,6 +426,55 @@ final class ColmiDecoderTests: XCTestCase { XCTAssertFalse(events.contains { if case .heartRateSample = $0 { return true }; return false }) } + // MARK: On-demand HRV / stress / temperature (Ring 2 Pro, QRing capture) + + func testSpotVitalFramesDecodeToTheirVitalInItsUnit() throws { + func sample(_ hex: String) throws -> (SpotVital, Double) { + let events = decoder.decodeNormal(try Data(hexString: hex)) + guard case let .spotVitalSample(vital, value, _) = events.first, events.count == 1 else { + throw NSError(domain: "ColmiDecoderTests", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "expected one spotVitalSample, got \(events.map(\.kind))", + ]) + } + return (vital, value) + } + // Frames as the Ring 2 Pro streamed them to QRing: 69 00 . + let hrv = try sample(ColmiPacket.frame([0x69, 0x0a, 0x00, 0x2b]).hexString) + XCTAssertEqual(hrv.0, .hrv); XCTAssertEqual(hrv.1, 43) + let stress = try sample(ColmiPacket.frame([0x69, 0x08, 0x00, 0x1e]).hexString) + XCTAssertEqual(stress.0, .stress); XCTAssertEqual(stress.1, 30) + // raw 0xa8 = 168 → 168 / 10 + 20, as QRing's TemperatureActivity shows it. + let temp = try sample("690b00a800000000000000000000001c") + XCTAssertEqual(temp.0, .temperature); XCTAssertEqual(temp.1, 36.8, accuracy: 1e-9) + } + + func testSpotVitalWarmUpDecodesToNothingAndAnErrorToNoReading() throws { + XCTAssertTrue(decoder.decodeNormal(try Data(hexString: "690a0000000000000000000000000073")).isEmpty) + XCTAssertTrue(decoder.decodeNormal(try Data(hexString: "690b0000000000000000000000000074")).isEmpty) + let error = decoder.decodeNormal(ColmiPacket.frame([0x69, 0x08, 0x01])) + guard case let .spotVitalNoReading(vital, _) = error.first else { return XCTFail("\(error.map(\.kind))") } + XCTAssertEqual(vital, .stress) + } + + func testHeartRateStreamFrameStillDecodesAsHeartRate() throws { + let events = decoder.decodeNormal(try Data(hexString: "690100590000000000000000000000c3")) + guard case let .heartRateSample(bpm, _) = events.first else { return XCTFail("\(events.map(\.kind))") } + XCTAssertEqual(bpm, 89) + } + + func testSpotVitalStartAndStopFramesMatchQRing() { + let encoder = ColmiEncoder() + // Start = StartHeartRateReq.getSimpleReq(type) as captured: 69 0a 25 / 69 08 25 / 69 0b 25. + XCTAssertEqual(encoder.spotVital(.hrv, enable: true), [0x69, 0x0a, 0x25]) + XCTAssertEqual(encoder.spotVital(.stress, enable: true), [0x69, 0x08, 0x25]) + XCTAssertEqual(encoder.spotVital(.temperature, enable: true), [0x69, 0x0b, 0x25]) + // HRV and stress stops hand the ring back its reading: captured `6a0a2b…` after an HRV of 43. + XCTAssertEqual(encoder.spotVital(.hrv, enable: false, lastRaw: 43), [0x6a, 0x0a, 0x2b, 0x00]) + XCTAssertEqual(encoder.spotVital(.stress, enable: false, lastRaw: 50), [0x6a, 0x08, 0x32, 0x00]) + // Temperature's never does: QRing's stopTemperatureCheck() = 6a 0b 00 00. + XCTAssertEqual(encoder.spotVital(.temperature, enable: false, lastRaw: 168), [0x6a, 0x0b, 0x00, 0x00]) + } + func testManualHeartRateOkAndError() { // ok: 69 00 00 let okFrame = ColmiPacket.frame([ColmiCommandID.manualHeartRate, 0, 0, 72]) diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 3e6a7550..77b85fe4 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -339,6 +339,7 @@ final class PairingMatchingTests: XCTestCase { "COLMI R10_xyz": "colmi-r10", "R11C_BEEF": "colmi-r11", "COLMI R12_x": "colmi-r12", + "COLMI Ring 2 Pro": "colmi-ring-2-pro", "R99 54DC": "colmi-r99", "R05_1A2B": "yawell-r05", "R10_DEAD": "yawell-r10", @@ -350,6 +351,20 @@ final class PairingMatchingTests: XCTestCase { } } + /// The Ring 2 Pro's on-demand HRV / stress / temperature are a per-model grant on top of the Colmi + /// family's baseline, and the family itself offers none of the three. + func testRing2ProCarriesItsOnDemandVitals() { + XCTAssertEqual(WearableModel.colmiRing2Pro.family, .colmiR02) + XCTAssertEqual(WearableModel.colmiRing2Pro.extraCapabilities, [.manualHrv, .manualStress, .manualTemperature]) + XCTAssertTrue(ColmiCoordinator().capabilities.isDisjoint(with: [.manualHrv, .manualStress, .manualTemperature])) + // Only hardware-validated models carry extras. + for model in WearableModel.catalog where model.id != WearableModel.colmiRing2Pro.id { + XCTAssertTrue(model.extraCapabilities.isEmpty, "\(model.id) has unvalidated extras") + } + // A serial-suffixed name is some other product, not this one. + XCTAssertNotEqual(WearableModel.model(advertisedName: "COLMI Ring 2 Pro_AB12")?.id, "colmi-ring-2-pro") + } + func testDetectedModelOverridesCarouselSelection() { let model = WearableModel.resolve( advertisedName: "COLMI R10_xyz", diff --git a/PulseLoopTests/SpotVitalWindowTests.swift b/PulseLoopTests/SpotVitalWindowTests.swift new file mode 100644 index 00000000..4edaa8c9 --- /dev/null +++ b/PulseLoopTests/SpotVitalWindowTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import PulseLoop + +/// `SpotVitalWindow` settles an on-demand HRV / stress / temperature run the way QRing does: on the last +/// plausible value, one second after the ring stops streaming. +final class SpotVitalWindowTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_000_000) + + func testSettlesOnTheLastValueOneSecondAfterTheRingGoesQuiet() { + var window = SpotVitalWindow() + window.begin(.hrv) + for (i, ms) in [41.0, 44, 43].enumerated() { + window.collect(.hrv, value: ms, at: t0.addingTimeInterval(Double(i) * 0.5)) + } + let last = t0.addingTimeInterval(1) + XCTAssertFalse(window.isSettled(at: last.addingTimeInterval(0.9))) + XCTAssertTrue(window.isSettled(at: last.addingTimeInterval(1))) + XCTAssertEqual(window.settled, 43) + } + + func testNothingSettlesBeforeAValueArrives() { + var window = SpotVitalWindow() + window.begin(.stress) + XCTAssertFalse(window.isSettled(at: t0.addingTimeInterval(60))) + XCTAssertNil(window.settled) + XCTAssertFalse(window.receivedReading) + } + + func testDropsImplausibleValuesAndValuesFromAnotherVital() { + var window = SpotVitalWindow() + window.begin(.temperature) + XCTAssertFalse(window.collect(.temperature, value: 20, at: t0)) // raw 0 would decode to 20 °C + XCTAssertFalse(window.collect(.hrv, value: 36.8, at: t0)) + XCTAssertFalse(window.receivedReading) + XCTAssertTrue(window.collect(.temperature, value: 36.8, at: t0)) + XCTAssertEqual(window.settled, 36.8) + } + + func testBeginClearsThePreviousRun() { + var window = SpotVitalWindow() + window.begin(.stress) + window.collect(.stress, value: 43, at: t0) + window.begin(.stress) + XCTAssertNil(window.settled) + XCTAssertFalse(window.isSettled(at: t0.addingTimeInterval(10))) + } +} diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index bf1dbe5b..4923cc90 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -75,6 +75,7 @@ A SmartHealth-flavoured unit speaks none of this — see | **R09** | Realtek RTL8762 | BLE 5.0 | Varies | Unknown | No | Unknown | | | **R10** | RTL8762 ESF | BLE 5.0 | 17 mAh | 5ATM | No | Vcare VC30F + STK8321 | Charging case: 200 mAh | | **R12** | Realtek RTL8762 | BLE 5.0 | 15/18 mAh | IP68 + 1ATM | Yes | Vcare VC30F + ST LIS2DOC | Newest (2025), 4g weight | +| **Ring 2 Pro** | Realtek Bee (firmware `RY10HCR_*`) | BLE | Unknown | Unknown | No | LIS3DH + PPG | Advertises `COLMI Ring 2 Pro` (no serial suffix). On-demand HRV / stress / temperature — see [below](#colmi-ring-2-pro) | ### Yawell-branded QRing models @@ -101,6 +102,25 @@ but speaks the same Nordic-UART QRing protocol. It pairs with both the **Da Ring PulseLoop matches R11 rings via the `R11C?_[0-9A-F]{4}$` pattern in the Colmi QRing driver. Capabilities should match the R10 (same VC30F + STK8321 sensor pair). +## Colmi Ring 2 Pro + +Same QRing UART + big-data protocol as the R02 family, so it rides the Colmi driver; QRing does not +OS-bond it. Verified on hardware in the Android port (PulseLoopAndroid #100); the iOS port is +unit-tested against the same captures but has not been run against the ring. + +- **On-demand HRV, stress and temperature** (`WearableModel.extraCapabilities`, granted only when the + advertised name matches). QRing's manual measures: start `69 25` with type `0a` HRV, `08` + stress, `0b` temperature; the ring streams `69 ` (value 0 = warm-up, ~25 s), + then goes quiet. The reading is the last value, settled one second after the stream stops + (`SpotVitalWindow`, 75 s ceiling). Stop is `6a 00` for HRV and stress, and a fixed + `6a 0b 00 00` for temperature (`StopHeartRateReq.stopTemperatureCheck`). Temperature is + `raw / 10 + 20` °C. +- **Sleep.** A session of 180 minutes or less is filed under the nap action (`bc 3e`) with main sleep + (`bc 27`) left empty; longer sessions land on `bc 27`, which iOS decodes (pinned by + `testRing2ProCapturedNightDecodes`). **iOS does not request or decode `bc 3e`**, so short sleeps on + this ring are not imported on iOS yet. Firmware analysis shows light/deep/REM are generated with + `rand()`; only the asleep/awake boundaries come from the sensors. + ## Sensors ### Vcare VC30F