diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 02d418b..3e86f73 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -15,6 +15,7 @@ enum AppRoute: Hashable { case settingsActivityTracking case settingsGoals case settingsVitals + case settingsCalibration case settingsPrivacyData case settingsAbout case pairing @@ -69,6 +70,10 @@ enum PulseColors { static let stress = Color(hex: "#FF8A4C") static let hrv = Color(hex: "#9D7CFF") static let temperature = Color(hex: "#2DD4D8") + // jring/56ff metrics + static let bloodPressure = Color(hex: "#FF6B9D") + static let bloodSugar = Color(hex: "#FFB84D") + static let fatigue = Color(hex: "#C77DFF") static let borderSubtle = Color.white.opacity(0.08) static let borderStrong = Color.white.opacity(0.16) } diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 7bd50bb..baec623 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -25,6 +25,12 @@ enum PulseEvent: Sendable { case stressSample(value: Int, timestamp: Date) case hrvSample(value: Int, timestamp: Date) case temperatureSample(celsius: Double, timestamp: Date) + // Extra metrics from the jring/56ff 0x24 combined-sensor packet. + case bloodPressureSample(systolic: Int, diastolic: Int, timestamp: Date) + 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. + case firmwareVersion(String) /// Friendly history-sync progress for the product UI (e.g. "Syncing sleep…"). Never protocol terms. case syncProgress(stage: String) case workoutStarted(UUID) @@ -241,6 +247,16 @@ final class EventPersistenceSubscriber { persistMeasurement(kind: .hrv, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "hrv_sample") case let .temperatureSample(celsius, timestamp): persistMeasurement(kind: .temperature, value: celsius, timestamp: timestamp, source: .colmi, kindLabel: "temperature_sample") + case let .bloodPressureSample(systolic, diastolic, timestamp): + // BP is two metrics in one packet — store as two rows so each trends independently. + persistMeasurement(kind: .bloodPressureSystolic, value: Double(systolic), timestamp: timestamp, source: .live, kindLabel: "bp_systolic_sample") + persistMeasurement(kind: .bloodPressureDiastolic, value: Double(diastolic), timestamp: timestamp, source: .live, kindLabel: "bp_diastolic_sample") + case let .fatigueSample(value, timestamp): + persistMeasurement(kind: .fatigue, value: Double(value), timestamp: timestamp, source: .live, kindLabel: "fatigue_sample") + case let .bloodSugarSample(mgdl, timestamp): + persistMeasurement(kind: .bloodSugar, value: mgdl, timestamp: timestamp, source: .live, kindLabel: "blood_sugar_sample") + case let .firmwareVersion(version): + persistFirmwareVersion(version) case let .sleepTimeline(timestamp, stages): persistSleepTimeline(start: timestamp, stages: stages) case let .gpsPoint(sessionId, latitude, longitude, altitude, horizontalAccuracy, speed, course, accepted, rejectionReason, timestamp): @@ -287,6 +303,12 @@ final class EventPersistenceSubscriber { ) } + /// Record the ring's firmware version on the current Device row (idempotent — no-op if unchanged). + private func persistFirmwareVersion(_ version: String) { + guard let device = DeviceRepository.current(context: context), device.firmwareVersion != version else { return } + device.firmwareVersion = version + } + /// Upsert a sleep session by night, appending this packet's per-minute stage blocks and /// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per /// night, so blocks must accumulate into one session rather than spawning a session per diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 84c94b2..16f377f 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -18,6 +18,11 @@ enum MeasurementKind: String, Codable, CaseIterable { case stress case hrv case temperature = "temp" + // jring/56ff metrics from the 0x24 combined-sensor packet. Append only — raw values persisted. + case bloodPressureSystolic = "bp_sys" + case bloodPressureDiastolic = "bp_dia" + case fatigue + case bloodSugar = "glucose" /// Display unit for a measurement of this kind. var unit: String { @@ -27,6 +32,9 @@ enum MeasurementKind: String, Codable, CaseIterable { case .stress: return "" case .hrv: return "ms" case .temperature: return "°C" + case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg" + case .fatigue: return "" + case .bloodSugar: return "mg/dL" } } } diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 40294e3..b270608 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -52,6 +52,12 @@ struct PulseLoopApp: App { // One-time cleanup of activity totals inflated by the old accumulator bug. ActivityService.migrateInflatedActivityIfNeeded(context: container.mainContext) + // A persisted "connected" state can't survive a restart — the live link is gone. Reset it so + // the UI never shows a false "Connected" until a real connection re-confirms. + if !runningTests { + DeviceRepository.resetStaleConnectionState(context: container.mainContext) + } + // Don't bring up CoreBluetooth under tests (see `isRunningUnitTests`). let client = RingBLEClient(startManager: !runningTests) let coordinator = RingSyncCoordinator(client: client, context: container.mainContext) @@ -103,6 +109,12 @@ struct PulseLoopApp: App { // batch isn't lost. if phase != .active { persistence.flush() } guard phase == .active else { return } + // Foreground reconnect: the OS can silently tear down the BLE link while suspended without + // delivering a disconnect, leaving us "connected" but dead. On every resume, re-link the + // last-known ring if it isn't actually connected. (Android foreground-reconnect parity.) + if bleClient.hasLastKnownRing, bleClient.state != .connected { + bleClient.connectLastKnown() + } // Both calls are no-ops when the AI Coach master switch is off — the // scheduler gates on `coachMasterEnabled`, and `runDueSlot` short // -circuits via the feature-flags gate. diff --git a/PulseLoop/RingProtocol/JringCoordinator.swift b/PulseLoop/RingProtocol/JringCoordinator.swift index fd00cd3..158faa8 100644 --- a/PulseLoop/RingProtocol/JringCoordinator.swift +++ b/PulseLoop/RingProtocol/JringCoordinator.swift @@ -27,6 +27,8 @@ final class JringCoordinator: WearableCoordinator { .heartRate, .spo2, .steps, .sleep, .battery, .manualHeartRate, .manualSpo2, // jring supports on-demand HR + SpO2 spot readings .realtimeHeartRate, .findDevice, + // 56ff combined-sensor metrics decoded from the 0x24 packet. + .bloodPressure, .bloodSugar, .fatigue, .stress, .hrv, ] let iconSystemName = "circle.hexagongrid.circle.fill" diff --git a/PulseLoop/RingProtocol/JringDriver.swift b/PulseLoop/RingProtocol/JringDriver.swift index b3f2897..e263f9a 100644 --- a/PulseLoop/RingProtocol/JringDriver.swift +++ b/PulseLoop/RingProtocol/JringDriver.swift @@ -29,7 +29,7 @@ final class JringDriver: WearableDriver { func frame(_ command: Data) -> Data { command } // already 20 bytes, no checksum func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { - [decoder.decode(data)] + decoder.decodeAll(data) } func makeSyncEngine() -> RingSyncEngine { diff --git a/PulseLoop/RingProtocol/JringSyncEngine.swift b/PulseLoop/RingProtocol/JringSyncEngine.swift index 63c0f6b..8ca9bf5 100644 --- a/PulseLoop/RingProtocol/JringSyncEngine.swift +++ b/PulseLoop/RingProtocol/JringSyncEngine.swift @@ -12,22 +12,90 @@ final class JringSyncEngine: RingSyncEngine { private weak var writer: RingCommandWriter? private let encoder = RingEncoder() + /// The user's profile for the ring's 0x02 user-info command. `nil` ⇒ send neutral defaults so the + /// ring's blood-sugar/calorie algorithms still have something to work with. + private var userProfile: UserProfileValues? + /// Reference BP calibration (mmHg). 0 ⇒ not calibrated, so we skip the 0x33 push. + private var bpReferenceSystolic = 0 + private var bpReferenceDiastolic = 0 + init(writer: RingCommandWriter?) { self.writer = writer } func runStartup() { - // Canonical startup: status → time sync → locale → activity query → history → history measurements. + // Claim the ring first (0x48) so it streams to us even if another app held it, then push the + // user profile (0x02) + any BP calibration (0x33) the ring needs for its on-device algorithms, + // then the canonical status → time → locale → activity → history flow. + writer?.enqueue(encoder.makeAppIdentifierCommand()) + writer?.enqueue(Data(userInfoCommand())) + if bpReferenceSystolic > 0, bpReferenceDiastolic > 0 { + writer?.enqueue(encoder.makeBPAdjustCommand(systolic: bpReferenceSystolic, diastolic: bpReferenceDiastolic)) + } writer?.enqueue(encoder.makeStatusCommand()) writer?.enqueue(encoder.makeTimeSyncCommand()) writer?.enqueue(encoder.makeLocaleCommand()) - writer?.enqueue(encoder.makeActivityQueryCommand()) writer?.enqueue(encoder.makeHistoryQueryCommand()) writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand()) } func handle(_ event: RingDecodedEvent) { - // Fire-and-forget protocol — nothing to advance. + // Bind handshake is the one response-driven flow: the ring may initiate a claim on connect. + if case let .bind(action, _) = event { respondToBind(action: action) } + } + + // MARK: - User profile (0x02) + + func setUserProfile(_ profile: UserProfileValues) { + userProfile = profile + } + + func applyUserProfile(_ profile: UserProfileValues) { + userProfile = profile + writer?.enqueue(Data(userInfoCommand())) + } + + /// Build the 0x02 command from the stored profile, falling back to the legacy hardcoded defaults + /// (age 25, male, 184 cm, 90 kg) when no profile has been pushed yet. + private func userInfoCommand() -> [UInt8] { + guard let p = userProfile else { + return [UInt8](encoder.makeUserInfoCommand(age: 25, isMale: true, heightCm: 184, weightKg: 90)) + } + // jring encodes sex as a high bit on the age byte; `UserProfileValues.gender == 0x01` ⇒ male. + return [UInt8](encoder.makeUserInfoCommand( + age: Int(p.age), isMale: p.gender == 0x01, heightCm: Int(p.heightCm), weightKg: Int(p.weightKg) + )) + } + + // MARK: - Blood-pressure calibration (0x33) + + func setBloodPressureCalibration(systolic: Int, diastolic: Int) { + bpReferenceSystolic = systolic + bpReferenceDiastolic = diastolic + } + + func applyBloodPressureCalibration(systolic: Int, diastolic: Int) { + bpReferenceSystolic = systolic + bpReferenceDiastolic = diastolic + guard systolic > 0, diastolic > 0 else { return } + writer?.enqueue(encoder.makeBPAdjustCommand(systolic: systolic, diastolic: diastolic)) + } + + // MARK: - Bind handshake (0x4B) + + /// Respond to the ring-driven bind handshake: INIT(0) → reply APP_START(1); ACK(2) → reply + /// SUCCESS(4). Other actions (cancel/unbond) need no app reply here. + private func respondToBind(action: UInt8) { + switch action { + case 0: writer?.enqueue(encoder.makeBindCommand(action: 1)) // INIT → APP_START + case 2: writer?.enqueue(encoder.makeBindCommand(action: 4)) // ACK → SUCCESS + default: break + } + } + + /// Release the ring on Forget: send UNBOND (0x4B action 5) so the ring re-advertises for other apps. + func unbind() { + writer?.enqueue(encoder.makeBindCommand(action: 5)) } func startHeartRate() { diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index d66f233..3f3a26b 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -81,16 +81,42 @@ final class RingBLEClient: NSObject { /// Each queued write carries its already-framed bytes and which characteristic to send it to. private var writeQueue: [(data: Data, useCommandChannel: Bool)] = [] private var writeInFlight = false + /// Monotonic id for the in-flight write, so a timeout task only unblocks *its* write (not a newer + /// one that has since started). Mirrors the Android write-ACK timeout guard. + private var writeSeq: UInt64 = 0 /// When true, an unexpected disconnect triggers an automatic reconnect attempt. private var autoReconnect = true + // MARK: Connection reliability (mirrors the Android RingBLEClient hardening) + private let encoder = RingEncoder() + /// Wall-clock of the last proof the link is alive (notification, write ACK, or read). Drives the + /// watchdog's zombie-link detection. + private var lastActivityAt: Date? + private var keepaliveTask: Task? + private var watchdogTask: Task? + /// Keepalive cadence — 15s, comfortably inside the ring's ~20s idle timeout. + private let keepaliveInterval: UInt64 = 15_000_000_000 + /// Watchdog tick + the "no activity ⇒ zombie link" threshold. The threshold is loosened from + /// Android's 50s because iOS hands background apps shorter, less predictable execution windows. + private let watchdogInterval: UInt64 = 15_000_000_000 + private let linkStaleSeconds: TimeInterval = 60 + /// Write-ACK timeout: if CoreBluetooth never reports the write completing, unblock the queue so a + /// single dropped ACK can't wedge it. + private let writeAckTimeout: UInt64 = 4_000_000_000 + private static let lastPeripheralKey = "ring.lastPeripheralIdentifier" private static let lastDeviceTypeKey = "ring.lastDeviceType" /// The 0x180F battery service, used only when the active driver exposes GATT battery. private let batteryServiceCBUUID = CBUUID(string: "180F") + /// Standard Device Information Service + its firmware/software revision characteristics. The 56ff + /// ring exposes these even without advertising 0x180A, so we scan for them across all services + /// (Android firmware-discovery parity). + private let disServiceCBUUID = CBUUID(string: "180A") + private let firmwareCharUUIDs: Set = [CBUUID(string: "2A26"), CBUUID(string: "2A28")] + override convenience init() { self.init(startManager: true) } @@ -158,9 +184,24 @@ final class RingBLEClient: NSObject { } } - /// Forget the active/last ring: disconnect and clear the remembered identifier + device type so - /// the app no longer auto-reconnects to it. + /// Forget the active/last ring: release it (jring sends the 0x4B UNBOND so the ring re-advertises + /// for other apps), then disconnect and clear the remembered identifier + device type so the app no + /// longer auto-reconnects to it. The unbind is best-effort — we give the write a short window to + /// flush before tearing the link down, but never block Forget on it. func forget() { + if state == .connected, let engine = activeSyncEngine { + engine.unbind() + // Let the UNBOND write flush (and the ring ACK) before we drop the link. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + finalizeForget() + } + } else { + finalizeForget() + } + } + + private func finalizeForget() { disconnect() UserDefaults.standard.removeObject(forKey: Self.lastPeripheralKey) UserDefaults.standard.removeObject(forKey: Self.lastDeviceTypeKey) @@ -201,6 +242,15 @@ final class RingBLEClient: NSObject { private func beginConnect(to target: CBPeripheral, deviceType: RingDeviceType?) { central.stopScan() autoReconnect = true + // Force-close any stale connection (incl. a different peripheral) before opening a new one, so + // a reconnect after an idle drop can't collide with an orphaned handle. iOS analogue of + // Android's gatt.disconnect()+close(). Reset the per-connection write state too. + stopReliabilityTimers() + if let old = peripheral, old.identifier != target.identifier || old.state != .disconnected { + central.cancelPeripheralConnection(old) + } + writeChar = nil; commandChar = nil; notifyChars = [:]; batteryCharacteristic = nil + writeInFlight = false; writeQueue = [] peripheral = target target.delegate = self // Select the coordinator/driver for this connection. Default to jring if discovery didn't @@ -233,8 +283,66 @@ final class RingBLEClient: NSObject { // device/firmware didn't expose a separate one. let target = (item.useCommandChannel ? commandChar : writeChar) ?? writeChar writeInFlight = true + writeSeq &+= 1 + let seq = writeSeq publishRawPacket(direction: .outgoing, data: item.data) peripheral.writeValue(item.data, for: target, type: .withResponse) + // Guard against a missed `didWriteValueFor`: if this write is still in flight after the + // timeout, unblock the queue so one dropped ACK can't wedge it (Android write-ACK timeout). + Task { @MainActor in + try? await Task.sleep(nanoseconds: writeAckTimeout) + if writeInFlight, seq == writeSeq { + writeInFlight = false + pumpWrites() + } + } + } + + // MARK: - Connection reliability + + /// Record that the link just proved itself alive (notification / write ACK / read). + private func noteActivity() { lastActivityAt = Date() } + + /// Start the periodic keepalive ping. jring-only: Colmi runs its own keepalive inside its sync + /// engine, so pinging here would double up. The ring's ~20s idle timeout means a missed ping is + /// recoverable on the next tick. + private func startKeepalive() { + keepaliveTask?.cancel() + guard activeDeviceType == .jring else { return } + keepaliveTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: self?.keepaliveInterval ?? 15_000_000_000) + guard let self, !Task.isCancelled, self.state == .connected else { return } + self.enqueueWrite(self.encoder.makeKeepaliveCommand()) + } + } + } + + /// Watchdog: CoreBluetooth doesn't always deliver a disconnect when the OS tears the link down in + /// the background, leaving a "zombie" peripheral that's `.connected` but silent. If we've gone + /// `linkStaleSeconds` with no inbound activity, force a reconnect. Also catches a hung connect. + private func startWatchdog() { + watchdogTask?.cancel() + watchdogTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: self?.watchdogInterval ?? 15_000_000_000) + guard let self, !Task.isCancelled else { return } + self.watchdogTick() + } + } + } + + private func watchdogTick() { + guard isBluetoothReady, state == .connected, let last = lastActivityAt else { return } + if Date().timeIntervalSince(last) > linkStaleSeconds { + // Zombie link: drop it and let the disconnect handler's auto-reconnect re-link. + if let peripheral { central.cancelPeripheralConnection(peripheral) } + } + } + + private func stopReliabilityTimers() { + keepaliveTask?.cancel(); keepaliveTask = nil + watchdogTask?.cancel(); watchdogTask = nil } private func publishRawPacket(direction: PacketDirection, data: Data) { @@ -324,9 +432,10 @@ extension RingBLEClient: CBCentralManagerDelegate { nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { MainActor.assumeIsolated { peripheral.delegate = self - var services = activeDriver?.serviceUUIDs ?? [] - if let battery = activeDriver?.batteryServiceUUID { services.append(battery) } - peripheral.discoverServices(services.isEmpty ? nil : services) + // Discover ALL services so we also find a Device Information Service the ring exposes + // without advertising it (firmware revision lives there). Characteristic discovery below + // filters to the driver's chars + battery + firmware. + peripheral.discoverServices(nil) } } @@ -356,6 +465,8 @@ extension RingBLEClient: CBCentralManagerDelegate { error: Error? ) { MainActor.assumeIsolated { + stopReliabilityTimers() + lastActivityAt = nil writeChar = nil commandChar = nil notifyChars = [:] @@ -390,6 +501,9 @@ extension RingBLEClient: CBPeripheralDelegate { if let batteryChar = driver.batteryCharUUID { peripheral.discoverCharacteristics([batteryChar], for: service) } + } else if service.uuid == disServiceCBUUID { + // Standard Device Information Service — read firmware/software revision. + peripheral.discoverCharacteristics(Array(firmwareCharUUIDs), for: service) } } } @@ -413,6 +527,8 @@ extension RingBLEClient: CBPeripheralDelegate { } else if characteristic.uuid == driver.batteryCharUUID { batteryCharacteristic = characteristic peripheral.readValue(for: characteristic) + } else if firmwareCharUUIDs.contains(characteristic.uuid) { + peripheral.readValue(for: characteristic) } } } @@ -439,6 +555,9 @@ extension RingBLEClient: CBPeripheralDelegate { if let type = activeDeviceType { publish(.deviceIdentified(deviceType: type, capabilities: activeCapabilities)) } + noteActivity() + startKeepalive() + startWatchdog() readBattery() onConnected?() pumpWrites() @@ -452,6 +571,7 @@ extension RingBLEClient: CBPeripheralDelegate { ) { MainActor.assumeIsolated { guard let value = characteristic.value else { return } + noteActivity() if characteristic.uuid == activeDriver?.batteryCharUUID { if let first = value.first { batteryPercent = Int(first) @@ -459,6 +579,14 @@ extension RingBLEClient: CBPeripheralDelegate { } return } + // Firmware revision read from a standard DIS characteristic (0x2A26/0x2A28) — surface it. + if firmwareCharUUIDs.contains(characteristic.uuid) { + if let fw = String(data: value, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !fw.isEmpty { + publish(.firmwareVersion(fw)) + } + return + } guard let driver = activeDriver, driver.notifyUUIDs.contains(characteristic.uuid) else { return } for decoded in driver.ingest(value, from: characteristic.uuid) { publish(.rawPacket(direction: .incoming, data: value, decoded: decoded)) @@ -477,6 +605,7 @@ extension RingBLEClient: CBPeripheralDelegate { error: Error? ) { MainActor.assumeIsolated { + noteActivity() writeInFlight = false pumpWrites() } diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index bb5fc07..0e66b70 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -19,6 +19,13 @@ enum RingEventBridge { static let hrvRange: ClosedRange = 1...300 /// Plausible skin/body temperature, in °C. static let temperatureRange: ClosedRange = 30...45 + /// Plausible blood-pressure bounds (mmHg) — drops misframed 0x24 bytes. + static let systolicRange: ClosedRange = 60...250 + static let diastolicRange: ClosedRange = 30...160 + /// Plausible fatigue score (0–100 scale; 0 = no sample). + static let fatigueRange: ClosedRange = 1...100 + /// Plausible blood sugar, in mg/dL. + static let bloodSugarRange: ClosedRange = 40...600 /// Sanity ceilings for one intraday activity bucket (~15 min): well above any human cadence so /// only clearly-misframed packets are rejected. static let maxBucketSteps = 5000 @@ -86,8 +93,35 @@ enum RingEventBridge { // last-sync) by re-asserting the connected state with the address attached. return [.deviceStateChanged(state: .connected, address: address)] - case .timeSyncAck, .commandAck, .unknown: - // No typed measurement — the raw packet was already logged for the debug feed. + default: + // Everything else: events with no typed fan-out here (timeSyncAck/commandAck/unknown, and + // bind — advanced by the sync engine's `handle`), plus the jring/56ff 0x24 extras + firmware + // which are split into `extraMetricEvents` to keep this switch's complexity in check. + return extraMetricEvents(for: decoded) + } + } + + /// Fan-out for the jring/56ff 0x24 extra metrics (BP, fatigue, blood sugar) and firmware, with the + /// same plausibility gating as the main vitals. Split from `events` so neither switch grows past + /// the project's cyclomatic-complexity limit. + private static func extraMetricEvents(for decoded: RingDecodedEvent) -> [PulseEvent] { + switch decoded { + case let .bloodPressureSample(systolic, diastolic, timestamp): + guard systolicRange.contains(systolic), diastolicRange.contains(diastolic) else { return [] } + return [.bloodPressureSample(systolic: systolic, diastolic: diastolic, timestamp: timestamp)] + + case let .fatigueSample(value, timestamp): + guard fatigueRange.contains(value) else { return [] } + return [.fatigueSample(value: value, timestamp: timestamp)] + + case let .bloodSugarSample(mgdl, timestamp): + guard bloodSugarRange.contains(mgdl) else { return [] } + return [.bloodSugarSample(mgdl: mgdl, timestamp: timestamp)] + + case let .firmware(version): + return [.firmwareVersion(version)] + + default: return [] } } diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index fad7f83..b9b77f7 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -9,7 +9,9 @@ enum RingUUIDs { enum RingCommandID: UInt8 { case timeSync = 0x01 - case activityQueryAck = 0x02 + /// User profile (age/sex/height/weight). The 0x02 *reply* is a generic ack; the *command* sets + /// the on-device profile that drives the ring's blood-sugar/calorie algorithms. + case userInfo = 0x02 case currentActivity = 0x03 case findRingCandidate = 0x04 case percentStatus = 0x0b @@ -23,12 +25,27 @@ enum RingCommandID: UInt8 { case goalOrConfig = 0x1a case deviceTimeOrConfig = 0x20 case locale = 0x21 - case spo2StartStop = 0x23 - case spo2ResultProgress = 0x24 + case combinedStartStop = 0x23 + /// Combined sensor result: HR + systolic + diastolic + SpO₂ + fatigue + stress + blood sugar + + /// HRV in one 9-byte payload (per the APK decompile's `onReceiveSensorData`). iOS previously + /// mislabelled this `spo2ResultProgress`. + case combinedResult = 0x24 case heartRateComplete = 0x27 - case spo2Complete = 0x28 + /// Blood-data notify (measurement-complete marker). iOS previously mislabelled this `spo2Complete`. + case bloodDataNotify = 0x28 + /// Blood-pressure calibration: pushes a reference cuff systolic/diastolic so the ring applies an + /// on-device offset. + case bpAdjust = 0x33 + /// Keepalive ping — prevents the ring's ~20s idle disconnect. + case keepalive = 0x3a + case spo2Toggle = 0x3e + case spo2Result = 0x3f case appIdentifier = 0x48 + /// Bind/unbind handshake (ring-driven claim + app-driven release on Forget). + case bind = 0x4b case mode = 0x52 + /// Firmware version notify (alternate to the 0x0c status payload). + case firmware = 0xf6 } enum RingProtocolError: Error { @@ -40,7 +57,7 @@ struct RingPacket { let commandId: UInt8 let payload: [UInt8] let raw: Data - + init(data: Data) throws { guard data.count == 20 else { throw RingProtocolError.invalidLength(data.count) @@ -67,14 +84,22 @@ enum RingDecodedEvent: Sendable { case stressSample(value: Int, timestamp: Date) case hrvSample(value: Int, timestamp: Date) // milliseconds case temperatureSample(celsius: Double, timestamp: Date) + // Extra metrics carried in the 0x24 combined-sensor packet (jring/56ff). + case bloodPressureSample(systolic: Int, diastolic: Int, timestamp: Date) + case fatigueSample(value: Int, timestamp: Date) // 0–100 scale + case bloodSugarSample(mgdl: Double, timestamp: Date) case historySyncProgress(stage: String) case historySyncFinished case battery(percent: Int) case status(address: String?) + /// Firmware version string parsed from the 0x0c status / 0xf6 firmware payload. + case firmware(version: String) + /// Ring bind/unbind handshake frame (0x4B): `action`/`state` per the protocol state machine. + case bind(action: UInt8, state: UInt8) case timeSyncAck(timestamp: Date) case commandAck(commandId: UInt8) case unknown(commandId: UInt8, raw: Data) - + var kind: String { switch self { case .activityUpdate: return "activity" @@ -89,27 +114,32 @@ enum RingDecodedEvent: Sendable { case .stressSample: return "stress_sample" case .hrvSample: return "hrv_sample" case .temperatureSample: return "temperature_sample" + case .bloodPressureSample: return "blood_pressure_sample" + case .fatigueSample: return "fatigue_sample" + case .bloodSugarSample: return "blood_sugar_sample" case .historySyncProgress: return "history_sync_progress" case .historySyncFinished: return "history_sync_finished" case .battery: return "battery" case .status: return "status" + case .firmware: return "firmware" + case .bind: return "bind" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" case .unknown: return "unknown" } } - + var confidence: DecodeConfidence { switch self { case .unknown: return .unknown - case .commandAck, .heartRateComplete, .spo2Complete, .spo2Progress: + case .commandAck, .heartRateComplete, .spo2Complete, .spo2Progress, .bind, .firmware: return .partial default: return .known } } - + var debugJSON: String { switch self { case let .activityUpdate(_, steps, distanceMeters, calories): @@ -126,6 +156,16 @@ enum RingDecodedEvent: Sendable { return #"{"hrv_ms":\#(value)}"# case let .temperatureSample(celsius, _): return #"{"temp_c":\#(celsius)}"# + case let .bloodPressureSample(systolic, diastolic, _): + return #"{"systolic":\#(systolic),"diastolic":\#(diastolic)}"# + case let .fatigueSample(value, _): + return #"{"fatigue":\#(value)}"# + case let .bloodSugarSample(mgdl, _): + return #"{"glucose_mgdl":\#(Int(mgdl))}"# + case let .firmware(version): + return #"{"firmware":"\#(version)"}"# + case let .bind(action, state): + return #"{"bind_action":\#(action),"bind_state":\#(state)}"# case let .historySyncProgress(stage): return #"{"stage":"\#(stage)"}"# case let .battery(percent): @@ -139,14 +179,86 @@ enum RingDecodedEvent: Sendable { } struct RingDecoder { + /// Decode one inbound frame into *all* the events it carries. Most frames decode to a single + /// event; the `0x24` combined-sensor packet fans out into several (HR, BP, SpO₂, fatigue, stress, + /// blood sugar, HRV). `JringDriver.ingest` calls this. + func decodeAll(_ data: Data) -> [RingDecodedEvent] { + guard let packet = try? RingPacket(data: data) else { + return [.unknown(commandId: data.first ?? 0, raw: data)] + } + let bytes = [UInt8](packet.raw) + if packet.commandId == 0x24 { + return decodeCombined(bytes, now: Date()) + } + // 0x16 history "data" blocks (sub-type 0xA0) carry 12 one-minute HR samples → two 6-sample + // averages 60s apart. Fan those out; all other 0x16 sub-types fall through to `decode`. + if packet.commandId == 0x16, bytes.count >= 20, bytes[1] == 0xa0 { + return decodeHistoryHeartRate(bytes) + } + // The 0x0c status payload carries both the embedded address and the firmware string; emit both. + if packet.commandId == 0x0c, bytes.count >= 13 { + return [decode(data), .firmware(version: firmwareString(bytes))] + } + return [decode(data)] + } + + /// Decode a 0x16 `0xA0` data block: base timestamp at [2..5] (LE u32), then two consecutive + /// blocks of six 1-minute HR samples at [8..13] and [14..19]. Each block is averaged into one + /// reading, the second timestamped 60s after the first. Mirrors the Android multi-packet routing. + private func decodeHistoryHeartRate(_ bytes: [UInt8]) -> [RingDecodedEvent] { + let base = TimeInterval(u32le(bytes, 2)) + func average(_ slice: ArraySlice) -> Int? { + let valid = slice.map { Int($0) }.filter { $0 > 0 } + guard !valid.isEmpty else { return nil } + return Int((Double(valid.reduce(0, +)) / Double(valid.count)).rounded()) + } + var events: [RingDecodedEvent] = [] + if let avg = average(bytes[8..<14]) { + events.append(.historyMeasurement(kind: .heartRate, value: Double(avg), + timestamp: Date(timeIntervalSince1970: base))) + } + if let avg = average(bytes[14..<20]) { + events.append(.historyMeasurement(kind: .heartRate, value: Double(avg), + timestamp: Date(timeIntervalSince1970: base + 60))) + } + return events.isEmpty ? [.commandAck(commandId: 0x16)] : events + } + + /// Decode the 9-byte `0x24` combined-sensor payload into one event per valid metric. Byte map + /// (matching the official Jring app's `onReceiveSensorData`): [1]=HR, [2]=systolic, [3]=diastolic, + /// [4]=SpO₂, [5]=fatigue, [6]=stress, [7]=blood sugar (mmol/L×10), [8]=HRV (ms). + private func decodeCombined(_ bytes: [UInt8], now: Date) -> [RingDecodedEvent] { + guard bytes.count >= 9 else { return [.commandAck(commandId: 0x24)] } + var events: [RingDecodedEvent] = [] + let hr = Int(bytes[1]) + if hr > 0 { events.append(.heartRateSample(bpm: hr, timestamp: now)) } + let systolic = Int(bytes[2]), diastolic = Int(bytes[3]) + if systolic > 0, diastolic > 0 { + events.append(.bloodPressureSample(systolic: systolic, diastolic: diastolic, timestamp: now)) + } + if (80...100).contains(bytes[4]) { + events.append(.spo2Result(value: Int(bytes[4]), timestamp: now)) + } + if bytes[5] > 0 { events.append(.fatigueSample(value: Int(bytes[5]), timestamp: now)) } + if bytes[6] > 0 { events.append(.stressSample(value: Int(bytes[6]), timestamp: now)) } + if bytes[7] > 0 { + // Ring reports mmol/L×10; convert to the mg/dL the rest of the app displays. + let mgdl = (Double(bytes[7]) / 10.0) * 18.016 + events.append(.bloodSugarSample(mgdl: mgdl, timestamp: now)) + } + if bytes[8] > 0 { events.append(.hrvSample(value: Int(bytes[8]), timestamp: now)) } + // A genuinely empty packet (warm-up) still needs to advance the write queue downstream. + return events.isEmpty ? [.commandAck(commandId: 0x24)] : events + } + func decode(_ data: Data) -> RingDecodedEvent { guard let packet = try? RingPacket(data: data) else { return .unknown(commandId: data.first ?? 0, raw: data) } - + let bytes = [UInt8](packet.raw) let now = Date() - + switch packet.commandId { case 0x01 where bytes.count >= 6: return .timeSyncAck(timestamp: Date(timeIntervalSince1970: TimeInterval(u32le(bytes, 1)))) @@ -161,6 +273,8 @@ struct RingDecoder { case 0x0b where bytes.count >= 2: return .battery(percent: Int(bytes[1])) case 0x0c: + // The 0x0c payload also carries firmware (version/cid/did); that's fanned out separately + // in `decodeAll`. Here we surface just the embedded address (re-asserts connected state). let address = bytes.count >= 9 ? bytes[3...8].map { String(format: "%02x", $0) }.joined(separator: ":") : nil return .status(address: address) case 0x11 where bytes.count >= 20: @@ -169,27 +283,43 @@ struct RingDecoder { return .sleepTimeline(timestamp: timestamp, stages: stages) case 0x14 where bytes.count >= 6: return .heartRateSample(bpm: Int(bytes[5]), timestamp: now) - case 0x16 where bytes.count >= 9: - let timestamp = Date(timeIntervalSince1970: TimeInterval(u32le(bytes, bytes[1] == 0xaa ? 3 : 2))) - let values = bytes.dropFirst(8).filter { $0 > 0 } - if let first = values.first { - return .historyMeasurement(kind: .heartRate, value: Double(first), timestamp: timestamp) - } + case 0x16: + // Header (0xF0) carries the total packet count; index (0xAA) / finished (0xFF) are + // sync-flow markers. Data blocks (0xA0) are handled in `decodeAll`. None decode to a + // measurement on their own, so they're plain acks here. return .commandAck(commandId: packet.commandId) case 0x24: - if bytes.count >= 5, (80...100).contains(bytes[4]) { - return .spo2Result(value: Int(bytes[4]), timestamp: now) - } - return .spo2Progress(percent: nil, timestamp: now) + // Combined-sensor packet: `decodeAll` fans out every metric; a single-event caller just + // gets the first (HR) for backwards compatibility. + return decodeCombined(bytes, now: now).first ?? .commandAck(commandId: packet.commandId) case 0x27: return .heartRateComplete(timestamp: now) case 0x28: + // Blood-data notify: a measurement-complete marker. Keep emitting `spo2Complete` so the + // existing "measurement finished" consumers still fire. return .spo2Complete(timestamp: now) + case 0x3f where bytes.count >= 2 && (80...100).contains(bytes[1]): + // Dedicated SpO₂ result command (separate from the combined packet). + return .spo2Result(value: Int(bytes[1]), timestamp: now) + case 0x4b where bytes.count >= 3: + return .bind(action: bytes[1], state: bytes[2]) + case 0xf6 where bytes.count >= 6: + // Alternate firmware notify: version at [4..5] (LE u16). + let version = Int(bytes[4]) | (Int(bytes[5]) << 8) + return .firmware(version: "V\(version)") default: return .unknown(commandId: packet.commandId, raw: packet.raw) } } - + + /// Build the firmware string from a 0x0c status payload: `"%04X%04XV%d"` of cid, did, version. + private func firmwareString(_ bytes: [UInt8]) -> String { + let version = Int(bytes[1]) | (Int(bytes[2]) << 8) + let cid = Int(bytes[9]) | (Int(bytes[10]) << 8) + let did = Int(bytes[11]) | (Int(bytes[12]) << 8) + return String(format: "%04X%04XV%d", cid, did, version) + } + private func u32le(_ bytes: [UInt8], _ offset: Int) -> UInt32 { guard bytes.count >= offset + 4 else { return 0 } return UInt32(bytes[offset]) @@ -197,7 +327,7 @@ struct RingDecoder { | UInt32(bytes[offset + 2]) << 16 | UInt32(bytes[offset + 3]) << 24 } - + private func stage(_ byte: UInt8) -> SleepStage { switch byte { case 0x28: return .light @@ -250,6 +380,57 @@ struct RingEncoder { return Data(bytes) } + /// User profile (0x02). The ring uses age/sex/height/weight for its blood-sugar/calorie + /// algorithms. Byte layout (matching the Android encoder): `[1]=(age & 0x7F)|(male?0x80:0)`, + /// `[2]=heightCm`, `[3]=weightKg`, `[4]=0x00` (metric flag, always metric on the wire). + func makeUserInfoCommand(age: Int, isMale: Bool, heightCm: Int, weightKg: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x02 + bytes[1] = UInt8(max(0, min(127, age))) | (isMale ? 0x80 : 0x00) + bytes[2] = UInt8(clamping: heightCm) + bytes[3] = UInt8(clamping: weightKg) + bytes[4] = 0x00 + return Data(bytes) + } + + /// Blood-pressure calibration (0x33): reference cuff systolic/diastolic as little-endian u16s, so + /// the ring applies an on-device offset. `[1..2]=systolic`, `[3..4]=diastolic`. + func makeBPAdjustCommand(systolic: Int, diastolic: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x33 + let sys = UInt16(max(0, min(65535, systolic))) + let dia = UInt16(max(0, min(65535, diastolic))) + bytes[1] = UInt8(sys & 0xff) + bytes[2] = UInt8((sys >> 8) & 0xff) + bytes[3] = UInt8(dia & 0xff) + bytes[4] = UInt8((dia >> 8) & 0xff) + return Data(bytes) + } + + /// App identity (0x48): claims the ring with a persistent app ID so it streams data to PulseLoop + /// (prevents the mute behaviour after another app claimed the ring). Up to 18 ASCII bytes. + func makeAppIdentifierCommand(appId: String = "PulseLoop") -> Data { + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x48 + let ascii = Array(appId.utf8.prefix(18)) + for (i, byte) in ascii.enumerated() { bytes[1 + i] = byte } + return Data(bytes) + } + + /// Keepalive ping (0x3A) — sent on an interval to prevent the ring's ~20s idle disconnect. + func makeKeepaliveCommand() -> Data { data(hex: "3a00000000000000000000000000000000000000") } + + /// Bind/unbind handshake frame (0x4B). `[1]=action`, `[2]=state` (0), `[3]=type` (always 1). + /// Actions: 0 INIT, 1 APP_START, 2 ACK, 3 ACK_CANCEL, 4 SUCCESS, 5 UNBOND, 6 UNBOND_ACK. + func makeBindCommand(action: UInt8, state: UInt8 = 0) -> Data { + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x4b + bytes[1] = action + bytes[2] = state + bytes[3] = 0x01 + return Data(bytes) + } + func makeTimeSyncCommand(date: Date = Date()) -> Data { var bytes = [UInt8](repeating: 0, count: 20) bytes[0] = 0x01 @@ -261,7 +442,7 @@ struct RingEncoder { bytes[5] = UInt8(bitPattern: Int8(TimeZone.current.secondsFromGMT(for: date) / 3600)) return Data(bytes) } - + private func data(hex: String) -> Data { (try? Data(hexString: hex)) ?? Data() } @@ -283,7 +464,7 @@ extension Data { } self = data } - + var hexString: String { map { String(format: "%02x", $0) }.joined() } diff --git a/PulseLoop/Services/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 027b1c9..ab576bc 100644 --- a/PulseLoop/Services/DerivedSummaries.swift +++ b/PulseLoop/Services/DerivedSummaries.swift @@ -13,6 +13,11 @@ enum MetricKey: String, CaseIterable { case stress case hrv case temperature = "temp" + // jring/56ff metrics (capability-gated in the UI). + case bloodPressureSystolic = "bp_sys" + case bloodPressureDiastolic = "bp_dia" + case fatigue + case bloodSugar = "glucose" /// The wearable capability that must be present for this metric to be shown. var requiredCapability: WearableCapability? { @@ -22,6 +27,9 @@ enum MetricKey: String, CaseIterable { case .stress: return .stress case .hrv: return .hrv case .temperature: return .temperature + case .bloodPressureSystolic, .bloodPressureDiastolic: return .bloodPressure + case .fatigue: return .fatigue + case .bloodSugar: return .bloodSugar case .steps, .calories, .distance, .activeMinutes: return .steps case .sleep: return .sleep case .battery: return .battery diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index ae98cbf..8089652 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -101,6 +101,14 @@ enum MetricsService { raw = rangeSamples(kind: .hrv, range: range, context: context) case .temperature: raw = rangeSamples(kind: .temperature, range: range, context: context) + case .bloodPressureSystolic: + raw = calibrated(rangeSamples(kind: .bloodPressureSystolic, range: range, context: context), kind: .bloodPressureSystolic) + case .bloodPressureDiastolic: + raw = calibrated(rangeSamples(kind: .bloodPressureDiastolic, range: range, context: context), kind: .bloodPressureDiastolic) + case .fatigue: + raw = rangeSamples(kind: .fatigue, range: range, context: context) + case .bloodSugar: + raw = calibrated(rangeSamples(kind: .bloodSugar, range: range, context: context), kind: .bloodSugar) case .steps, .calories, .distance, .activeMinutes: raw = activitySamples(metric: metric, range: range, context: context) default: @@ -112,6 +120,26 @@ enum MetricsService { return MetricDownsampler.bucketAverage(raw, targetBuckets: targetBuckets) } + /// Apply the user's calibration display offset to a series before it reaches the UI. Raw stored + /// rows are never modified — the offset lives only on the display read path (mirrors the Android + /// "offsets applied in ViewModels before UI" pipeline). + private static func calibrated(_ samples: [MetricSample], kind: MeasurementKind) -> [MetricSample] { + let cal = CalibrationStore.shared.settings + guard cal.adjusted(0, kind: kind) != 0 else { return samples } // no offset ⇒ identity + return samples.map { MetricSample(timestamp: $0.timestamp, value: cal.adjusted($0.value, kind: kind)) } + } + + /// The latest reading for a kind, with calibration offset applied (for "latest value" display). + static func calibratedLatest(kind: MeasurementKind, context: ModelContext) -> Double? { + guard let raw = MetricsRepository.latestMeasurement(kind: kind, context: context)?.value else { return nil } + return CalibrationStore.shared.settings.adjusted(raw, kind: kind) + } + + /// The latest *raw* (uncalibrated) reading for a kind — used to derive a calibration offset. + static func latestRaw(kind: MeasurementKind, context: ModelContext) -> Double? { + MetricsRepository.latestMeasurement(kind: kind, context: context)?.value + } + static func fetchMeasurements(_ context: ModelContext) -> [Measurement] { MetricsRepository.measurements(context: context).sorted { $0.timestamp > $1.timestamp } } @@ -172,6 +200,14 @@ enum MetricsService { value = Double(Int.random(in: 30...90)) case .temperature: value = Double.random(in: 33...36) + case .bloodPressureSystolic: + value = Double(Int.random(in: 110...130)) + case .bloodPressureDiastolic: + value = Double(Int.random(in: 70...85)) + case .fatigue: + value = Double(Int.random(in: 20...70)) + case .bloodSugar: + value = Double(Int.random(in: 85...110)) } let row = MeasurementRepository.insertMeasurement( kind: kind, diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index e7c3d38..d1afb7a 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -11,6 +11,17 @@ enum DeviceRepository { static func current(context: ModelContext) -> Device? { devices(context: context).first } + + /// Stale-state guard: a persisted "connected"/"connecting" must not survive a process restart — + /// the live BLE link is gone, so the UI would otherwise show a false "Connected" until a real + /// connection re-confirms it. Reset such rows on launch. (Android stale-state-guard parity.) + @MainActor + static func resetStaleConnectionState(context: ModelContext) { + for device in devices(context: context) where device.state == .connected || device.state == .connecting { + device.state = .disconnected + } + try? context.save() + } } enum MetricsRepository { diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index c5e7a5d..7532909 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -92,6 +92,10 @@ final class RingSyncCoordinator { if let profile = ProfileRepository.profile(context: context) { engine?.setUserProfile(profileValues(from: profile)) } + let cal = CalibrationStore.shared.settings + if cal.hasBPReference { + engine?.setBloodPressureCalibration(systolic: cal.bpReferenceSystolic, diastolic: cal.bpReferenceDiastolic) + } engine?.runStartup() lastSyncAt = Date() // Show the progress bar immediately; the engine's own `.syncProgress` stages refine the @@ -115,6 +119,15 @@ final class RingSyncCoordinator { engine?.applyUserProfile(profileValues(from: profile)) } + /// Live "Save" from the BP calibration screen: push the reference cuff values to the connected + /// ring (0x33). No-op when disconnected — applied on the next connect handshake. + func applyBloodPressureCalibration() { + guard client.state == .connected else { return } + let cal = CalibrationStore.shared.settings + guard cal.hasBPReference else { return } + engine?.applyBloodPressureCalibration(systolic: cal.bpReferenceSystolic, diastolic: cal.bpReferenceDiastolic) + } + private func profileValues(from profile: UserProfile) -> UserProfileValues { UserProfileValues( metric: profile.units == .metric, diff --git a/PulseLoop/Settings/CalibrationStore.swift b/PulseLoop/Settings/CalibrationStore.swift new file mode 100644 index 0000000..5a66cc3 --- /dev/null +++ b/PulseLoop/Settings/CalibrationStore.swift @@ -0,0 +1,123 @@ +import Foundation + +/// User-entered calibration for the metrics the ring can't measure accurately on its own. +/// +/// Two distinct mechanisms (both mirrored from the Android port): +/// - **Blood pressure** — the reference cuff systolic/diastolic are pushed to the ring (`0x33`) so it +/// applies an on-device offset, *and* a display offset is applied app-side before the UI. +/// - **Blood sugar** — the ring estimates glucose from the user's profile (no real sensor), so the +/// only calibration is an app-side offset: `glucoseOffsetMgdl = referenceMgdl - latestRawMgdl`. +/// +/// Persisted as JSON in `UserDefaults`, mirroring `MetricPrefs` / `MetricPrefsStore`. Raw stored +/// measurements are never modified — offsets are applied only on the display read path. +struct Calibration: Codable, Equatable { + /// Reference cuff readings (mmHg). 0 ⇒ not calibrated. These are both the values pushed to the + /// ring via `0x33` and the basis for the app-side display offset. + var bpReferenceSystolic: Int = 0 + var bpReferenceDiastolic: Int = 0 + /// App-side BP display offsets (mmHg), added to raw ring readings before display. + var bpSystolicOffset: Int = 0 + var bpDiastolicOffset: Int = 0 + /// Blood-sugar app-side offset (mg/dL) and the last reference reading entered (persisted so the + /// settings field can repopulate). + var glucoseOffsetMgdl: Double = 0 + var glucoseRefMgdl: Double = 0 + + static let `default` = Calibration() + + init() {} + + /// Tolerant decode: missing keys fall back to defaults instead of failing the whole decode. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = Calibration.default + bpReferenceSystolic = try c.decodeIfPresent(Int.self, forKey: .bpReferenceSystolic) ?? d.bpReferenceSystolic + bpReferenceDiastolic = try c.decodeIfPresent(Int.self, forKey: .bpReferenceDiastolic) ?? d.bpReferenceDiastolic + bpSystolicOffset = try c.decodeIfPresent(Int.self, forKey: .bpSystolicOffset) ?? d.bpSystolicOffset + bpDiastolicOffset = try c.decodeIfPresent(Int.self, forKey: .bpDiastolicOffset) ?? d.bpDiastolicOffset + glucoseOffsetMgdl = try c.decodeIfPresent(Double.self, forKey: .glucoseOffsetMgdl) ?? d.glucoseOffsetMgdl + glucoseRefMgdl = try c.decodeIfPresent(Double.self, forKey: .glucoseRefMgdl) ?? d.glucoseRefMgdl + } + + var hasBPReference: Bool { bpReferenceSystolic > 0 && bpReferenceDiastolic > 0 } + var isGlucoseCalibrated: Bool { glucoseOffsetMgdl != 0 } + + /// Apply the stored display offset to a raw measurement value of the given kind. All other kinds + /// pass through unchanged. + func adjusted(_ value: Double, kind: MeasurementKind) -> Double { + switch kind { + case .bloodPressureSystolic: return value + Double(bpSystolicOffset) + case .bloodPressureDiastolic: return value + Double(bpDiastolicOffset) + case .bloodSugar: return value + glucoseOffsetMgdl + default: return value + } + } +} + +/// Observable, UserDefaults-backed store for `Calibration`. Mutating `settings` persists immediately; +/// a shared instance keeps Settings and every vitals view in sync (mirrors `MetricPrefsStore`). +@MainActor +@Observable +final class CalibrationStore { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let shared = CalibrationStore() + + private static let storageKey = "pulseloop.calibration.v1" + private let defaults: UserDefaults + + var settings: Calibration { + didSet { persist() } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode(Calibration.self, from: data) { + self.settings = decoded + } else { + self.settings = .default + } + } + + /// Save a BP cuff reference (and derive the app-side display offset against the latest raw BP). + func calibrateBloodPressure(referenceSystolic: Int, referenceDiastolic: Int, latestRawSystolic: Double?, latestRawDiastolic: Double?) { + var s = settings + s.bpReferenceSystolic = referenceSystolic + s.bpReferenceDiastolic = referenceDiastolic + if let raw = latestRawSystolic { s.bpSystolicOffset = referenceSystolic - Int(raw.rounded()) } + if let raw = latestRawDiastolic { s.bpDiastolicOffset = referenceDiastolic - Int(raw.rounded()) } + settings = s + } + + /// Compute and store the glucose offset from a lab/meter reference and the latest raw reading: + /// `offset = reference - latestRaw`. + func calibrateGlucose(referenceMgdl: Double, latestRawMgdl: Double?) { + var s = settings + s.glucoseRefMgdl = referenceMgdl + if let raw = latestRawMgdl { s.glucoseOffsetMgdl = referenceMgdl - raw } + settings = s + } + + func resetGlucose() { + var s = settings + s.glucoseOffsetMgdl = 0 + s.glucoseRefMgdl = 0 + settings = s + } + + func resetBloodPressure() { + var s = settings + s.bpReferenceSystolic = 0 + s.bpReferenceDiastolic = 0 + s.bpSystolicOffset = 0 + s.bpDiastolicOffset = 0 + settings = s + } + + private func persist() { + if let data = try? JSONEncoder().encode(settings) { + defaults.set(data, forKey: Self.storageKey) + } + } +} diff --git a/PulseLoop/ViewModels/VitalsStore.swift b/PulseLoop/ViewModels/VitalsStore.swift index e28714d..d2ec4f5 100644 --- a/PulseLoop/ViewModels/VitalsStore.swift +++ b/PulseLoop/ViewModels/VitalsStore.swift @@ -15,6 +15,11 @@ final class VitalsStore { private(set) var stressSamples: [MetricSample] private(set) var hrvSamples: [MetricSample] private(set) var tempSamples: [MetricSample] + // jring/56ff metrics (calibration offsets already applied by `metricRange`). + private(set) var systolicSamples: [MetricSample] + private(set) var diastolicSamples: [MetricSample] + private(set) var bloodSugarSamples: [MetricSample] + private(set) var fatigueSamples: [MetricSample] private(set) var capabilities: Set /// Which metric cards are visible (capability + user-hidden), computed once per rebuild so the /// view doesn't call `isVisible` (a device fetch each) five times per render. @@ -31,6 +36,10 @@ final class VitalsStore { self.stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) self.hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) self.tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) + self.systolicSamples = MetricsService.metricRange(metric: .bloodPressureSystolic, range: .twentyFourHours, context: modelContext) + self.diastolicSamples = MetricsService.metricRange(metric: .bloodPressureDiastolic, range: .twentyFourHours, context: modelContext) + self.bloodSugarSamples = MetricsService.metricRange(metric: .bloodSugar, range: .twentyFourHours, context: modelContext) + self.fatigueSamples = MetricsService.metricRange(metric: .fatigue, range: .twentyFourHours, context: modelContext) self.capabilities = MetricsService.deviceCapabilities(modelContext) self.visibleMetrics = Self.computeVisible(context: modelContext) self.signature = Self.currentSignature(context: modelContext) @@ -53,6 +62,10 @@ final class VitalsStore { stressSamples = MetricsService.metricRange(metric: .stress, range: .twentyFourHours, context: modelContext) hrvSamples = MetricsService.metricRange(metric: .hrv, range: .twentyFourHours, context: modelContext) tempSamples = MetricsService.metricRange(metric: .temperature, range: .twentyFourHours, context: modelContext) + systolicSamples = MetricsService.metricRange(metric: .bloodPressureSystolic, range: .twentyFourHours, context: modelContext) + diastolicSamples = MetricsService.metricRange(metric: .bloodPressureDiastolic, range: .twentyFourHours, context: modelContext) + bloodSugarSamples = MetricsService.metricRange(metric: .bloodSugar, range: .twentyFourHours, context: modelContext) + fatigueSamples = MetricsService.metricRange(metric: .fatigue, range: .twentyFourHours, context: modelContext) capabilities = MetricsService.deviceCapabilities(modelContext) visibleMetrics = Self.computeVisible(context: modelContext) signature = sig @@ -60,7 +73,9 @@ final class VitalsStore { private static func computeVisible(context: ModelContext) -> Set { var set: Set = [] - for metric in [MetricKey.heartRate, .spo2, .stress, .hrv, .temperature] where MetricsService.isVisible(metric, context: context) { + let candidates: [MetricKey] = [.heartRate, .spo2, .stress, .hrv, .temperature, + .bloodPressureSystolic, .bloodSugar, .fatigue] + for metric in candidates where MetricsService.isVisible(metric, context: context) { set.insert(metric) } return set @@ -76,6 +91,7 @@ final class VitalsStore { let device = DeviceRepository.current(context: context) return [ latest(.heartRate), latest(.spo2), latest(.stress), latest(.hrv), latest(.temperature), + latest(.bloodPressureSystolic), latest(.bloodPressureDiastolic), latest(.bloodSugar), latest(.fatigue), device.map { "\($0.batteryPercent)/\($0.state.rawValue)" } ?? "·", ].joined(separator: "|") } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 7b624a1..520a02c 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -78,6 +78,8 @@ struct RootAppView: View { GoalsSettingsView() case .settingsVitals: VitalsSettingsView() + case .settingsCalibration: + CalibrationSettingsView() case .settingsPrivacyData: PrivacyDataSettingsView() case .settingsAbout: diff --git a/PulseLoop/Views/Settings/CalibrationSettingsView.swift b/PulseLoop/Views/Settings/CalibrationSettingsView.swift new file mode 100644 index 0000000..6a54369 --- /dev/null +++ b/PulseLoop/Views/Settings/CalibrationSettingsView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import SwiftData + +/// Calibration detail screen (56ff/jring only). Two independent calibrations, both mirrored from the +/// Android port: +/// - **Blood pressure** — enter a reference cuff reading. It's pushed to the ring (`0x33`) so the ring +/// applies an on-device offset, and an app-side display offset is derived against the latest raw BP. +/// - **Blood sugar** — the ring estimates glucose from your profile (no real sensor), so the only +/// calibration is an app-side offset: `offset = labReading − latestRaw`. +/// +/// Capability-gated: only rings that declare `.bloodPressure` / `.bloodSugar` (the jring) reach here; +/// a defensive empty-state covers arriving without those capabilities (e.g. a Colmi). +struct CalibrationSettingsView: View { + @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble + @Environment(RingSyncCoordinator.self) private var coordinator + @State private var store = CalibrationStore.shared + + // Draft input fields. + @State private var bpSystolicText = "" + @State private var bpDiastolicText = "" + @State private var glucoseRefText = "" + @State private var bpStatus: String? + @State private var glucoseStatus: String? + @State private var loaded = false + + private var capabilities: Set { + MetricsService.activeCapabilities(context: modelContext, ble: ble) + } + private var supportsBP: Bool { capabilities.contains(.bloodPressure) } + private var supportsGlucose: Bool { capabilities.contains(.bloodSugar) } + + var body: some View { + ScrollView { + VStack(spacing: 16) { + if supportsBP || supportsGlucose { + if supportsBP { bpSection } + if supportsGlucose { glucoseSection } + if ble.state != .connected { + Text("Not connected — calibration is saved and applied the next time your ring syncs.") + .font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + SectionHeader(title: "Not available", action: nil) + StatusCopy( + title: "Unsupported on this ring", + body: "The connected device doesn't measure blood pressure or blood sugar, so there's nothing to calibrate." + ) + } + } + .padding() + } + .scrollDismissesKeyboard(.interactively) + .background(PulseColors.background) + .navigationTitle("Calibration") + .onAppear(perform: loadIfNeeded) + } + + // MARK: - Blood pressure + + @ViewBuilder + private var bpSection: some View { + SectionHeader(title: "Blood pressure", action: nil) + Text(""" + Enter a reading from a cuff taken at the same time as a ring measurement. We send it to the \ + ring to correct its sensor and adjust the values shown here. + """) + .font(.system(size: 12)).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + + numberRow("Reference systolic (mmHg)", text: $bpSystolicText) + numberRow("Reference diastolic (mmHg)", text: $bpDiastolicText) + + PrimaryButton(title: "Calibrate blood pressure", systemImage: "checkmark") { saveBP() } + if store.settings.hasBPReference { + SecondaryButton(title: "Reset", systemImage: "arrow.counterclockwise") { resetBP() } + } + if let bpStatus { + Text(bpStatus).font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Blood sugar + + @ViewBuilder + private var glucoseSection: some View { + SectionHeader(title: "Blood sugar", action: nil) + Text(""" + The ring estimates blood sugar from your profile, not a real sensor. Enter a lab or meter \ + reading taken alongside a ring measurement to offset the displayed values. + """) + .font(.system(size: 12)).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + + numberRow("Reference (mg/dL)", text: $glucoseRefText) + + PrimaryButton(title: "Calibrate blood sugar", systemImage: "checkmark") { saveGlucose() } + if store.settings.isGlucoseCalibrated { + SecondaryButton(title: "Reset", systemImage: "arrow.counterclockwise") { resetGlucose() } + } + if let glucoseStatus { + Text(glucoseStatus).font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func numberRow(_ title: String, text: Binding) -> some View { + HStack { + Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textPrimary) + Spacer() + TextField("0", text: text) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .foregroundStyle(PulseColors.textPrimary) + .frame(maxWidth: 90) + } + .padding(.horizontal, 16).padding(.vertical, 12) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + // MARK: - Load / save + + private func loadIfNeeded() { + guard !loaded else { return } + loaded = true + let s = store.settings + if s.bpReferenceSystolic > 0 { bpSystolicText = "\(s.bpReferenceSystolic)" } + if s.bpReferenceDiastolic > 0 { bpDiastolicText = "\(s.bpReferenceDiastolic)" } + if s.glucoseRefMgdl > 0 { glucoseRefText = "\(Int(s.glucoseRefMgdl))" } + } + + private func saveBP() { + guard let sys = Int(bpSystolicText), let dia = Int(bpDiastolicText), sys > 0, dia > 0 else { + bpStatus = "Enter both systolic and diastolic." + return + } + let latestSys = MetricsService.latestRaw(kind: .bloodPressureSystolic, context: modelContext) + let latestDia = MetricsService.latestRaw(kind: .bloodPressureDiastolic, context: modelContext) + store.calibrateBloodPressure(referenceSystolic: sys, referenceDiastolic: dia, + latestRawSystolic: latestSys, latestRawDiastolic: latestDia) + coordinator.applyBloodPressureCalibration() + bpStatus = ble.state == .connected ? "Saved and sent to your ring." : "Saved — will apply on next sync." + } + + private func resetBP() { + store.resetBloodPressure() + bpSystolicText = ""; bpDiastolicText = "" + bpStatus = "Calibration cleared." + } + + private func saveGlucose() { + guard let ref = Double(glucoseRefText), ref > 0 else { + glucoseStatus = "Enter a reference reading." + return + } + let latest = MetricsService.latestRaw(kind: .bloodSugar, context: modelContext) + store.calibrateGlucose(referenceMgdl: ref, latestRawMgdl: latest) + glucoseStatus = latest == nil ? "Saved — take a ring measurement to apply the offset." : "Calibration applied." + } + + private func resetGlucose() { + store.resetGlucose() + glucoseRefText = "" + glucoseStatus = "Calibration cleared." + } +} diff --git a/PulseLoop/Views/Settings/VitalsSettingsView.swift b/PulseLoop/Views/Settings/VitalsSettingsView.swift index 1d90baa..fb02dcd 100644 --- a/PulseLoop/Views/Settings/VitalsSettingsView.swift +++ b/PulseLoop/Views/Settings/VitalsSettingsView.swift @@ -17,6 +17,10 @@ struct VitalsSettingsView: View { (.stress, "Stress", PulseColors.stress), (.hrv, "HRV", PulseColors.hrv), (.temperature, "Skin temperature", PulseColors.temperature), + // jring/56ff metrics — appear only when the connected ring declares the capability. + (.bloodPressureSystolic, "Blood pressure", PulseColors.bloodPressure), + (.bloodSugar, "Blood sugar", PulseColors.bloodSugar), + (.fatigue, "Fatigue", PulseColors.fatigue), ] private var supported: [(metric: MetricKey, label: String, color: Color)] { diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index 5e88427..59af3c5 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -88,6 +88,17 @@ struct SettingsView: View { ) { path.append(AppRoute.settingsMeasurement) } } + // Device-specific: only the 56ff jring measures BP / blood sugar (Colmi has neither + // sensor and never declares these), so the calibration screen is jring-only. + if capabilities.contains(.bloodPressure) || capabilities.contains(.bloodSugar) { + SettingsCategoryRow( + icon: "slider.horizontal.3", + tint: PulseColors.bloodPressure, + title: "Calibration", + subtitle: "Tune blood pressure and blood sugar accuracy" + ) { path.append(AppRoute.settingsCalibration) } + } + SettingsCategoryRow( icon: "lock.shield", tint: PulseColors.success, diff --git a/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index 41fe34e..f33341e 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -27,6 +27,10 @@ struct VitalsView: View { let stressSamples = activeStore.stressSamples let hrvSamples = activeStore.hrvSamples let tempSamples = activeStore.tempSamples + let systolicSamples = activeStore.systolicSamples + let diastolicSamples = activeStore.diastolicSamples + let bloodSugarSamples = activeStore.bloodSugarSamples + let fatigueSamples = activeStore.fatigueSamples return AnyView(ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -145,6 +149,61 @@ struct VitalsView: View { } } } + + if activeStore.visibleMetrics.contains(.bloodPressureSystolic) { + DetailCard(title: "Blood pressure", color: PulseColors.bloodPressure) { + let sys = systolicSamples.last.map { Int($0.value) } + let dia = diastolicSamples.last.map { Int($0.value) } + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(sys.map(String.init) ?? "--") + .font(.system(size: 40, weight: .semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + Text("/").font(.system(size: 28, weight: .medium)).foregroundStyle(PulseColors.textMuted) + Text(dia.map(String.init) ?? "--") + .font(.system(size: 40, weight: .semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + if sys != nil, dia != nil { + Text("mmHg").font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textMuted) + } + } + .padding(.top, 12) + if sys == nil || dia == nil { + InlineEmptyState(title: "No blood pressure yet", message: "Take a combined measurement and sync.") + } + } + } + + if activeStore.visibleMetrics.contains(.bloodSugar) { + DetailCard(title: "Blood sugar", color: PulseColors.bloodSugar) { + let latest = bloodSugarSamples.last.map { Int($0.value.rounded()) } + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(latest.map(String.init) ?? "--") + .font(.system(size: 40, weight: .semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + if latest != nil { + Text("mg/dL").font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textMuted) + } + } + .padding(.top, 12) + if latest == nil { + InlineEmptyState(title: "No blood sugar yet", message: "Estimated from your profile — set it in Settings → Profile.") + } + } + } + + if activeStore.visibleMetrics.contains(.fatigue) { + DetailCard(title: "Fatigue", color: PulseColors.fatigue) { + let latest = fatigueSamples.last.map { Int($0.value) } + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(latest.map(String.init) ?? "--") + .font(.system(size: 40, weight: .semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) + if latest != nil { + Text("/ 100").font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textMuted) + } + } + .padding(.top, 12) + if latest == nil { + InlineEmptyState(title: "No fatigue data yet", message: "Wear the ring through the day and sync.") + } + } + } } .padding(.horizontal, 16) .padding(.bottom, 96) diff --git a/PulseLoop/Wearables/WearableCapability.swift b/PulseLoop/Wearables/WearableCapability.swift index ce6e7cb..a859f14 100644 --- a/PulseLoop/Wearables/WearableCapability.swift +++ b/PulseLoop/Wearables/WearableCapability.swift @@ -23,6 +23,12 @@ enum WearableCapability: String, CaseIterable, Codable, Sendable { case hrv case temperature + // jring/56ff (today): metrics from the 0x24 combined-sensor packet that Colmi lacks. Colmi must + // NOT declare these — it has no BP/blood-sugar sensor. + case bloodPressure + case bloodSugar + case fatigue + // Interaction capabilities case manualHeartRate // single-shot on-demand HR case manualSpo2 // single-shot on-demand SpO2 (jring; Colmi has no instant SpO2) diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 17e8ecb..27e52e7 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -141,6 +141,17 @@ protocol RingSyncEngine: AnyObject { /// Apply the user's profile *now* (store + send) — the live path when the profile screen saves. func applyUserProfile(_ profile: UserProfileValues) + + /// Store reference blood-pressure calibration (mmHg) *without* sending — `runStartup` pushes it as + /// part of the connect handshake. Devices without on-device BP calibration ignore it. + func setBloodPressureCalibration(systolic: Int, diastolic: Int) + + /// Push BP calibration *now* (store + send) — the live path when the calibration screen saves. + func applyBloodPressureCalibration(systolic: Int, diastolic: Int) + + /// Release the ring on Forget: send the unbind command (jring 0x4B UNBOND) so the ring stops + /// streaming to us and re-advertises for other apps. Devices without a bind protocol ignore it. + func unbind() } extension RingSyncEngine { @@ -155,4 +166,11 @@ extension RingSyncEngine { /// Default: devices that don't accept a user profile ignore it. func setUserProfile(_ profile: UserProfileValues) {} func applyUserProfile(_ profile: UserProfileValues) {} + + /// Default: devices without on-device BP calibration (e.g. Colmi) ignore it. + func setBloodPressureCalibration(systolic: Int, diastolic: Int) {} + func applyBloodPressureCalibration(systolic: Int, diastolic: Int) {} + + /// Default: devices without a bind protocol (e.g. Colmi) have nothing to release. + func unbind() {} } diff --git a/PulseLoopTests/CalibrationStoreTests.swift b/PulseLoopTests/CalibrationStoreTests.swift new file mode 100644 index 0000000..d8b982e --- /dev/null +++ b/PulseLoopTests/CalibrationStoreTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import PulseLoop + +/// Pure calibration math: offset derivation and the display-offset application. No hardware/store IO. +@MainActor +final class CalibrationStoreTests: XCTestCase { + /// An isolated store backed by a throwaway suite so tests never touch the standard defaults. + private func makeStore() -> CalibrationStore { + let suite = UserDefaults(suiteName: "calib.test.\(UUID().uuidString)")! + return CalibrationStore(defaults: suite) + } + + func testGlucoseOffsetIsReferenceMinusLatestRaw() { + let store = makeStore() + store.calibrateGlucose(referenceMgdl: 110, latestRawMgdl: 120) + XCTAssertEqual(store.settings.glucoseOffsetMgdl, -10, accuracy: 0.001) + XCTAssertEqual(store.settings.glucoseRefMgdl, 110, accuracy: 0.001) + // Display applies the offset: a raw 120 reads as 110. + XCTAssertEqual(store.settings.adjusted(120, kind: .bloodSugar), 110, accuracy: 0.001) + } + + func testGlucoseResetClearsOffset() { + let store = makeStore() + store.calibrateGlucose(referenceMgdl: 110, latestRawMgdl: 120) + store.resetGlucose() + XCTAssertEqual(store.settings.glucoseOffsetMgdl, 0) + XCTAssertEqual(store.settings.adjusted(120, kind: .bloodSugar), 120) + } + + func testBPOffsetDerivedAgainstLatestRaw() { + let store = makeStore() + // Cuff reads 130/85, ring's latest raw was 120/80 → offsets +10/+5. + store.calibrateBloodPressure(referenceSystolic: 130, referenceDiastolic: 85, + latestRawSystolic: 120, latestRawDiastolic: 80) + XCTAssertEqual(store.settings.bpSystolicOffset, 10) + XCTAssertEqual(store.settings.bpDiastolicOffset, 5) + XCTAssertEqual(store.settings.adjusted(120, kind: .bloodPressureSystolic), 130) + XCTAssertEqual(store.settings.adjusted(80, kind: .bloodPressureDiastolic), 85) + XCTAssertTrue(store.settings.hasBPReference) + } + + func testAdjustedIsIdentityForUncalibratedKinds() { + let store = makeStore() + XCTAssertEqual(store.settings.adjusted(72, kind: .heartRate), 72) + XCTAssertEqual(store.settings.adjusted(98, kind: .spo2), 98) + } + + func testCalibrationPersistsAcrossInstances() { + let suite = UserDefaults(suiteName: "calib.test.\(UUID().uuidString)")! + let store = CalibrationStore(defaults: suite) + store.calibrateGlucose(referenceMgdl: 100, latestRawMgdl: 90) + // A fresh store reading the same suite sees the persisted offset. + let reloaded = CalibrationStore(defaults: suite) + XCTAssertEqual(reloaded.settings.glucoseOffsetMgdl, 10, accuracy: 0.001) + } +} diff --git a/PulseLoopTests/EventBridgeTests.swift b/PulseLoopTests/EventBridgeTests.swift index fe24583..8c705c8 100644 --- a/PulseLoopTests/EventBridgeTests.swift +++ b/PulseLoopTests/EventBridgeTests.swift @@ -31,6 +31,41 @@ final class EventBridgeTests: XCTestCase { XCTAssertTrue(RingEventBridge.events(for: .unknown(commandId: 0x52, raw: Data())).isEmpty) } + // MARK: - 0x24 combined-sensor metric gates + + func testBloodPressureMapsThroughWhenPlausible() { + let events = RingEventBridge.events(for: .bloodPressureSample(systolic: 120, diastolic: 80, timestamp: Date())) + guard case .bloodPressureSample(120, 80, _) = events.first else { + return XCTFail("expected bloodPressureSample") + } + } + + func testImplausibleBloodPressureDropped() { + // Systolic 300 is out of the 60…250 range. + XCTAssertTrue(RingEventBridge.events(for: .bloodPressureSample(systolic: 300, diastolic: 80, timestamp: Date())).isEmpty) + } + + func testFatigueGate() { + XCTAssertEqual(RingEventBridge.events(for: .fatigueSample(value: 50, timestamp: Date())).count, 1) + XCTAssertTrue(RingEventBridge.events(for: .fatigueSample(value: 0, timestamp: Date())).isEmpty) + } + + func testBloodSugarGate() { + XCTAssertEqual(RingEventBridge.events(for: .bloodSugarSample(mgdl: 95, timestamp: Date())).count, 1) + // 10 mg/dL is below the 40…600 plausible range. + XCTAssertTrue(RingEventBridge.events(for: .bloodSugarSample(mgdl: 10, timestamp: Date())).isEmpty) + } + + func testFirmwareMapsThrough() { + guard case .firmwareVersion("003A002AV138") = RingEventBridge.events(for: .firmware(version: "003A002AV138")).first else { + return XCTFail("expected firmwareVersion") + } + } + + func testBindProducesNoTypedEvents() { + XCTAssertTrue(RingEventBridge.events(for: .bind(action: 0, state: 0)).isEmpty) + } + func testCommandAckProducesNoTypedEvents() { XCTAssertTrue(RingEventBridge.events(for: .commandAck(commandId: 0x02)).isEmpty) } diff --git a/PulseLoopTests/RingDecoderTests.swift b/PulseLoopTests/RingDecoderTests.swift index 93d8ad4..751f165 100644 --- a/PulseLoopTests/RingDecoderTests.swift +++ b/PulseLoopTests/RingDecoderTests.swift @@ -29,20 +29,43 @@ final class RingDecoderTests: XCTestCase { XCTAssertEqual(bpm, 0x50) // 80 bpm } - func testSpO2Result() { - let event = decode("245e774d63050000000000000000000000000000") - guard case let .spo2Result(value, _) = event else { - return XCTFail("expected spo2Result, got \(event.kind)") - } - XCTAssertEqual(value, 99) + private func decodeAll(_ hex: String) -> [RingDecodedEvent] { + decoder.decodeAll((try? Data(hexString: hex)) ?? Data()) } - func testSpO2Progress() { - let event = decode("2400000000000000000000000000000000000000") - guard case .spo2Progress = event else { - return XCTFail("expected spo2Progress, got \(event.kind)") - } - XCTAssertEqual(event.confidence, .partial) + /// The 0x24 combined-sensor packet fans out into one event per valid metric. Byte map: + /// [1]=HR, [2]=systolic, [3]=diastolic, [4]=SpO₂, [5]=fatigue, [6]=stress, [7]=glucose(mmol/L×10), + /// [8]=HRV. Here: HR=70, sys=120, dia=80, SpO₂=98, fatigue=40, stress=30, glucose=51, HRV=45. + func testCombinedSensorPacketDecodesAllMetrics() { + // 24 46 78 50 62 28 1e 33 2d ... + let events = decodeAll("2446785062281e332d0000000000000000000000") + func first(_ predicate: (RingDecodedEvent) -> T?) -> T? { events.lazy.compactMap(predicate).first } + + XCTAssertEqual(first { if case let .heartRateSample(b, _) = $0 { return b } else { return nil } }, 0x46) + let bp = first { e -> (Int, Int)? in if case let .bloodPressureSample(s, d, _) = e { return (s, d) } else { return nil } } + XCTAssertEqual(bp?.0, 120); XCTAssertEqual(bp?.1, 80) + XCTAssertEqual(first { if case let .spo2Result(v, _) = $0 { return v } else { return nil } }, 98) + XCTAssertEqual(first { if case let .fatigueSample(v, _) = $0 { return v } else { return nil } }, 40) + XCTAssertEqual(first { if case let .stressSample(v, _) = $0 { return v } else { return nil } }, 30) + XCTAssertEqual(first { if case let .hrvSample(v, _) = $0 { return v } else { return nil } }, 45) + + // Blood sugar: raw 0x33 = 51 → 5.1 mmol/L → 5.1 × 18.016 ≈ 91.88 mg/dL. + let glucose = first { e -> Double? in if case let .bloodSugarSample(mgdl, _) = e { return mgdl } else { return nil } } + XCTAssertEqual(glucose ?? 0, 91.88, accuracy: 0.05) + } + + /// A warm-up 0x24 packet (all metric bytes zero) yields no measurements, just an ack so the queue + /// still advances. + func testCombinedSensorEmptyPacketIsAck() { + let events = decodeAll("2400000000000000000000000000000000000000") + XCTAssertEqual(events.count, 1) + guard case .commandAck = events[0] else { return XCTFail("expected commandAck, got \(events[0].kind)") } + } + + /// SpO₂ outside 80…100 is rejected (byte[4]=0x05) — no spo2Result emitted. + func testCombinedSensorRejectsImplausibleSpO2() { + let events = decodeAll("245e774d05050000000000000000000000000000") + XCTAssertFalse(events.contains { if case .spo2Result = $0 { return true } else { return false } }) } func testBatteryPercentStatus() { @@ -93,4 +116,91 @@ final class RingDecoderTests: XCTestCase { let value = UInt32(data[1]) | UInt32(data[2]) << 8 | UInt32(data[3]) << 16 | UInt32(data[4]) << 24 XCTAssertEqual(value, 10000) } + + // MARK: - Ring-config command layouts + + /// 0x02 user info: age=25 male → byte[1] = 25 | 0x80 = 0x99; height 184 → 0xB8; weight 90 → 0x5A. + /// Matches the legacy hardcoded `0299b85a…` activity-query bytes. + func testUserInfoCommandLayout() { + let data = [UInt8](RingEncoder().makeUserInfoCommand(age: 25, isMale: true, heightCm: 184, weightKg: 90)) + XCTAssertEqual(data.count, 20) + XCTAssertEqual(data[0], 0x02) + XCTAssertEqual(data[1], 0x99) + XCTAssertEqual(data[2], 0xb8) + XCTAssertEqual(data[3], 0x5a) + XCTAssertEqual(data[4], 0x00) + } + + func testUserInfoCommandFemaleClearsHighBit() { + let data = [UInt8](RingEncoder().makeUserInfoCommand(age: 30, isMale: false, heightCm: 165, weightKg: 60)) + XCTAssertEqual(data[1], 30) // no 0x80 bit + } + + /// 0x33 BP calibration: systolic/diastolic as little-endian u16s. + func testBPAdjustCommandLayout() { + let data = [UInt8](RingEncoder().makeBPAdjustCommand(systolic: 120, diastolic: 80)) + XCTAssertEqual(data[0], 0x33) + XCTAssertEqual(UInt16(data[1]) | UInt16(data[2]) << 8, 120) + XCTAssertEqual(UInt16(data[3]) | UInt16(data[4]) << 8, 80) + } + + /// 0x48 app identity: ASCII app id starting at byte[1]. + func testAppIdentifierCommandLayout() { + let data = [UInt8](RingEncoder().makeAppIdentifierCommand(appId: "PulseLoop")) + XCTAssertEqual(data[0], 0x48) + XCTAssertEqual(data[1], UInt8(ascii: "P")) + let ascii = String(bytes: data[1...9], encoding: .utf8) + XCTAssertEqual(ascii, "PulseLoop") + } + + /// 0x4B bind frame: [1]=action, [2]=state, [3]=1. + func testBindCommandLayout() { + let data = [UInt8](RingEncoder().makeBindCommand(action: 1)) + XCTAssertEqual(data[0], 0x4b) + XCTAssertEqual(data[1], 1) + XCTAssertEqual(data[2], 0) + XCTAssertEqual(data[3], 1) + } + + func testKeepaliveCommandLayout() { + let data = [UInt8](RingEncoder().makeKeepaliveCommand()) + XCTAssertEqual(data.count, 20) + XCTAssertEqual(data[0], 0x3a) + XCTAssertTrue(data.dropFirst().allSatisfy { $0 == 0 }) + } + + // MARK: - Decode: bind, firmware, 0x16 history averaging + + func testBindDecode() { + let event = decode("4b00000100000000000000000000000000000000") + guard case let .bind(action, state) = event else { return XCTFail("expected bind, got \(event.kind)") } + XCTAssertEqual(action, 0) + XCTAssertEqual(state, 0) + } + + /// 0x0c status fans out into both the address (status) and the firmware string. + func testStatusPayloadAlsoEmitsFirmware() { + let events = decodeAll("0c7a0041422ec75b6a3a00160000000000000000") + XCTAssertTrue(events.contains { if case .status = $0 { return true } else { return false } }) + XCTAssertTrue(events.contains { if case .firmware = $0 { return true } else { return false } }) + } + + /// 0x16 data block (sub-type 0xA0): base ts at [2..5], then two 6-sample HR blocks → two averages + /// 60s apart. Here both blocks are all 60 → averages 60, second timestamped +60s. + func testHistoryHeartRateAveraging() { + let base: UInt32 = 1_700_000_000 + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x16; bytes[1] = 0xa0 + bytes[2] = UInt8(base & 0xff); bytes[3] = UInt8((base >> 8) & 0xff) + bytes[4] = UInt8((base >> 16) & 0xff); bytes[5] = UInt8((base >> 24) & 0xff) + for i in 8..<20 { bytes[i] = 60 } + let events = decoder.decodeAll(Data(bytes)) + let hr = events.compactMap { e -> (Double, Date)? in + if case let .historyMeasurement(kind, value, ts) = e, kind == .heartRate { return (value, ts) } else { return nil } + } + XCTAssertEqual(hr.count, 2) + XCTAssertEqual(hr[0].0, 60) + XCTAssertEqual(hr[1].0, 60) + XCTAssertEqual(hr[1].1.timeIntervalSince1970 - hr[0].1.timeIntervalSince1970, 60, accuracy: 0.5) + } } diff --git a/docs/hardware/jring.md b/docs/hardware/jring.md index ddbf8a5..3c53baf 100644 --- a/docs/hardware/jring.md +++ b/docs/hardware/jring.md @@ -14,6 +14,13 @@ keeprapid OEM and white-labeled under many brands. It speaks a custom `56ff` BLE protocol and is the **only** PulseLoop-supported ring that reports blood pressure and (profile-derived) blood sugar. +!!! warning "Set the ring up in the official Jring app first" + Pair and initialize the ring once with the original **Jring / KeepFit** app + before connecting it to PulseLoop. First-time setup sends some device + initialization commands that we haven't fully reverse-engineered yet; without + that one-time setup a fresh ring may not stream all data correctly to + PulseLoop. After it's been set up once, PulseLoop can connect to it directly. + ## At a glance | | Detail | diff --git a/docs/platforms/ios-vs-android.md b/docs/platforms/ios-vs-android.md index 99ae127..47f0e0e 100644 --- a/docs/platforms/ios-vs-android.md +++ b/docs/platforms/ios-vs-android.md @@ -14,85 +14,27 @@ where they currently differ. !!! note "This is a snapshot" Things change fast. When a feature lands on both ports, it leaves this page. - *Last updated: 2026-06-25.* For the canonical state, check each repo: + *Last updated: 2026-06-29.* For the canonical state, check each repo: [iOS](https://github.com/saksham2001/PulseLoopiOS) · [Android](https://github.com/foureight84/PulseLoopAndroid). -Today, the Android port is ahead in a few areas — fuller protocol decoding, ring -configuration/calibration, connection reliability, and richer vitals UI. The -sections below break that down. - -## Protocol & Ring Communication - -### Extended 0x24 Combined Sensor Decoding - -iOS only decodes bytes[1]-[4] (HR + systolic + diastolic + SpO₂) from the `0x24` combined -measurement packet. Android decodes the full 9 bytes — matching the official Jring app's -`onReceiveSensorData(i, i2, i3, i4, i5, i6, i7, i8)`: - -| Byte | Metric | iOS | Android | -|------|--------|:---:|:-------:| -| 1 | Heart Rate (BPM) | ✅ | ✅ | -| 2 | Systolic (mmHg) | ✅ | ✅ | -| 3 | Diastolic (mmHg) | ✅ | ✅ | -| 4 | SpO₂ (%) | ✅ | ✅ | -| 5 | **Fatigue** (0–100) | — | ✅ | -| 6 | **Stress** (0–100) | — | ✅ | -| 7 | **Blood Sugar** (mmol/L ×10 → mg/dL) | — | ✅ | -| 8 | **HRV** (ms) | — | ✅ | - -**iOS also mislabels the opcodes:** `spo2ResultProgress = 0x24` and `spo2Complete = 0x28`. -These are actually combined sensor data and blood data per the APK decompilation — not -SpO₂-only packets. The separate SpO₂ commands are `0x3E`/`0x3F`. - -### Ring Configuration Commands (Android-only) - -| Command | Opcode | Purpose | -|---------|--------|---------| -| **User profile** | `0x02` | Sends age/sex/height/weight to ring. Android pushes real profile from Settings on every connect; iOS sends hardcoded defaults (age 25, male, 184cm, 90kg) with no way to configure. | -| **BP calibration** | `0x33` | Sends reference systolic/diastolic to ring for on-device offset correction. iOS has no equivalent. | -| **App identity** | `0x48` | Claims the ring with a persistent app ID so it streams data to PulseLoop. Prevents mute behavior after another app claimed the ring. iOS doesn't send this. | -| **Bind/unbind** | `0x4B` | Ring-driven pairing handshake (INIT → APP_START → ACK → SUCCESS). Proper unbind (UNBOND → UNBOND_ACK) on Forget so the ring re-advertises for other apps. iOS has no bind protocol. | -| **Keepalive ping** | `0x3A` | Prevents ring's ~20s idle disconnect. Android pings every 15s. iOS has no keepalive. | - -### Connection Reliability (Android-only) - -- **Keepalive ping** — 15s interval prevents ring idle timeout -- **Write ACK timeout** — 3s timeout unblocks command queue on missed ACKs -- **Connection watchdog** — monitors GATT activity, forces reconnect after 10–20s silence -- **Foreground reconnection** — reconnects on app resume if GATT dropped during sleep -- **Stale-state guard** — resets persisted connection state on app restart -- **Force-close stale GATT** — explicitly disconnects/closes orphaned handles before new connection -- **No OS-level bonding** — avoids Bluetooth status-bar icon and OS-level pairing instability -- **High-priority connection interval** — requests priority on connect -- **Firmware discovery** — scans all BLE services for firmware characteristics, not just standard DIS - -## Settings & Calibration - -### User Profile - -| | iOS | Android | -|---|---|---| -| **Profile form** | Not configurable — hardcoded defaults always sent | Full form (age, sex, height, weight) in Settings | -| **Stored on-device** | N/A | Room database, never transmitted off-device | -| **Synced to ring** | Only hardcoded defaults at startup | Real profile pushed on connect + on save | -| **Colmi handling** | N/A | Form hidden with notice explaining Colmi doesn't need it | - -### BP Calibration - -| | iOS | Android | -|---|---|---| -| **Cuff reference entry** | Not available | Systolic/diastolic fields in Settings | -| **Sent to ring** | N/A | `0x33` command for on-device offset | -| **App-side display** | N/A | Applied in ViewModels | - -### Blood Sugar Calibration - -| | iOS | Android | -|---|---|---| -| **Lab reference entry** | Not available | mg/dL field + Calibrate button in Settings | -| **Offset method** | N/A | `glucoseOffsetMgdl = ref - latestRaw` | -| **Reset** | N/A | Reset button to clear calibration | +The two ports have converged on the low-level work — protocol decoding, ring +configuration/calibration, and connection reliability are now on both. What +remains is mostly **UI polish** (richer vitals/sleep/activity dashboards, detail +screens) where Android is still ahead. The sections below break that down. + +!!! success "Now on both ports" + These previously Android-only items have landed on iOS and left this page: + full `0x24` decoding (fatigue, stress, blood sugar, HRV) with corrected + opcode labels, the ring-config commands (`0x02` profile, `0x33` BP + calibration, `0x48` app identity, `0x4B` bind/unbind, `0x3A` keepalive), + connection reliability (write-ACK timeout, watchdog, foreground reconnect, + stale-state guard, force-close, firmware discovery), profile + BP + blood + sugar calibration settings, the `0x16` multi-packet history averaging, and + the calibrated display pipeline. Per-metric on/off toggles for the new jring + vitals live in **Settings → Vitals & Display**, and BP/blood-sugar + calibration in **Settings → Calibration** (both capability-gated, so Colmi + never sees them). ## UI Differences @@ -100,12 +42,12 @@ SpO₂-only packets. The separate SpO₂ commands are `0x3E`/`0x3F`. | Feature | iOS | Android | |---------|:---:|:---:| +| BP / blood sugar / fatigue panels | ✅ Value cards (capability-gated to jring) | ✅ | | Threshold bars on every metric panel | — | ✅ Color-coded (Good → Normal → Borderline → High) | | Tap-through detail screens per metric | — | ✅ Full trend view with period selector | | Zone-colored trend charts | — | ✅ Data points colored by threshold zone | | Range/avg summaries on panels | — | ✅ `Range: min – max · Avg: avg` | | Combined measurement button | — | ✅ One-tap BP+SpO₂+stress+fatigue+BS with countdown | -| Pull-to-refresh | — | ✅ Triggers immediate ring sync | ### Vitals Detail (tap on any metric) @@ -119,22 +61,15 @@ SpO₂-only packets. The separate SpO₂ commands are `0x3E`/`0x3F`. | Metric explainer text | — | ✅ | | Medical disclaimer card | — | ✅ | -### Colmi Ring Handling - -| Feature | iOS | Android | -|---------|:---:|:---:| -| BP/blood sugar panels hidden for Colmi | — | ✅ Capability-gated in Today and Vitals | -| Profile/calibration form hidden for Colmi | — | ✅ Notice explaining features are 56ff-only | -| Measure button hidden for Colmi | — | ✅ Gated on `supportsBP || supportsGlucose` | -| Colmi capabilities correctly declared | — | ✅ Fixed (was incorrectly including BP/BS) | - ### Ring Management | Feature | iOS | Android | |---------|:---:|:---:| -| Proper unbind on forget | — | ✅ `0x4B` UNBOND → UNBOND_ACK before teardown | +| BP/blood sugar/fatigue panels gated to jring | ✅ Capability-gated; Colmi never declares them | ✅ | +| Calibration screen gated to jring | ✅ Shown only when ring supports BP/blood sugar | ✅ | +| Proper unbind on forget | ✅ `0x4B` UNBOND before teardown (jring) | ✅ UNBOND → UNBOND_ACK | +| Firmware version captured | ✅ Parsed from `0x0C`/`0xF6` + DIS, stored on device | ✅ Also displayed in Settings | | Connection state display | — | ✅ Live status with battery % in Settings | -| Firmware version display | — | ✅ Parsed from `0x0C`/`0xF6` + standard DIS | ### Sleep View @@ -154,28 +89,26 @@ SpO₂-only packets. The separate SpO₂ commands are `0x3E`/`0x3F`. ### Settings Screen +Profile, BP calibration, blood sugar calibration, unit toggle, and demo seeding +are now on both ports. iOS keeps per-metric visibility toggles in **Vitals & +Display** and calibration in a dedicated **Calibration** screen. + | Feature | iOS | Android | |---------|:---:|:---:| -| Profile (age/sex/height/weight) | — | ✅ | -| BP calibration (systolic/diastolic) | — | ✅ | -| Blood sugar calibration (mg/dL offset) | — | ✅ | -| Unit system toggle (Metric / Imperial) | — | ✅ | -| Profile sync to ring on save | — | ✅ | -| Demo data seeder | — | ✅ | -| Ring connection management | — | ✅ Live status, firmware version, forget button | +| Per-metric vitals on/off toggles | ✅ Vitals & Display (incl. new jring metrics) | ✅ | +| Ring connection management | ✅ Status + forget in Wearable settings | ✅ Live status, firmware version, forget | ## Data Flow +The new metric capture + calibration pipeline is on both ports. What's left is +Colmi-specific decoding and the reactive-store/timezone-bucketing internals. + | Feature | iOS | Android | |---------|:---:|:---:| -| Blood sugar (profile-derived) displayed | — | ✅ mg/dL with app-side calibration offset | -| Fatigue metric displayed | — | ✅ 0–100 scale from 0x24 byte[5] | | Sleep REM detection (Colmi) | — | ✅ Via V2 big-data protocol | | Skin temperature (Colmi) | — | ✅ Via V2 big-data protocol | -| Heart rate history (0x16) multi-packet | — | ✅ With sub-type routing and averaging | -| Reactive Room database with Flow | — | ✅ Live data as ring syncs | -| Local-midnight-aligned day bucketing | — | ✅ Consistent daily stats across timezones | -| Calibrated display pipeline | — | ✅ Offsets applied in ViewModels before UI | +| Reactive store with live updates | ✅ SwiftData + event-bus signature refresh | ✅ Room + Flow | +| Local-midnight-aligned day bucketing | Partial — `Calendar.startOfDay` per day | ✅ Timezone-offset SQL bucketing | ## Architecture @@ -194,17 +127,17 @@ SpO₂-only packets. The separate SpO₂ commands are `0x3E`/`0x3F`. | Notifications | UNUserNotificationCenter | NotificationManager + WorkManager | | DI | @Environment | Manual (ViewModel factories) | -## Summary: What Android has that iOS doesn't - -1. **Full 0x24 decoding** — fatigue, stress, blood sugar, HRV -2. **Ring configuration** — user profile (0x02), BP calibration (0x33), app ID (0x48) -3. **Bind/unbind protocol** (0x4B) — proper ring claiming and release -4. **Keepalive + connection watchdog** — prevents silent disconnects -5. **Profile & calibration settings** — age/sex/height/weight, BP cuff reference, glucose offset -6. **Colmi-aware UI** — hides BP/BS panels and profile form for Colmi rings -7. **Vitals detail screens** — period selector, trend charts, stat tiles, threshold bars -8. **Threshold bars on all vitals panels** — color-coded reference ranges -9. **Combined measurement button** — one-tap BP+SpO₂+stress+fatigue+BS with countdown -10. **Pull-to-refresh** — immediate ring sync from Today dashboard -11. **Reactive Room database** — Flow-based live data throughout the app -12. **Calibrated display pipeline** — offsets applied before UI rendering +## Summary: What Android still has that iOS doesn't + +The low-level protocol, ring configuration, connection reliability, and +calibration pipeline are now on both ports. The remaining gaps are UI polish and +a couple of Colmi-specific decodes: + +1. **Vitals detail screens** — tap-through per metric with period selector, trend arrows, stat tiles +2. **Threshold bars on all vitals panels** — color-coded reference ranges (Good → High) +3. **Zone-colored trend charts** — data points colored by threshold zone +4. **Combined measurement button** — one-tap BP+SpO₂+stress+fatigue+BS with countdown +5. **Richer Sleep view** — stage breakdown pills, coach insights, duration/architecture charts +6. **Richer Activity view** — threshold reference ranges, active-minutes trend card +7. **Sleep REM + skin temperature (Colmi)** — via the V2 big-data protocol +8. **Timezone-offset day bucketing** — Android buckets daily stats by a local-midnight SQL offset