From 7dd97f6a4358cfaac2771721da91905c2c607a98 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 14:58:50 -0400 Subject: [PATCH 1/6] Rebuild TK5 ring support on the authoritative Yucheng YCBT protocol and add SmartHealth-app Colmi rings --- PulseLoop/Models/PulseModels.swift | 6 + .../ColmiSmartHealthCoordinator.swift | 126 ++++ PulseLoop/RingProtocol/RingBLEClient.swift | 209 +++++- PulseLoop/RingProtocol/RingEventBridge.swift | 42 +- PulseLoop/RingProtocol/RingProtocol.swift | 26 +- PulseLoop/RingProtocol/TK5Coordinator.swift | 53 +- PulseLoop/RingProtocol/TK5Decoder.swift | 180 ------ PulseLoop/RingProtocol/TK5Driver.swift | 94 --- PulseLoop/RingProtocol/TK5Encoder.swift | 102 --- PulseLoop/RingProtocol/TK5Protocol.swift | 154 ----- PulseLoop/RingProtocol/TK5SyncEngine.swift | 126 ---- PulseLoop/RingProtocol/YCBTDecoder.swift | 247 ++++++++ PulseLoop/RingProtocol/YCBTDriver.swift | 117 ++++ PulseLoop/RingProtocol/YCBTEncoder.swift | 134 ++++ .../RingProtocol/YCBTHealthRecords.swift | 362 +++++++++++ .../RingProtocol/YCBTHistoryTransfer.swift | 237 +++++++ PulseLoop/RingProtocol/YCBTProtocol.swift | 500 +++++++++++++++ .../RingProtocol/YCBTSettingsEncoder.swift | 125 ++++ PulseLoop/RingProtocol/YCBTSyncEngine.swift | 175 ++++++ PulseLoop/Services/PulseServices.swift | 4 + PulseLoop/Services/RingSyncCoordinator.swift | 116 +++- PulseLoop/Views/PairingComponents.swift | 48 ++ PulseLoop/Views/PairingView.swift | 126 +++- PulseLoop/Views/Settings/DeviceHeroCard.swift | 4 +- .../Settings/MeasurementSettingsView.swift | 4 +- PulseLoop/Wearables/WearableCoordinator.swift | 37 ++ PulseLoop/Wearables/WearableDriver.swift | 28 +- PulseLoop/Wearables/WearableModel.swift | 177 +++++- PulseLoopTests/ColmiDecoderTests.swift | 17 + PulseLoopTests/EventBridgeTests.swift | 80 +++ PulseLoopTests/HistoryDedupTests.swift | 42 ++ PulseLoopTests/MeasurementSettingsTests.swift | 16 +- PulseLoopTests/PairingMatchingTests.swift | 316 +++++++++- PulseLoopTests/RingIdentityMemoryTests.swift | 87 +++ PulseLoopTests/TK5CoordinatorTests.swift | 44 ++ PulseLoopTests/TK5DecoderTests.swift | 360 ----------- PulseLoopTests/YCBTDecoderTests.swift | 291 +++++++++ PulseLoopTests/YCBTDriverTests.swift | 110 ++++ PulseLoopTests/YCBTEncoderTests.swift | 172 +++++ PulseLoopTests/YCBTFrameAssemblerTests.swift | 80 +++ PulseLoopTests/YCBTHealthRecordsTests.swift | 374 +++++++++++ PulseLoopTests/YCBTHistoryTransferTests.swift | 319 ++++++++++ PulseLoopTests/YCBTSupportFunctionTests.swift | 266 ++++++++ README.md | 21 +- docs/YCBT-Protocol.md | 594 ++++++++++++++++++ docs/hardware/colmi.md | 152 ++++- docs/hardware/index.md | 84 ++- docs/hardware/tk5.md | 188 ++++-- mkdocs.yml | 3 +- 49 files changed, 5962 insertions(+), 1213 deletions(-) create mode 100644 PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift delete mode 100644 PulseLoop/RingProtocol/TK5Decoder.swift delete mode 100644 PulseLoop/RingProtocol/TK5Driver.swift delete mode 100644 PulseLoop/RingProtocol/TK5Encoder.swift delete mode 100644 PulseLoop/RingProtocol/TK5Protocol.swift delete mode 100644 PulseLoop/RingProtocol/TK5SyncEngine.swift create mode 100644 PulseLoop/RingProtocol/YCBTDecoder.swift create mode 100644 PulseLoop/RingProtocol/YCBTDriver.swift create mode 100644 PulseLoop/RingProtocol/YCBTEncoder.swift create mode 100644 PulseLoop/RingProtocol/YCBTHealthRecords.swift create mode 100644 PulseLoop/RingProtocol/YCBTHistoryTransfer.swift create mode 100644 PulseLoop/RingProtocol/YCBTProtocol.swift create mode 100644 PulseLoop/RingProtocol/YCBTSettingsEncoder.swift create mode 100644 PulseLoop/RingProtocol/YCBTSyncEngine.swift create mode 100644 PulseLoopTests/RingIdentityMemoryTests.swift create mode 100644 PulseLoopTests/TK5CoordinatorTests.swift delete mode 100644 PulseLoopTests/TK5DecoderTests.swift create mode 100644 PulseLoopTests/YCBTDecoderTests.swift create mode 100644 PulseLoopTests/YCBTDriverTests.swift create mode 100644 PulseLoopTests/YCBTEncoderTests.swift create mode 100644 PulseLoopTests/YCBTFrameAssemblerTests.swift create mode 100644 PulseLoopTests/YCBTHealthRecordsTests.swift create mode 100644 PulseLoopTests/YCBTHistoryTransferTests.swift create mode 100644 PulseLoopTests/YCBTSupportFunctionTests.swift create mode 100644 docs/YCBT-Protocol.md diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 7a284f42..24d5210b 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -23,6 +23,10 @@ enum MeasurementKind: String, Codable, CaseIterable { case bloodPressureDiastolic = "bp_dia" case fatigue case bloodSugar = "glucose" + // YCBT/TK5 history metrics: respiratory rate rides the "All" record (byte 10), VO₂max the + // body-data record (byte 16). Append only — raw values persisted. + case respiratoryRate = "resp_rate" + case vo2max /// Display unit for a measurement of this kind. var unit: String { @@ -35,6 +39,8 @@ enum MeasurementKind: String, Codable, CaseIterable { case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg" case .fatigue: return "" case .bloodSugar: return "mg/dL" + case .respiratoryRate: return "brpm" // breaths per minute + case .vo2max: return "mL/kg/min" } } } diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift new file mode 100644 index 00000000..2b2b6d06 --- /dev/null +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -0,0 +1,126 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for Colmi rings that ship with the **SmartHealth** app (the R09/R10 the owner has). +/// +/// Same product line as `ColmiCoordinator`, a completely different firmware: these speak YCBT — the +/// byte-identical protocol the TK5 speaks — so the entire stack they build (`YCBTDriver` → encoder, +/// decoder, history transfer, sync engine) is the shared one, and this file is the whole of what makes +/// them their own family: an advertised identity and a capability set. Colmi rings that ship with +/// **QRing** keep the GadgetBridge-derived `ColmiDriver`. +/// +/// The two are told apart at *pairing*, not on the wire — see `RingAppVariant`. +@MainActor +final class ColmiSmartHealthCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .colmiSmartHealth + + // MARK: - Advertisement constants — PROVISIONAL + + /// ⚠️ **None of this has been checked against a real SmartHealth-Colmi advertisement.** We have no + /// capture yet (plan B0: the owner takes one with nRF Connect). These constants are a *hint* that + /// sets the default position of the pairing screen's app-type picker, and nothing more. + /// + /// The design deliberately does not depend on them being right. The user's explicit pick is what + /// selects the driver (`RingBLEClient.coordinatorType(preferredFamily:autoMatched:)`), so a + /// heuristic that never fires costs a toggle the user has to flip — not a working connection — and + /// one that fires wrongly costs the same. That asymmetry is why this is a hint and not a decision: + /// a QRing-Colmi and a SmartHealth-Colmi can advertise the *identical* local name, so no + /// advertisement-only rule can ever be trusted to separate them. + /// + /// Refine exactly these two constants (and nothing else) once the capture exists. + enum Advertisement { + /// The SmartHealth product code, matched as a **prefix of the manufacturer data** — i.e. in the + /// company-ID slot, which is where the only capture we have in this family puts it (the TK5's + /// `10786501…`; its trailing bytes are that model's own, so we match less than `TK5Coordinator` + /// does but at the same anchor). + /// + /// `BleHelper.filterDevice` merely *contains*-tests `1078` (and its siblings 1178/1278/1378/C5FE) + /// against the whole raw scan record — but it has no choice: it never parses out the AD + /// structures. We do (CoreBluetooth hands us the manufacturer data already isolated), and an + /// unanchored substring test over hex is not even byte-aligned: manufacturer bytes `a1 07 8f` + /// stringify to `"a1078f"` and would match. Colmi's manufacturer payload commonly embeds the MAC, + /// so an unlucky QRing-Colmi would be tagged as this family, defaulting the picker to the wrong + /// app for a ring we already support. Anchoring costs nothing and cannot do that. + /// + /// If the B0 capture shows a SmartHealth-Colmi carrying the code somewhere *other* than the first + /// two bytes, the fix is here and is one line: match on a byte-aligned index instead of a prefix. + static let manufacturerHexMarker = "1078" + + /// The QRing-flavoured Colmi rings advertise one of these. Presence is a positive disqualifier: + /// this ring answers to the *other* driver, so the conjunction below rejects it outright. + static let qringServiceUUIDs: [CBUUID] = [ + CBUUID(string: ColmiUUIDs.serviceV1), + CBUUID(string: ColmiUUIDs.serviceV2), + ] + } + + /// The conjunction: a Colmi-line local name **and** the SmartHealth marker **and** no QRing service. + /// + /// The name half reuses the catalog's own Colmi patterns rather than restating them — a card that + /// offers the SmartHealth app variant *is* the definition of "a Colmi-line name", and one list of + /// regexes is one list to keep right. The conjunction is what keeps this matcher off a TK5 (whose + /// `10786501…` carries the marker in the same slot — every ring in this SDK family does — but whose + /// card offers no variants, so the *name* half rejects it) and off any Colmi that advertises the + /// QRing service. + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + guard let model = WearableModel.model(advertisedName: name), + model.variant(for: deviceType) != nil else { return false } + guard let manufacturer = advertisement.manufacturerData, + manufacturer.hexString.hasPrefix(Advertisement.manufacturerHexMarker) else { return false } + return !advertisement.serviceUUIDs.contains { Advertisement.qringServiceUUIDs.contains($0) } + } + + // MARK: - Capabilities + + /// The floor: what every YCBT ring does regardless of which sensors its SKU carries. A *family* is + /// not a SKU here — two Colmi rings speaking this identical protocol can differ on whether they have + /// a temperature or blood-pressure sensor at all — so anything sensor-dependent is deferred to + /// `bitmapGatedCapabilities` and only claimed if the ring itself claims it. + /// + /// Two entries look like they belong in the gated set and deliberately don't, because they are + /// *protocol* facts, not sensor facts — identical for every YCBT ring, and the TK5 (the one unit of + /// this protocol we have on the bench) declares both as baseline: + /// + /// - `.measurementInterval` is the five `01 xx {enable, interval}` monitor writes. It is a settings + /// screen, not a sensor; a ring that doesn't implement one of the five NAKs that one write. + /// - `.spo2History` is the all-day `05 1A` log. A ring without it answers the query with a no-data + /// header or `0xFC`, which `YCBTHistoryTransfer` skips permanently. + /// + /// Neither is named by any bit in `YCBTSupportFunction`, so gating them would not defer the decision + /// — it would make them permanently unreachable (see `bitmapGatedCapabilities`). + /// + /// **`.fatigue` is deliberately absent**, unlike on the TK5. It rides the body-data record (`05 33`) + /// and no bit names it, so we can neither gate it nor honestly promise it on hardware nobody has + /// connected yet — and unlike the two above, an unsupported claim here *is* user-visible: `.fatigue` + /// renders its own Vitals gauge, which would sit permanently at "No fatigue score yet". B6 (the + /// first real sync) is what decides; adding a capability then is a one-line change, and a card that + /// appears is a better surprise than one that never fills. + let capabilities: Set = [ + .heartRate, .spo2, .spo2History, .steps, .sleep, .remSleep, .battery, .hrv, + .manualHeartRate, .manualSpo2, .manualHrv, + .realtimeHeartRate, .realtimeSteps, + .findDevice, .measurementInterval, + ] + + /// The per-SKU sensors: added only if this unit's `02 01` capability bitmap claims them (the + /// refinement is `WearableCoordinator.refinedCapabilities`, which can only *add*, and only from + /// this list). + /// + /// Every entry must be a capability `YCBTSupportFunction` can actually derive from a bit — a gate no + /// bit can ever satisfy is not a deferred decision but a dead promise, permanently unreachable while + /// reading as "supported if the ring says so". `PairingMatchingTests` asserts that invariant. + /// + /// These are exactly the rows the gap analysis marks `❔` for this family: present in the protocol, + /// unknown per unit. + let bitmapGatedCapabilities: Set = [ + .temperature, .bloodPressure, .stress, .bloodSugar, .manualBloodPressure, + ] + + let iconSystemName = "circle.circle.fill" + + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + YCBTDriver(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 6e36988c..fbcc25a5 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -27,12 +27,38 @@ final class RingBLEClient: NSObject { /// Registry of supported wearables. First coordinator whose `matches` claims a peripheral wins. /// **Adding a wearable = append one entry here.** + /// + /// The order is load-bearing at exactly one place: `ColmiSmartHealthCoordinator` must precede + /// `ColmiCoordinator`. Both recognize the same Colmi local names, and the QRing matcher needs + /// *only* the name — so behind it, no SmartHealth ring would ever be claimed. The SmartHealth + /// matcher is a conjunction a QRing ring cannot satisfy, so it is safe in front. static let coordinators: [WearableCoordinator.Type] = [ JringCoordinator.self, + ColmiSmartHealthCoordinator.self, ColmiCoordinator.self, TK5Coordinator.self, ] + /// Which coordinator serves a connection. Pure, so the pairing rules are testable without a + /// `CBCentralManager`. + /// + /// **The user's explicit family outranks the scan's auto-match.** Two Colmi rings that speak + /// different protocols can advertise the identical local name, so the advertisement is a hint and + /// the pairing screen's app-type pick is the fact. Falls back to jring when neither is known (a + /// reconnect to an unrecognized cached peripheral), preserving the original behavior. + static func coordinatorType( + preferredFamily: RingDeviceType?, + autoMatched: RingDeviceType? + ) -> WearableCoordinator.Type { + let family = preferredFamily ?? autoMatched + return coordinators.first { $0.deviceType == family } ?? JringCoordinator.self + } + + /// Walk the registry to claim an advertisement; nil when no coordinator recognizes it. + static func matchDeviceType(name: String?, advertisement: AdvertisementInfo) -> RingDeviceType? { + coordinators.first { $0.matches(name: name, advertisement: advertisement) }?.deviceType + } + struct DiscoveredRing: Identifiable, Equatable { let id: UUID // CBPeripheral.identifier let name: String @@ -114,9 +140,20 @@ final class RingBLEClient: NSObject { /// single dropped ACK can't wedge it. private let writeAckTimeout: UInt64 = 4_000_000_000 + /// Connect-attempt timeout, armed **only** for a connect the user asked for (see `beginConnect`). + /// The watchdog above only guards a link that already reached `.connected`; nothing guarded the + /// connect *phase*, which is where the wrong-driver failure lives: pick the wrong app variant for a + /// Colmi and the installed driver hunts for service UUIDs the ring doesn't have — the BLE link + /// opens, GATT discovery turns up nothing, `.connected` never arrives, and the pairing screen spins + /// forever with no error. 20 s is slack, not a race: a healthy ring completes link + discovery + + /// notify-enable in a few seconds. + private var connectTimeoutTask: Task? + private let connectTimeout: UInt64 = 20_000_000_000 + private static let lastPeripheralKey = "ring.lastPeripheralIdentifier" private static let lastDeviceTypeKey = "ring.lastDeviceType" private static let lastWearableModelKey = "ring.lastWearableModel" + private static let lastCapabilitiesKey = "ring.lastCapabilities" /// The 0x180F battery service, used only when the active driver exposes GATT battery. private let batteryServiceCBUUID = CBUUID(string: "180F") @@ -169,7 +206,9 @@ final class RingBLEClient: NSObject { if state == .scanning { state = .idle } } - func connect(to id: UUID, selectedModelID: String? = nil) { + /// - Parameter preferredFamily: the family the *user* declared at pairing (the app-type picker on a + /// Colmi card). Non-nil wins over the scan's auto-match — see `coordinatorType(preferredFamily:autoMatched:)`. + func connect(to id: UUID, selectedModelID: String? = nil, preferredFamily: RingDeviceType? = nil) { // Prefer the freshly-scanned object; fall back to the system cache (paired/known). guard let target = discoveredPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first else { lastError = "Ring no longer available; scan again." @@ -181,7 +220,9 @@ final class RingBLEClient: NSObject { to: target, deviceType: discoveredRing?.deviceType, selectedModelID: discoveredRing?.wearableModelID ?? selectedModelID, - advertisedName: discoveredRing?.name + advertisedName: discoveredRing?.name, + preferredFamily: preferredFamily, + userInitiated: true ) } @@ -202,6 +243,7 @@ final class RingBLEClient: NSObject { func disconnect() { autoReconnect = false + cancelConnectTimeout() // an explicit disconnect is not a failed connect attempt central.stopScan() if let peripheral { central.cancelPeripheralConnection(peripheral) @@ -232,6 +274,7 @@ final class RingBLEClient: NSObject { UserDefaults.standard.removeObject(forKey: Self.lastPeripheralKey) UserDefaults.standard.removeObject(forKey: Self.lastDeviceTypeKey) UserDefaults.standard.removeObject(forKey: Self.lastWearableModelKey) + UserDefaults.standard.removeObject(forKey: Self.lastCapabilitiesKey) // a new ring must not inherit the old one's claims activeDeviceType = nil activeWearableModelID = nil activeCapabilities = [] @@ -266,6 +309,19 @@ final class RingBLEClient: NSObject { UserDefaults.standard.string(forKey: Self.lastWearableModelKey) } + /// What the last connected ring resolved to *after* its capability bitmap had spoken — the baseline + /// plus whatever the unit itself claimed (`applySupportFunctions`). + /// + /// Remembered because the bitmap arrives partway into a handshake, while `Device.capabilitiesRaw` is + /// overwritten wholesale at the *start* of one. Without this, every connect would first stamp the + /// device back down to the family baseline — so a ring whose temperature sensor we already know about + /// would drop its Vitals card on every launch and only get it back a second later, and a handshake + /// whose `02 01` reply is lost (or answered by firmware with a short array) would leave it dropped + /// for the whole session and while offline afterwards. + var lastKnownCapabilities: Set { + Set(csv: UserDefaults.standard.string(forKey: Self.lastCapabilitiesKey) ?? "") + } + var hasLastKnownRing: Bool { lastKnownIdentifier != nil } /// The active sync engine, exposed so the `RingSyncCoordinator` façade can drive command flows. @@ -273,11 +329,18 @@ final class RingBLEClient: NSObject { // MARK: - Internal + /// - Parameters: + /// - preferredFamily: an explicitly user-declared family; wins over `deviceType` (the auto-match). + /// - userInitiated: the user tapped a ring in the pairing screen, so a connect that never lands is + /// a dead end they need told about — arm the connect-attempt timeout. Background reconnects pass + /// false: a pending connect that waits forever is exactly what they're *for*. private func beginConnect( to target: CBPeripheral, deviceType: RingDeviceType?, selectedModelID: String?, - advertisedName: String? + advertisedName: String?, + preferredFamily: RingDeviceType? = nil, + userInitiated: Bool = false ) { central.stopScan() autoReconnect = true @@ -285,6 +348,7 @@ final class RingBLEClient: NSObject { // 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() + cancelConnectTimeout() if let old = peripheral, old.identifier != target.identifier || old.state != .disconnected { central.cancelPeripheralConnection(old) } @@ -292,9 +356,9 @@ final class RingBLEClient: NSObject { writeInFlight = false; writeQueue = [] peripheral = target target.delegate = self - // Select the coordinator/driver for this connection. Default to jring if discovery didn't - // tag a type (e.g. reconnect to an unknown cached peripheral) — preserves prior behavior. - let coordinatorType = Self.coordinators.first { $0.deviceType == deviceType } ?? JringCoordinator.self + // Select the coordinator/driver for this connection: the user's declared family if they made + // one, else the auto-match, else jring (an unknown cached peripheral — prior behavior). + let coordinatorType = Self.coordinatorType(preferredFamily: preferredFamily, autoMatched: deviceType) activeAdvertisedName = advertisedName activeWearableModelID = WearableModel.resolve( advertisedName: advertisedName, @@ -303,19 +367,67 @@ final class RingBLEClient: NSObject { )?.id installDriver(coordinatorType) state = .connecting + if userInitiated { startConnectTimeout() } central.connect(target, options: nil) } /// Instantiate the coordinator's driver + sync engine for the upcoming connection. A fresh driver /// per connection keeps per-connection state from leaking across reconnects. - private func installDriver(_ coordinatorType: WearableCoordinator.Type) { + /// + /// Capabilities start from what we last *learned* about the ring, not from the bare family baseline: + /// the remembered set is fed back through the same additive-only refinement (`refinedCapabilities`), + /// so it can only re-grant capabilities this family gates on the bitmap — a set remembered from a + /// different family, or one naming something this family never gates, cannot leak in. The ring's own + /// bitmap still overrules it moments later (`applySupportFunctions`), downwards as well as upwards. + func installDriver(_ coordinatorType: WearableCoordinator.Type) { let coordinator = coordinatorType.init() let driver = coordinator.makeDriver(writer: self) activeCoordinator = coordinator activeDriver = driver activeSyncEngine = driver.makeSyncEngine() activeDeviceType = coordinatorType.deviceType - activeCapabilities = coordinator.capabilities + activeCapabilities = coordinator.refinedCapabilities(bitmapDerived: lastKnownCapabilities) + } + + /// Re-adopt the remembered ring's identity: its family **and** the exact catalog model. + /// + /// State restoration is the one connect path with no advertisement to re-derive either from. The + /// family was already remembered; the model id was not, and a restored session that reached + /// `.connected` without it published `.deviceIdentified(wearableModelID: nil)` — which *erases* + /// `Device.wearableModelID`, costing the device card its product name and art until some later + /// reconnect happened to carry a resolvable GAP name. + func adoptRememberedIdentity() { + // The remembered family already *is* the user's choice — `beginConnect` persisted whichever + // coordinator it selected — so a restored session re-adopts the right driver for free. + installDriver(Self.coordinatorType(preferredFamily: nil, autoMatched: lastKnownDeviceType)) + activeWearableModelID = lastKnownWearableModelID + } + + /// Fold the ring's self-reported capability bitmap into the active capability set. + /// + /// `installDriver` seeds `activeCapabilities` with the coordinator's baseline — what the *family* + /// can do. This is where *this unit* gets a say, which is what makes one driver able to serve SKUs + /// that differ on which sensors they physically have. The bitmap can only add capabilities the + /// family pre-approved (`refinedCapabilities`), so a family that gates none is untouched. + /// + /// Re-publishing `.deviceIdentified` is what carries the refined set into `Device.capabilitiesRaw`; + /// the equality guard keeps that idempotent, because the bitmap arrives on *every* handshake and a + /// reconnect runs a fresh one — without it, each reconnect would re-publish an identical set and + /// churn the bus and the store for nothing. + private func applySupportFunctions(_ derived: Set) { + guard let coordinator = activeCoordinator, let type = activeDeviceType else { return } + let refined = coordinator.refinedCapabilities(bitmapDerived: derived) + // Remember the answer even when it changes nothing right now: this is what seeds the *next* + // connect, before that connect's own bitmap has arrived (see `lastKnownCapabilities`). + UserDefaults.standard.set(refined.csv, forKey: Self.lastCapabilitiesKey) + guard refined != activeCapabilities else { return } + activeCapabilities = refined + publish(.deviceIdentified( + deviceType: type, + wearableModelID: activeWearableModelID, + advertisedName: activeAdvertisedName, + capabilities: refined + )) } private func pumpWrites() { @@ -400,6 +512,41 @@ final class RingBLEClient: NSObject { watchdogTask?.cancel(); watchdogTask = nil } + // MARK: Connect-attempt timeout + + private func startConnectTimeout() { + connectTimeoutTask?.cancel() + connectTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: self?.connectTimeout ?? 20_000_000_000) + guard let self, !Task.isCancelled else { return } + self.connectAttemptTimedOut() + } + } + + /// Deliberately *not* folded into `stopReliabilityTimers`: that also runs on an unexpected disconnect, + /// where the client re-dials and the attempt is still live — cancelling there would disarm the very + /// case this guards. + private func cancelConnectTimeout() { + connectTimeoutTask?.cancel() + connectTimeoutTask = nil + } + + /// The user's connect never reached `.connected`. Give up, and say why in terms they can act on. + private func connectAttemptTimedOut() { + guard state == .connecting || state == .reconnecting else { return } + connectTimeoutTask = nil + // Order matters: `didDisconnectPeripheral` re-dials whenever `autoReconnect` is set, so clearing + // it *before* the cancel is what stops the failure from instantly reconnecting itself. + autoReconnect = false + if let peripheral { central.cancelPeripheralConnection(peripheral) } + stopReliabilityTimers() + // Names the app variant we tried and the one to try instead — the whole point of failing loudly + // here is that the wrong-app pick is recoverable in one tap (`PairingView`). + lastError = RingConnectFailure.message(family: activeDeviceType) + state = .failed + publish(.deviceStateChanged(state: .failed, address: nil)) + } + private func publishRawPacket(direction: PacketDirection, data: Data) { // Outgoing frames are logged raw for the debug feed. We do NOT route them through the // driver's `ingest` (that is the inbound path and, for big-data devices, would corrupt the @@ -412,16 +559,13 @@ final class RingBLEClient: NSObject { Task { await PulseEventBus.shared.publish(event) } } - /// Walk the coordinator registry to claim a discovered peripheral. Returns the first matching - /// device type, or nil if no coordinator recognizes it. + /// Lift CoreBluetooth's raw advertisement dictionary into `AdvertisementInfo` and claim it against + /// the registry. private func matchDeviceType(name: String?, advertisementData: [String: Any]) -> RingDeviceType? { - let serviceUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] - let mfg = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data - let info = AdvertisementInfo(serviceUUIDs: serviceUUIDs, manufacturerData: mfg) - for coordinatorType in Self.coordinators where coordinatorType.matches(name: name, advertisement: info) { - return coordinatorType.deviceType - } - return nil + Self.matchDeviceType(name: name, advertisement: AdvertisementInfo( + serviceUUIDs: (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [], + manufacturerData: advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + )) } } @@ -449,8 +593,7 @@ extension RingBLEClient: CBCentralManagerDelegate { autoReconnect = true peripheral = target target.delegate = self - let coordinatorType = Self.coordinators.first { $0.deviceType == lastKnownDeviceType } ?? JringCoordinator.self - installDriver(coordinatorType) + adoptRememberedIdentity() } } @@ -494,13 +637,20 @@ extension RingBLEClient: CBCentralManagerDelegate { // advertisement omits the service UUID / manufacturer bytes; recognized rings sort first. guard let displayName = name, !displayName.isEmpty else { return } discoveredPeripherals[peripheral.identifier] = peripheral + // Keep the model tag when the matched family is *any* the card can resolve to, not just its + // default — a Colmi claimed as `.colmiSmartHealth` is still a "Colmi R09". + let modelID: String? = { + guard let matchedModel, let matchedType, + matchedModel.families.contains(matchedType) else { return nil } + return matchedModel.id + }() let ring = DiscoveredRing( id: peripheral.identifier, name: displayName, rssi: RSSI.intValue, isLikelyRing: matchedType != nil, deviceType: matchedType, - wearableModelID: matchedModel?.family == matchedType ? matchedModel?.id : nil + wearableModelID: modelID ) if let index = discovered.firstIndex(where: { $0.id == ring.id }) { discovered[index] = ring @@ -514,6 +664,9 @@ extension RingBLEClient: CBCentralManagerDelegate { nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { MainActor.assumeIsolated { peripheral.delegate = self + // Auto-reconnect keeps the existing driver, so tell it the link is new: anything half-read + // when the old link dropped (a partial frame, an in-flight history transfer) must go. + activeDriver?.connectionDidStart() // 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. @@ -555,12 +708,20 @@ extension RingBLEClient: CBCentralManagerDelegate { batteryCharacteristic = nil writeInFlight = false writeQueue = [] + // Stop the driver's own state machines *now*, not on the next connect: a self-driving one + // (the YCBT history transfer's stall watchdog) would otherwise keep stepping through the + // reconnect gap and refill the queue we just cleared, and those stale queries would be the + // first thing the new link writes — ahead of its handshake. + activeDriver?.connectionDidEnd() publish(.deviceStateChanged(state: .disconnected, address: nil)) if autoReconnect { state = .reconnecting central.connect(peripheral, options: nil) } else { - state = .disconnected + // A connect-attempt timeout has already parked us in `.failed` with an explanatory + // message, and the cancel *it* issued is what brought us here — it must not overwrite + // that with a bland `.disconnected`. + if state != .failed { state = .disconnected } self.peripheral = nil } } @@ -602,7 +763,7 @@ extension RingBLEClient: CBPeripheralDelegate { let uuid = characteristic.uuid // Write / command / notify are checked independently (not mutually exclusive) because a // device can expose one characteristic that is *both* the write target and a notify - // source — the TK5's `be940001` receives command replies on the same char it's written + // source — the YCBT families' `be940001` receives command replies on the same char it's written // to. jring/Colmi keep these on distinct UUIDs, so their behavior is unchanged. if uuid == driver.writeUUID { writeChar = characteristic } if uuid == driver.commandUUID { commandChar = characteristic } @@ -633,6 +794,7 @@ extension RingBLEClient: CBPeripheralDelegate { // this twice; guard against re-running startup.) guard state != .connected else { return } state = .connected + cancelConnectTimeout() // the attempt landed UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: Self.lastPeripheralKey) if let type = activeDeviceType { UserDefaults.standard.set(type.rawValue, forKey: Self.lastDeviceTypeKey) @@ -689,6 +851,9 @@ extension RingBLEClient: CBPeripheralDelegate { for event in RingEventBridge.events(for: decoded) { publish(event) } + if case let .supportFunctions(derived) = decoded { + applySupportFunctions(derived) + } // Advance any response-driven sync machine (no-op for jring). activeSyncEngine?.handle(decoded) } diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index ff146602..ed008420 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -26,6 +26,16 @@ enum RingEventBridge { static let fatigueRange: ClosedRange = 1...100 /// Plausible blood sugar, in mg/dL. static let bloodSugarRange: ClosedRange = 40...600 + /// Plausible SpO₂, in percent. The floor is the *display* floor of consumer oximetry hardware, not + /// the 70 % where its accuracy spec stops: this gate exists to drop the ring's "no sample" fillers + /// and misframed bytes, and it must not second-guess a genuinely hypoxemic night. A QRing-Colmi's + /// all-day SpO₂ log reached persistence ungated before this gate existed, so anything a real sensor + /// can report has to keep reaching it. + static let spo2Range: ClosedRange = 35...100 + /// Plausible respiratory rate, in breaths per minute (newborn-to-panic bracket). + static let respiratoryRateRange: ClosedRange = 4...60 + /// Plausible VO₂max, in mL/kg/min (sedentary floor to elite-athlete ceiling). + static let vo2maxRange: ClosedRange = 10...90 /// 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 @@ -50,7 +60,13 @@ enum RingEventBridge { case let .activityBucket(timestamp, steps, distanceMeters): // Guard against a misframed history packet painting a wild total: a single 15-min bucket // can't realistically exceed these. Drop the bucket if it does. - guard (0...maxBucketSteps).contains(steps), (0...maxBucketDistance).contains(distanceMeters) else { return [] } + // + // The timestamp needs the same window as every other history path: a bucket the ring logged + // against an unset RTC decodes to 2000-01-01, and — unlike a live update, which only ratchets + // today's row — a bucket *creates* an `ActivityDaily` at that date and re-upserts it on every + // sync, so it never ages out. (A no-op for Colmi, whose decoder pre-gates the same window.) + guard (0...maxBucketSteps).contains(steps), (0...maxBucketDistance).contains(distanceMeters), + isPlausibleActivityTimestamp(timestamp, now: now) else { return [] } return [.activityBucket(timestamp: timestamp, steps: steps, distanceMeters: distanceMeters)] case let .heartRateSample(bpm, timestamp): @@ -126,11 +142,33 @@ enum RingEventBridge { timestamp: Date, now: Date ) -> [PulseEvent] { - if kind == .heartRate, !hrRange.contains(Int(value)) { return [] } + guard isPlausible(kind: kind, value: value) else { return [] } guard isWithinHistoryWindow(timestamp, now: now) else { return [] } return [.historyMeasurement(kind: kind, value: value, timestamp: timestamp)] } + /// Range gate for a history sample, by kind — the single place these bounds live (the record + /// decoders only drop the ring's "no sample" fillers, never a range). + /// + /// This matters more for history than for live data: a ring replays its *entire* log on every sync + /// and history rows upsert on (kind, timestamp), so one misdecoded record doesn't just flicker — + /// it re-persists on every future sync until the ring forgets it. + private static func isPlausible(kind: MeasurementKind, value: Double) -> Bool { + switch kind { + case .heartRate: return hrRange.contains(Int(value)) + case .spo2: return spo2Range.contains(Int(value)) + case .stress: return stressRange.contains(Int(value)) + case .hrv: return hrvRange.contains(Int(value)) + case .temperature: return temperatureRange.contains(value) + case .bloodPressureSystolic: return systolicRange.contains(Int(value)) + case .bloodPressureDiastolic: return diastolicRange.contains(Int(value)) + case .fatigue: return fatigueRange.contains(Int(value)) + case .bloodSugar: return bloodSugarRange.contains(value) + case .respiratoryRate: return respiratoryRateRange.contains(Int(value)) + case .vo2max: return vo2maxRange.contains(Int(value)) + } + } + private static func extraMetricEvents(for decoded: RingDecodedEvent) -> [PulseEvent] { switch decoded { case let .bloodPressureSample(systolic, diastolic, timestamp): diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index 5d7412fd..99602558 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -127,6 +127,20 @@ enum RingDecodedEvent: Sendable { case bind(action: UInt8, state: UInt8) /// Capability bitmask reply (0x20). Consumed by `JringSyncEngine`; produces no `PulseEvent`. case bandFunction(JringBandCapabilities) + /// The device's own capability bitmap, already mapped onto `WearableCapability` (YCBT `02 01`; see + /// `YCBTSupportFunction`). Consumed by `RingBLEClient` to refine the active capability set — the + /// coordinator's baseline is what the *family* can do, this is what *this unit* claims. Produces no + /// `PulseEvent`: capabilities reach persistence via the re-published `.deviceIdentified`, not here. + case supportFunctions(Set) + /// The chipset/OTA family (YCBT `02 1b`). **Diagnostic only** — PulseLoop does no firmware updates, + /// so nothing branches on it; it is decoded because it is the one frame that says whether the ring's + /// OTA path is JieLi RCSP (3/4/5), which is what would gate the AE00 auth we deliberately don't + /// implement. Produces no `PulseEvent`. + case chipScheme(value: Int) + /// The ring reports it went on/off the finger (YCBT `06 13`). Debug-feed only for now — it produces + /// no `PulseEvent`, because nothing in the app gates on wear state yet. It is decoded so the packet + /// feed can show *why* a measurement returned nothing (the ring was off). + case wearingStatus(worn: Bool, timestamp: Date) case timeSyncAck(timestamp: Date) case commandAck(commandId: UInt8) case unknown(commandId: UInt8, raw: Data) @@ -155,6 +169,9 @@ enum RingDecodedEvent: Sendable { case .firmware: return "firmware" case .bind: return "bind" case .bandFunction: return "band_function" + case .supportFunctions: return "support_functions" + case .chipScheme: return "chip_scheme" + case .wearingStatus: return "wearing_status" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" case .unknown: return "unknown" @@ -166,7 +183,8 @@ enum RingDecodedEvent: Sendable { case .unknown: return .unknown case .commandAck, .heartRateComplete, .spo2Complete, .spo2Progress, .bind, .firmware, - .bandFunction: // bit ordering unverified against hardware + .bandFunction, // bit ordering unverified against hardware + .wearingStatus: // layout is SDK-verified; the status byte's *polarity* is not return .partial default: return .known @@ -201,6 +219,12 @@ enum RingDecodedEvent: Sendable { return #"{"bind_action":\#(action),"bind_state":\#(state)}"# case let .bandFunction(caps): return #"{"temp":\#(caps.hasTemperature),"spo2_separate":\#(caps.separateBloodOxygenMode),"spo2_offline":\#(caps.hasOxygenOfflineHistory),"pressure":\#(caps.hasPressureHistory)}"# + case let .supportFunctions(capabilities): + return #"{"claimed":"\#(capabilities.csv)"}"# + case let .chipScheme(value): + return #"{"chip_scheme":\#(value)}"# + case let .wearingStatus(worn, _): + return #"{"worn":\#(worn)}"# case let .historySyncProgress(stage): return #"{"stage":"\#(stage)"}"# case let .battery(percent): diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 872a8b65..9915f1be 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -4,6 +4,10 @@ import Foundation /// Coordinator for the TK5 ring (SmartHealth app). Declares the capabilities we can actually decode /// and recognizes the device from its advertisement. /// +/// The *protocol* is not TK5-specific — the ring speaks YCBT, so the driver, encoder, decoder and sync +/// engine it builds are the shared `YCBT*` types. This file is the whole of what makes a TK5 a TK5: +/// its advertised identity and its capability set. +/// /// Recognition is name-first: the TK5's proprietary `be940000` service is **not advertised** (only /// standard Heart Rate + a generic `FEE7` service are), so the reliable signal is the `TK5 …` local /// name, backed up by the manufacturer-data prefix observed in the nRF capture. @@ -26,27 +30,50 @@ final class TK5Coordinator: WearableCoordinator { return false } - /// Metrics decoded from the captures: live + history HR, live SpO₂, day steps, HRV (history + - /// live, verified against the app's displayed values), blood pressure, the deep/light/REM sleep - /// timeline, and the in-band battery. Stress is *not* claimed — the ring doesn't store it; - /// SmartHealth derives it from HRV app-side. Temperature is omitted until a capture contains it, - /// so no empty card appears. + /// Everything the ring stores and `YCBTHealthRecords` decodes: live + history HR, SpO₂ (live *and* + /// the all-day `05 1A` log), day steps, HRV, blood pressure, temperature, blood sugar, the + /// deep/light/REM sleep timeline, and the in-band battery. + /// + /// **Stress and fatigue are ring-stored, not app-derived**: the body-data record (`05 33`) carries + /// them as the SDK's `pressure` and `body` fields, alongside VO₂max and the HRV frequency-domain + /// metrics. Temperature likewise has both a dedicated log (`05 1E`) and a field in the All record. + /// + /// `manualHeartRate` / `manualSpo2` / `manualHrv` / `manualBloodPressure` surface the "Measure now" + /// buttons in Vitals: a spot reading toggles the live `03 2f` stream on in the metric's own mode, + /// collects the first good sample from the `06 01` (HR) / `06 02` (SpO₂) / `06 03` (BP *and* HRV) + /// frames, then toggles the same mode off. All four ride the one stream, so any of them can be + /// measured on demand — one at a time. /// - /// `manualHeartRate` / `manualSpo2` / `manualHrv` surface the "Measure now" buttons in Vitals: a - /// spot reading toggles the live `03 2f` stream on, collects the first good sample from the - /// `06 01` (HR) / `06 02` (SpO₂) / `06 03` (HRV) frames, then toggles it off. All three ride the - /// one stream, so any of them can be measured on demand. + /// `measurementInterval` surfaces the Measurement-settings screen: the ring's five `01 xx + /// {enable, interval}` monitors map 1:1 onto it (HR `01 0C`, BP `01 1C`, temperature `01 20`, + /// SpO₂ `01 26`, HRV `01 45`), and the interval is floored at the firmware's 30-minute minimum. + /// + /// This is the *decodable* set, not a per-unit truth: firmware variants differ, and which history + /// types actually return records is what the on-device checkpoint establishes (which is also why + /// `supportLevel` stays `.limited` — see `docs/hardware/tk5.md`). let capabilities: Set = [ - .heartRate, .spo2, .steps, .battery, .hrv, .bloodPressure, + .heartRate, .spo2, .spo2History, .steps, .battery, .hrv, .bloodPressure, + .temperature, .stress, .fatigue, .bloodSugar, .sleep, .remSleep, - .manualHeartRate, .manualSpo2, .manualHrv, + .manualHeartRate, .manualSpo2, .manualHrv, .manualBloodPressure, .realtimeHeartRate, .realtimeSteps, - .findDevice, + .findDevice, .measurementInterval, ] + /// The TK5 keeps its static set: nothing above is bitmap-gated, so its `02 01` reply can neither add + /// nor remove a capability. That is deliberate — there is one TK5 SKU and it is the ring we have on + /// the bench, so gating it would trade a known-good capability set for a runtime dependency on a + /// parser no hardware had yet exercised. + /// + /// The handshake still *requests* the bitmap and `YCBTSupportFunction` still parses it, so every TK5 + /// session prints the decoded claim to the debug feed. That is the point: it validates the bit table + /// against real hardware, at zero behavioural risk, before the Colmi family — whose SKUs genuinely + /// differ on which sensors they carry — starts depending on it. + let bitmapGatedCapabilities: Set = [] + let iconSystemName = "circle.circle.fill" func makeDriver(writer: RingCommandWriter) -> WearableDriver { - TK5Driver(writer: writer) + YCBTDriver(writer: writer) } } diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift deleted file mode 100644 index 70738daf..00000000 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation - -/// Decodes inbound TK5 frames into the shared `RingDecodedEvent`. Frames arrive already -/// CRC-validated (see `TK5Frame`), from either the command channel (be940001) or the async stream -/// (be940003); this decoder dispatches on `(type, cmd)` regardless of channel. -/// -/// Confidence tags mirror the codebase convention: fields read straight out of the capture are -/// trusted; guessed offsets are tagged `// UNVERIFIED (capture-inferred)`. Everything routes through -/// `RingEventBridge`, which range-gates each metric, so a misdecoded byte is dropped rather than -/// persisted as garbage. -struct TK5Decoder { - /// Decode one validated frame into the events it carries (usually one; unknown frames → `.unknown`). - func decode(_ frame: TK5Frame, now: Date = Date()) -> [RingDecodedEvent] { - switch (frame.type, frame.cmd) { - - // MARK: Async live stream (be940003, type 0x06) - - case (TK5FrameType.stream, TK5Command.liveStatus): - // Cumulative day totals. steps verified against capture (0x027b = 635); distance/calories - // are the adjacent u16s — UNVERIFIED (capture-inferred), but `applyActivityUpdate` uses - // max() so an over-read can't corrupt the day. - let p = frame.payload - guard p.count >= 2 else { return [.commandAck(commandId: frame.cmd)] } - let steps = TK5Bytes.u16(p, 0) - let distance = Double(TK5Bytes.u16(p, 2)) - let calories = Double(TK5Bytes.u16(p, 4)) - return [.activityUpdate(timestamp: now, steps: steps, distanceMeters: distance, calories: calories)] - - case (TK5FrameType.stream, TK5Command.liveHeartRate): - // 1-byte live bpm. Verified (climbed 82→86 across the capture). - guard let bpm = frame.payload.first else { return [.commandAck(commandId: frame.cmd)] } - return [.heartRateSample(bpm: Int(bpm), timestamp: now)] - - case (TK5FrameType.stream, TK5Command.liveSpo2): - // 1-byte live SpO₂ % from the mode-0x02 (red-LED) stream; values sat in 95–98 in the - // capture. Gate to a plausible range so a warm-up 0 isn't surfaced as a reading (the event - // bridge doesn't range-gate SpO₂). - guard let spo2 = frame.payload.first, (70...100).contains(spo2) else { - return [.commandAck(commandId: frame.cmd)] - } - return [.spo2Result(value: Int(spo2), timestamp: now)] - - case (TK5FrameType.stream, TK5Command.liveExtended): - return decodeLiveExtended(frame.payload, cmd: frame.cmd, now: now) - - // MARK: History records (be940003, type 0x05) - - case (TK5FrameType.register, TK5Command.historyRecordShort): - // Packed **6-byte** HR history records: `[ts:4][flag:1][hr:1]`. One frame carries many — - // e.g. eight hourly overnight samples (71,66,63,62,66,60,65,58 bpm @23:00–06:00, matched - // to wall-clock) — so decode *every* record, not just the first (the earlier bug that hid - // periodic data). hr is at offset 5. - return records(in: frame.payload, size: 6).compactMap { r in - let hr = r[5] - guard hr > 0 else { return nil } - return .historyMeasurement(kind: .heartRate, value: Double(hr), - timestamp: TK5Bytes.date(TK5Bytes.u32(r, 0))) - } - - case (TK5FrameType.register, TK5Command.historyRecordLong): - return decodeCombinedVitals(frame.payload, cmd: frame.cmd) - - // MARK: Command channel (be940001) - - case (TK5FrameType.device, TK5Command.status): - // 30-byte status. Battery at payload[5] (0x64 = 100 in the capture). UNVERIFIED - // (capture-inferred); guarded to 0…100 downstream. - let p = frame.payload - var events: [RingDecodedEvent] = [.status(address: nil)] - if p.count >= 6 { events.append(.battery(percent: Int(p[5]))) } - return events - - case (TK5FrameType.device, TK5Command.deviceInfo): - // 66-byte device/firmware block. A readable version string couldn't be pinned to a fixed - // offset from one capture, so we just re-assert connected state here. - return [.status(address: nil)] - - case (TK5FrameType.config, TK5Command.setTime): - return [.timeSyncAck(timestamp: now)] - - default: - return [.commandAck(commandId: frame.cmd)] - } - } - - /// The `06 03` live frame carries two shapes depending on the active measurement mode: - /// BP (mode 0x01): `[sys][dia][hr?]…` — verified against the app (111/74, 112/75) - /// HRV (mode 0x0a): `[0 0 0][hrv]…` — verified against the app (79, 177 ms) - /// Distinguish by the leading bytes; both are range-gated downstream. - private func decodeLiveExtended(_ p: [UInt8], cmd: UInt8, now: Date) -> [RingDecodedEvent] { - if p.count >= 2, (60...250).contains(p[0]), (30...160).contains(p[1]) { - return [.bloodPressureSample(systolic: Int(p[0]), diastolic: Int(p[1]), timestamp: now)] - } - if p.count >= 4, p[3] > 0 { - return [.hrvSample(value: Int(p[3]), timestamp: now)] - } - return [.commandAck(commandId: cmd)] - } - - /// Packed **20-byte** combined-vitals history records: `[ts:4][steps:2][hr@6][sys?@7][dia?@8] - /// [spo2@9][?@10][hrv@11]…`, many per frame. Emit periodic SpO₂ + HRV (HR comes from the paired - /// `05 15` stream, so it isn't re-emitted here) plus per-day steps. HRV verified against the app - /// (48/79 ms); SpO₂ inferred (95–98) and range-gated; BP verified (106/70 @6:00). Steps are a - /// *cumulative* daily counter (rises through the day, resets to 0 at midnight), so they're emitted - /// as an `activityUpdate` (per-day max) — not an additive bucket — with distance/calories 0 so - /// `max()` leaves any live-status values intact. - private func decodeCombinedVitals(_ payload: [UInt8], cmd: UInt8) -> [RingDecodedEvent] { - var events: [RingDecodedEvent] = [] - for r in records(in: payload, size: 20) { - let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) - events.append(.activityUpdate(timestamp: ts, steps: TK5Bytes.u16(r, 4), - distanceMeters: 0, calories: 0)) - if (60...250).contains(r[7]), (30...160).contains(r[8]) { - events.append(.bloodPressureSample(systolic: Int(r[7]), diastolic: Int(r[8]), timestamp: ts)) - } - if (70...100).contains(r[9]) { - events.append(.historyMeasurement(kind: .spo2, value: Double(r[9]), timestamp: ts)) - } - if r[11] > 0 { - events.append(.historyMeasurement(kind: .hrv, value: Double(r[11]), timestamp: ts)) - } - } - return events.isEmpty ? [.commandAck(commandId: cmd)] : events - } - - /// Decode a fully-reassembled sleep record (see `TK5Driver` for the multi-frame reassembly). - /// Layout: a 20-byte header `[magic:2][totalLen:2 LE][startTs:4][endTs:4]…` followed by 8-byte - /// stage segments `[stage:1][startTs:4 LE][durationSec:2 LE][pad:1]`. Segments are contiguous, so - /// we expand each into per-minute `SleepStage`s and emit one `.sleepTimeline` anchored at the first - /// segment. Stage mapping verified against the app's on-screen breakdown (deep 93 / light 249 / - /// rem 130 min): `0xf1`=deep, `0xf2`=light, `0xf3`=rem, `0xf4`=awake. - func decodeSleep(_ record: [UInt8]) -> [RingDecodedEvent] { - let headerLen = 20 - let segmentLen = 8 - guard record.count >= headerLen + segmentLen else { return [] } - - var stages: [SleepStage] = [] - var startDate: Date? - var i = headerLen - while i + segmentLen <= record.count { - let tag = record[i] - guard let stage = sleepStage(tag) else { break } // stop at padding / unknown tail - let segStart = TK5Bytes.u32(Array(record[i...]), 1) - let durationSec = TK5Bytes.u16(Array(record[i...]), 5) - if startDate == nil { startDate = TK5Bytes.date(segStart) } - let minutes = Int((Double(durationSec) / 60.0).rounded()) - stages.append(contentsOf: Array(repeating: stage, count: max(1, minutes))) - i += segmentLen - } - - guard let start = startDate, !stages.isEmpty else { return [] } - return [.sleepTimeline(timestamp: start, stages: stages)] - } - - /// Map a TK5 sleep stage tag to the shared `SleepStage`. Verified against the app's displayed - /// deep/light/REM minutes; `nil` signals a non-stage byte (end of segment list). - private func sleepStage(_ tag: UInt8) -> SleepStage? { - switch tag { - case 0xf1: return .deep - case 0xf2: return .light - case 0xf3: return .rem - case 0xf4: return .awake - default: return nil - } - } - - /// Split a packed history payload into fixed-size records, dropping any short trailing remainder. - /// TK5 history frames concatenate many equal-size records (e.g. an hour's worth of samples), so a - /// decoder must walk them all rather than reading only the first. - private func records(in payload: [UInt8], size: Int) -> [[UInt8]] { - guard size > 0 else { return [] } - var out: [[UInt8]] = [] - var i = 0 - while i + size <= payload.count { - out.append(Array(payload[i..<(i + size)])) - i += size - } - return out - } -} diff --git a/PulseLoop/RingProtocol/TK5Driver.swift b/PulseLoop/RingProtocol/TK5Driver.swift deleted file mode 100644 index 09b034e5..00000000 --- a/PulseLoop/RingProtocol/TK5Driver.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -@preconcurrency import CoreBluetooth - -/// TK5 driver. Owns the length-prefixed CRC16 framing and the split-channel topology: the command -/// characteristic `be940001` is *both* the write target and a notify source (command replies), while -/// `be940003` carries the async live/history stream. The standard `180D`/`2A37` Heart Rate -/// characteristic is deliberately left unsubscribed — see the BLE-topology note below. -/// -/// Because `be940001` is simultaneously the write and a notify characteristic, `RingBLEClient`'s -/// discovery subscribes any `notifyUUIDs` entry even when it also matches `writeUUID`. -@MainActor -final class TK5Driver: WearableDriver { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - - private weak var writer: RingCommandWriter? - private let decoder = TK5Decoder() - - init(writer: RingCommandWriter) { - self.writer = writer - } - - // MARK: BLE topology - // - // Only the proprietary `be940000` service is used. The standard `180D`/`2A37` Heart Rate - // characteristic is intentionally NOT subscribed: on the TK5 it emits a cached resting HR - // periodically even when the ring is off the finger (observed as a constant ~87 bpm), which would - // override a real on-demand measurement. The official app never subscribes it either — live HR - // comes solely from the proprietary `06 01` stream, which reflects actual finger contact. - let serviceUUIDs: [CBUUID] = [CBUUID(string: TK5UUIDs.service)] - let writeUUID = CBUUID(string: TK5UUIDs.command) - let notifyUUIDs: [CBUUID] = [ - CBUUID(string: TK5UUIDs.command), // command replies (also the write char) - CBUUID(string: TK5UUIDs.stream), // async live + history stream - ] - let batteryServiceUUID: CBUUID? = nil // battery is in-band (0x02 0x00 status, payload[5]) - let batteryCharUUID: CBUUID? = nil - - // MARK: Framing - func frame(_ command: Data) -> Data { - // Logical command is `[type, cmd, payload…]`; insert the total-length field and append CRC16. - TK5Frame.frame([UInt8](command)) - } - - // Sleep-record reassembly. The sleep timeline (`05 13`) is one logical record split across several - // be940003 frames, with stage segments straddling frame boundaries. The header frame starts with - // the `af fa` magic and carries the total concatenated payload length at bytes [2..3]; we buffer - // until that many bytes arrive, then decode. - private var sleepBuffer: [UInt8] = [] - private var sleepTotal = 0 - - // MARK: Inbound decode - // - // Every history frame is announced with a leading `.historySyncProgress` before its decoded - // records. The sync engine's watchdog needs to know *a history frame arrived*, and it can't infer - // that from the events alone: a sleep continuation frame decodes to nothing until the record is - // complete, an all-unworn `05 18` page yields only `.activityUpdate`, and `.activityUpdate` is - // also what the ring's continuous live `06 00` status pushes. Only the frame type distinguishes - // them, and only the driver sees it. - func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { - guard let frame = TK5Frame(validating: data) else { - return [.unknown(commandId: data.first ?? 0, raw: data)] - } - guard frame.type == TK5FrameType.register else { return decoder.decode(frame) } - switch frame.cmd { - case TK5Command.sleepRecord: - return [.historySyncProgress(stage: "Syncing sleep…")] + reassembleSleep(frame.payload) - case TK5Command.historyRecordShort, TK5Command.historyRecordLong: - return [.historySyncProgress(stage: "Syncing history…")] + decoder.decode(frame) - default: - return decoder.decode(frame) // register reads / pref-write acks - } - } - - private func reassembleSleep(_ payload: [UInt8]) -> [RingDecodedEvent] { - if payload.count >= 4, payload[0] == 0xaf, payload[1] == 0xfa { - // Header frame — start a fresh buffer and read the total length. - sleepBuffer = payload - sleepTotal = Int(payload[2]) | (Int(payload[3]) << 8) - } else if !sleepBuffer.isEmpty { - sleepBuffer.append(contentsOf: payload) // continuation - } else { - return [] // continuation with no header seen (mid-stream connect) — ignore - } - guard sleepTotal > 0, sleepBuffer.count >= sleepTotal else { return [] } // still buffering - let record = Array(sleepBuffer.prefix(sleepTotal)) - sleepBuffer = [] - sleepTotal = 0 - return decoder.decodeSleep(record) - } - - func makeSyncEngine() -> RingSyncEngine { - TK5SyncEngine(writer: writer) - } -} diff --git a/PulseLoop/RingProtocol/TK5Encoder.swift b/PulseLoop/RingProtocol/TK5Encoder.swift deleted file mode 100644 index 0d966a68..00000000 --- a/PulseLoop/RingProtocol/TK5Encoder.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -/// Builds *logical* TK5 commands — `[type, cmd, payload…]` without the length field or CRC, which -/// `TK5Driver.frame(_:)` appends. The connect handshake is a faithful replay of the exact byte -/// sequence the SmartHealth app sent on connect (only the time command is regenerated), which is the -/// safest way to reproduce known-good ring behavior for the fields we can't independently decode. -struct TK5Encoder { - /// Set the ring clock: `01 00` + `[year:2 LE][month][day][hour][min][sec][00]`. - /// Verified against the capture (`ea07 07 06 0c 22 0e 00` = 2026-07-06 12:34:14). - func setTime(_ date: Date = Date(), calendar: Calendar = .current) -> [UInt8] { - let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) - let year = UInt16(c.year ?? 2000) - return [TK5FrameType.config, TK5Command.setTime, - UInt8(year & 0xff), UInt8((year >> 8) & 0xff), - UInt8(c.month ?? 1), UInt8(c.day ?? 1), - UInt8(c.hour ?? 0), UInt8(c.minute ?? 0), UInt8(c.second ?? 0), 0x00] - } - - /// The connect handshake, in order, as the SmartHealth app issued it (time regenerated). These are - /// replayed verbatim because the ring expects this exact sequence; the 2-byte tokens on the `0x02` - /// commands are constants observed in the capture. - /// UNVERIFIED (capture-inferred): whether those tokens are session-stable across reconnects. - func startupSequence(date: Date = Date()) -> [[UInt8]] { - var seq: [[UInt8]] = [] - seq.append(setTime(date)) - seq.append(logical("02", "01", "4746")) // device info - seq.append(logical("02", "1b", "")) // (post-info probe) - seq.append(logical("02", "00", "4743")) // status (battery lives here) - seq.append(logical("02", "07", "4346")) - seq.append(logical("02", "03", "4750")) - // Calibration/preference register reads — harmless, mirror the app's connect probe. - for cmd in ["02", "04", "06", "08", "09", "33"] { seq.append(logical("05", cmd, "")) } - // Config writes the app pushes every connect (exact bytes; meanings not independently decoded). - seq.append(logical("01", "12", "00")) - seq.append(logical("01", "04", "010100010000")) - seq.append(logical("01", "0c", "013c")) - seq.append(logical("01", "09", "003132")) - seq.append(logical("01", "03", "aa40002b")) - seq.append(logical("04", "0e", "00")) // bond nudge - seq.append(contentsOf: enableAllMonitoring()) - seq.append(enableLiveStatus()) - return seq - } - - /// Enable the ring's **live status auto-push** (`03 09 01 00 02`). Once sent, the ring streams - /// `06 00` status frames (current step count / distance / calories) on be940003 continuously while - /// connected — verified in the captures, where the first `06 00` appears immediately after this - /// command and then repeats for the rest of the session. Without it the app only sees the one-time - /// history dump, so today's live step count never updates. (`03 09 00 00 02` disables it.) - func enableLiveStatus() -> [UInt8] { logical("03", "09", "010002") } - - /// Enable the ring's all-day background monitoring for the extra metrics. Each `05 02` - /// write turns one metric's auto-sampling on (`0x02` = enable, the Colmi-family pref-write - /// convention); the second capture showed the SmartHealth app send exactly this burst when the - /// user toggled HRV/stress/temperature/SpO₂/sleep monitoring on, and the `05 09` config read-back - /// then reported the enabled state. Sending them on connect makes the ring *record* these metrics - /// so a later history dump has something to return (notably: sleep, once worn overnight). - /// UNVERIFIED (capture-inferred): the exact metric↔cmd mapping. We enable the whole observed set so - /// nothing is missed; each is an idempotent, individually-acked pref-write. - func enableAllMonitoring() -> [[UInt8]] { - ["40", "42", "43", "44", "4e"].map { logical("05", $0, "02") } - } - - // MARK: - History dump - - /// Begin the history dump. `02 24` + `f0` (header marker); records then stream on be940003. - func historyStart() -> [UInt8] { logical("02", "24", "f0") } - /// Request the next history page. `02 26` (no payload). - func historyPage() -> [UInt8] { [TK5FrameType.device, TK5Command.historyPage] } - /// Acknowledge / finish the history dump. `02 28` + token. - func historyAck() -> [UInt8] { logical("02", "28", "4746") } - - // MARK: - Live actions - - /// Live measurement start/stop via `03 2f` with a `[enable:1][mode:1]` payload. The **mode byte - /// selects the sensor**, verified against the capture: mode 0x00 = heart rate (green LED) → `06 01` - /// stream, 0x02 = SpO₂ (red/IR LED) → `06 02`, 0x0a = HRV → `06 03`. Stop is mode-agnostic - /// (`00 00`). Using the wrong mode lights the wrong LED and yields no reading, so each metric must - /// use its own start. - func heartRateStart() -> [UInt8] { logical("03", "2f", "0100") } - func spo2Start() -> [UInt8] { logical("03", "2f", "0102") } - func hrvStart() -> [UInt8] { logical("03", "2f", "010a") } - func liveStreamStop() -> [UInt8] { logical("03", "2f", "0000") } - - /// Find-device / bond nudge (`04 0e`). Best-effort; the TK5 has no confirmed vibrate command in - /// the capture, so this is the closest observed "poke the ring" frame. - func findDevice() -> [UInt8] { logical("04", "0e", "00") } - - // MARK: - Helpers - - /// Assemble a logical command from hex strings for `type`, `cmd`, and `payload`. - private func logical(_ type: String, _ cmd: String, _ payload: String) -> [UInt8] { - var out: [UInt8] = [UInt8(type, radix: 16) ?? 0, UInt8(cmd, radix: 16) ?? 0] - var i = payload.startIndex - while i < payload.endIndex { - let next = payload.index(i, offsetBy: 2) - if let b = UInt8(payload[i..= 6 else { return nil } - let declared = Int(bytes[2]) | (Int(bytes[3]) << 8) - guard declared == bytes.count else { return nil } - let crcGiven = UInt16(bytes[bytes.count - 2]) | (UInt16(bytes[bytes.count - 1]) << 8) - guard TK5Frame.crc16(bytes[0..<(bytes.count - 2)]) == crcGiven else { return nil } - self.type = bytes[0] - self.cmd = bytes[1] - self.payload = Array(bytes[4..<(bytes.count - 2)]) - } - - /// Build a framed packet from a logical command `[type, cmd, payload…]`: insert the total-length - /// field after the two header bytes and append the little-endian CRC16. - static func frame(_ logical: [UInt8]) -> Data { - guard logical.count >= 2 else { return Data(logical) } - let total = logical.count + 4 // + 2-byte length field + 2-byte CRC - var out: [UInt8] = [logical[0], logical[1], UInt8(total & 0xff), UInt8((total >> 8) & 0xff)] - out.append(contentsOf: logical[2...]) - let crc = crc16(out[0..> 8) & 0xff)) - return Data(out) - } - - /// CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no input/output reflection, no final xor). - static func crc16(_ bytes: S) -> UInt16 where S.Element == UInt8 { - var crc: UInt16 = 0xFFFF - for b in bytes { - crc ^= UInt16(b) << 8 - for _ in 0..<8 { - crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : (crc << 1) - } - } - return crc - } -} - -/// Little-endian + epoch helpers. TK5 timestamps are **seconds since 2000-01-01 UTC**, not the Unix -/// epoch (confirmed against the capture's wall-clock time). -enum TK5Bytes { - /// Seconds between 1970-01-01 and 2000-01-01 (the TK5 epoch offset). - static let epochOffset: TimeInterval = 946_684_800 - - static func u16(_ b: [UInt8], _ i: Int) -> Int { - guard b.count >= i + 2 else { return 0 } - return Int(b[i]) | (Int(b[i + 1]) << 8) - } - - static func u32(_ b: [UInt8], _ i: Int) -> Int { - guard b.count >= i + 4 else { return 0 } - return Int(b[i]) | (Int(b[i + 1]) << 8) | (Int(b[i + 2]) << 16) | (Int(b[i + 3]) << 24) - } - - /// Convert a ring timestamp (2000-epoch seconds) to a `Date`. The ring has no timezone concept — - /// its clock is set from local wall-clock fields (see `TK5Encoder.setTime`) and ticks in local - /// time, so decoding must un-apply the device's UTC offset to recover the true absolute instant. - /// Without this, `Calendar.current` re-applies that same offset when a caller later extracts - /// local components (e.g. `Calendar.wakingDay(forSleepStart:)`'s hour check), doubling it instead - /// of cancelling it. Uses the *current* offset as an approximation of the offset in effect when - /// the timestamp was recorded — correct for same-session syncs, only wrong across a DST - /// transition that happens between recording and decoding. - static func date(_ ringSeconds: Int, timeZone: TimeZone = .current) -> Date { - let offset = TimeInterval(timeZone.secondsFromGMT()) - return Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset - offset) - } - - /// Convert a `Date` to ring seconds (2000-epoch), the inverse of `date(_:timeZone:)`. - static func ringSeconds(_ date: Date, timeZone: TimeZone = .current) -> Int { - let offset = TimeInterval(timeZone.secondsFromGMT(for: date)) - return Int(date.timeIntervalSince1970 - epochOffset + offset) - } -} diff --git a/PulseLoop/RingProtocol/TK5SyncEngine.swift b/PulseLoop/RingProtocol/TK5SyncEngine.swift deleted file mode 100644 index 1096fb05..00000000 --- a/PulseLoop/RingProtocol/TK5SyncEngine.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation - -/// TK5 sync engine. Connect is a faithful replay of the SmartHealth app's handshake, followed by an -/// on-connect history dump. Like the jring it's largely fire-and-forget; the one response-driven flow -/// is history paging, which mirrors the Colmi engine's watchdog pattern (page on each record, finish -/// when the record stream goes quiet). -/// -/// The TK5 has no confirmed on-device profile / goal / measurement-interval commands in the capture, -/// so those `RingSyncEngine` hooks are intentional no-ops (defaults inherited from the protocol -/// extension). Live HR **and** SpO₂ share one proprietary stream toggled by `03 2f`. -@MainActor -final class TK5SyncEngine: RingSyncEngine { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - - private weak var writer: RingCommandWriter? - private let encoder = TK5Encoder() - - init(writer: RingCommandWriter?) { - self.writer = writer - } - - // MARK: Startup - - func runStartup() { - for command in encoder.startupSequence() { - writer?.enqueue(Data(command)) - } - startHistorySync() - } - - // MARK: History dump (watchdog-driven paging) - - private var historyActive = false - private var historyWatchdog: Task? - /// Quiet period after the last inbound record before we call the dump complete. Matches the Colmi - /// engine's per-stage timeout: a 4s window let a slow multi-frame sleep transfer time out and get - /// acked away mid-record. - private let historyQuietTimeout: UInt64 = 10_000_000_000 - - private func startHistorySync() { - historyActive = true - // Zero the ring-history activity days we're about to re-sum, so re-syncs stay idempotent - // (same guard the Colmi engine uses before summing history buckets). - Task { await PulseEventBus.shared.publish(.activitySyncReset(sinceDaysAgo: 7)) } - writer?.enqueue(Data(encoder.historyStart())) - armHistoryWatchdog() - } - - private func armHistoryWatchdog() { - historyWatchdog?.cancel() - let timeout = historyQuietTimeout - historyWatchdog = Task { [weak self] in - try? await Task.sleep(nanoseconds: timeout) - guard !Task.isCancelled, let self, self.historyActive else { return } - self.finishHistorySync() - } - } - - private func finishHistorySync() { - historyActive = false - historyWatchdog?.cancel() - historyWatchdog = nil - writer?.enqueue(Data(encoder.historyAck())) - Task { await PulseEventBus.shared.publish(.syncProgress(stage: "done")) } - } - - func handle(_ event: RingDecodedEvent) { - // While a dump is in flight, each history record pulls the next page and resets the quiet timer. - guard historyActive else { return } - switch event { - // Every record a history frame can decode to. `.sleepTimeline` and `.bloodPressureSample` used - // to fall through, so a night of sleep or a BP-only page never asked for the next page. - // Deliberately *not* `.activityUpdate`: the ring's continuous live `06 00` status decodes to - // that too, and treating it as history would keep the watchdog alive forever, so the dump - // would never finish and `historyAck` would never be sent. - case .historyMeasurement, .activityBucket, .bloodPressureSample, .sleepTimeline: - writer?.enqueue(Data(encoder.historyPage())) - armHistoryWatchdog() - case .historySyncProgress: - // `TK5Driver` announces every history frame with this, including a sleep continuation that - // decodes to nothing and an all-unworn page that decodes to no records. Keep the dump - // alive, but don't ask for the next page — the ring may still be streaming this one. - armHistoryWatchdog() - default: - break - } - } - - // MARK: Live actions (proprietary 06-stream on be940003, mode-selected by 03 2f) - // - // The `03 2f` payload is [enable, mode]; the mode byte picks the sensor/LED (HR 0x00 → 06 01, - // SpO₂ 0x02 → 06 02, HRV 0x0a → 06 03). Each metric must start its own mode — the shared stop is - // mode-agnostic. Only one mode runs at a time, so start-then-stop per measurement is correct. - - func startHeartRate() { - writer?.enqueue(Data(encoder.heartRateStart())) - } - - func stopHeartRate() { - writer?.enqueue(Data(encoder.liveStreamStop())) - } - - func startSpO2() { - writer?.enqueue(Data(encoder.spo2Start())) - } - - func stopSpO2() { - writer?.enqueue(Data(encoder.liveStreamStop())) - } - - func startHRV() { - writer?.enqueue(Data(encoder.hrvStart())) - } - - func stopHRV() { - writer?.enqueue(Data(encoder.liveStreamStop())) - } - - func findDevice() { - writer?.enqueue(Data(encoder.findDevice())) - } - - func setGoal(steps: Int) { - // No confirmed goal-write command in the capture; PulseLoop persists the goal regardless. - } -} diff --git a/PulseLoop/RingProtocol/YCBTDecoder.swift b/PulseLoop/RingProtocol/YCBTDecoder.swift new file mode 100644 index 00000000..580eee5e --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTDecoder.swift @@ -0,0 +1,247 @@ +import Foundation +import os.log + +private let ycbtLog = Logger(subsystem: "xyz.sakshambhutani.pulseloop2", category: "YCBTDecoder") + +/// Decodes inbound YCBT frames into the shared `RingDecodedEvent`. Frames arrive already reassembled +/// (`YCBTFrameAssembler`) and CRC-validated (`YCBTFrame`), from either the command channel (be940001) +/// or the async stream (be940003); this decoder dispatches on `(type, cmd)` regardless of channel. +/// +/// **Health-group (`0x05`) frames never reach here** — the driver routes them into +/// `YCBTHistoryTransfer`, which reassembles a whole transfer before `YCBTHealthRecords` cuts it into +/// records. History records are packed back-to-back and chopped at arbitrary frame boundaries, so +/// decoding one per-frame loses every record that straddles two (see `docs/YCBT-Protocol.md` §4). +/// +/// Everything routes through `RingEventBridge`, which range-gates each metric, so a misdecoded byte is +/// dropped rather than persisted as garbage. +struct YCBTDecoder { + /// Decode one validated frame into the events it carries. Dispatch is on the **group** first, exactly + /// as `YCBTClientImpl.bleDataResponse` does — the key byte only means anything within its group + /// (`0x00` is GetDeviceInfo in group 2, the live-status stream in group 6, and find-phone in group 4). + func decode(_ frame: YCBTFrame, now: Date = Date()) -> [RingDecodedEvent] { + switch frame.type { + case YCBTGroup.real: + return decodeRealStream(frame, now: now) + + // Auto-ACKed by `YCBTDriver` *before* this decode runs — the ring retransmits until it is. + case YCBTGroup.devControl: + return decodeDevControlPush(frame.cmd, payload: frame.payload, now: now) + + case YCBTGroup.get: + return decodeGetReply(frame) + + case YCBTGroup.setting where frame.cmd == YCBTSettingKey.setTime: + return [.timeSyncAck(timestamp: now)] + + default: + return [.commandAck(commandId: frame.cmd)] + } + } + + // MARK: - Async live stream (be940003, group 0x06) + + private func decodeRealStream(_ frame: YCBTFrame, now: Date) -> [RingDecodedEvent] { + let p = frame.payload + switch frame.cmd { + case YCBTCommand.liveStatus: + // Cumulative day totals. steps verified against capture (0x027b = 635); distance/calories + // are the adjacent u16s — UNVERIFIED (capture-inferred), but `applyActivityUpdate` uses + // max() so an over-read can't corrupt the day. + guard p.count >= 2 else { return [.commandAck(commandId: frame.cmd)] } + return [.activityUpdate(timestamp: now, steps: YCBTBytes.u16(p, 0), + distanceMeters: Double(YCBTBytes.u16(p, 2)), + calories: Double(YCBTBytes.u16(p, 4)))] + + case YCBTCommand.liveHeartRate: + // 1-byte live bpm. Verified (climbed 82→86 across the capture). + guard let bpm = p.first else { return [.commandAck(commandId: frame.cmd)] } + return [.heartRateSample(bpm: Int(bpm), timestamp: now)] + + case YCBTCommand.liveSpo2: + // 1-byte live SpO₂ % from the mode-0x02 (red-LED) stream; values sat in 95–98 in the + // capture. Gate to a plausible range so a warm-up 0 isn't surfaced as a reading — `.spo2Result` + // is the one metric `RingEventBridge` does not range-gate, so the decoder must. + guard let spo2 = p.first, RingEventBridge.spo2Range.contains(Int(spo2)) else { + return [.commandAck(commandId: frame.cmd)] + } + return [.spo2Result(value: Int(spo2), timestamp: now)] + + case YCBTCommand.liveVitals: + return decodeLiveVitals(p, cmd: frame.cmd, now: now) + + case YCBTCommand.liveBattery: + // `06 15` battery push (`unpackUploadBatteryLevel`): `[chargingStatus][percent]`. The ring + // sends it unprompted on charge/level changes, so battery stays fresh without polling `02 00`. + // The charging flag has no home in `RingDecodedEvent` and is dropped. + guard p.count >= 2 else { return [.commandAck(commandId: frame.cmd)] } + return [.battery(percent: Int(p[1]))] + + case YCBTCommand.liveWearingStatus: + // `06 13` (`unpackWearingStatusData`): `[ts:u32 2000-epoch][status]`. **UNVERIFIED polarity** — + // the SDK and the app both forward `status` without ever asserting which value means "worn", + // so nonzero is taken as worn. Harmless: this event produces no `PulseEvent` (debug feed only). + guard p.count >= 5 else { return [.commandAck(commandId: frame.cmd)] } + return [.wearingStatus(worn: p[4] != 0, timestamp: YCBTBytes.date(YCBTBytes.u32(p, 0)))] + + default: + return [.commandAck(commandId: frame.cmd)] + } + } + + // MARK: - Command channel (be940001, group 0x02) + + private func decodeGetReply(_ frame: YCBTFrame) -> [RingDecodedEvent] { + switch frame.cmd { + case YCBTCommand.getDeviceInfo: + return decodeDeviceInfo(frame.payload) + case YCBTCommand.getSupportFunction: + return decodeSupportFunction(frame.payload) + case YCBTCommand.getChipScheme: + return decodeChipScheme(frame.payload) + default: + return [.commandAck(commandId: frame.cmd)] + } + } + + /// `02 00` GetDeviceInfo reply (`DataUnpack.unpackDeviceInfoData`): deviceId u16 @0, firmware + /// sub-version @2 and main-version @3 (displayed "main.sub"), battery **state** @4 and battery + /// **percent** @5. Battery is in-band on this reply — the ring exposes no standard battery service. + /// + /// The sub-version is **zero-padded to two digits** (`i4 < 10 ? i5 + ".0" + i4 : i5 + "." + i4`), so + /// main 1 / sub 5 is the "1.05" the vendor's release notes name — not "1.5", which the user would + /// read as a different firmware than the one they are on. + private func decodeDeviceInfo(_ p: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [.status(address: nil)] + if p.count >= 4 { events.append(.firmware(version: String(format: "%d.%02d", p[3], p[2]))) } + if p.count >= 6 { events.append(.battery(percent: Int(p[5]))) } + return events + } + + /// `02 01` GetSupportFunction reply — the firmware's own capability bitmap, and the only thing that + /// can tell two SKUs of one ring family apart at runtime. `RingBLEClient` folds it into the active + /// capability set; the log line stays because the raw byte count is the first thing to check when a + /// ring under-reports (an old firmware just sends a shorter array). + private func decodeSupportFunction(_ p: [UInt8]) -> [RingDecodedEvent] { + let caps = YCBTSupportFunction.capabilities(from: p) + ycbtLog.info("SupportFunction bitmap (\(p.count, privacy: .public) B) → \(caps.csv, privacy: .public)") + return [.supportFunctions(caps)] + } + + /// `02 1b` GetChipScheme reply — the chipset/OTA family. Nothing branches on it (PulseLoop does no + /// firmware updates); it is decoded so the debug feed shows *which* OTA family a ring belongs to and + /// whether JieLi RCSP — the AE00 auth we deliberately don't implement — is in play at all. + private func decodeChipScheme(_ p: [UInt8]) -> [RingDecodedEvent] { + let scheme = YCBTChipScheme.value(from: p) + ycbtLog.info("chipScheme \(scheme, privacy: .public) (JieLi: \(YCBTChipScheme.isJieLi(scheme), privacy: .public))") + return [.chipScheme(value: scheme)] + } + + /// `06 03` — the SDK's `Real_UploadBlood` frame (`DataUnpack.unpackRealBloodData`), and the live feed + /// for **both** the BP and the HRV spot measurements (SmartHealth's BP screen and HRV screen each + /// subscribe to this one dataType, reading `bloodSBP`/`bloodDBP` and `hrv` respectively): + /// + /// `[SBP@0][DBP@1][hr@2]` then, if the payload is long enough, `[hrv@3][spo2@4][tempInt@5][tempFrac@6]` + /// + /// There are **not** two frame shapes here to disambiguate: the offsets are fixed, and the mode just + /// decides which of them the ring fills (BP mode fills @0/@1 and zeroes @3; HRV mode the reverse). + /// So each field is emitted iff it carries a value — which also recovers the HR that the BP sweep + /// measures, and which a shape heuristic would throw away. + /// + /// Temperature is `int` and `frac` **string-concatenated**, not `int + frac/100` as the realtime + /// report claimed: SmartHealth's temperature screen does `Double.parseDouble(int + "." + frac)` on + /// the same field pair (see `YCBTHealthRecords.composite`). Ranges belong to `RingEventBridge`, except + /// SpO₂ — the one metric it doesn't gate — so a warm-up 0 is dropped here. + private func decodeLiveVitals(_ p: [UInt8], cmd: UInt8, now: Date) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + if p.count >= 2, p[0] > 0, p[1] > 0 { + events.append(.bloodPressureSample(systolic: Int(p[0]), diastolic: Int(p[1]), timestamp: now)) + } + if p.count >= 3, p[2] > 0 { + events.append(.heartRateSample(bpm: Int(p[2]), timestamp: now)) + } + if p.count >= 4, p[3] > 0 { + events.append(.hrvSample(value: Int(p[3]), timestamp: now)) + } + if p.count >= 5, RingEventBridge.spo2Range.contains(Int(p[4])) { + events.append(.spo2Result(value: Int(p[4]), timestamp: now)) + } + if p.count >= 7, p[5] > 0 { + events.append(.temperatureSample(celsius: YCBTHealthRecords.composite(p[5], p[6]), timestamp: now)) + } + return events.isEmpty ? [.commandAck(commandId: cmd)] : events + } + + /// The ring's DevControl pushes. Only the measurement ones carry data PulseLoop has a home for. + /// + /// Everything else in the catalog — find-phone (`0x00`), SOS (`0x05` / `0x17`), sedentary reminder + /// (`0x16`), the camera / music / call remotes — has **no product surface in PulseLoop**: it is not a + /// phone-notification mirror and has no SOS flow. Those frames are ACKed (in the driver, so the ring + /// stops retransmitting) and surfaced as a plain `.commandAck`, which still puts them in the debug + /// packet feed. Giving any of them a behaviour is a product decision, not a protocol one. + private func decodeDevControlPush(_ cmd: UInt8, payload: [UInt8], now: Date) -> [RingDecodedEvent] { + switch cmd { + case YCBTDevControl.measurementStatus: + let events = measurementStatusEvents(payload, now: now) + return events.isEmpty ? [.commandAck(commandId: cmd)] : events + + case YCBTDevControl.measurementResult: + // `04 0e` (`unpackParseData` 1038): `[measureType][result]`, **no value**. SmartHealth reacts + // to a success by re-reading history — which is where the reading actually lands, and which + // PulseLoop's periodic re-sync already does. Log it: it is the only signal that says *why* a + // spot measurement produced nothing (2 = failed, else cancelled). + if payload.count >= 2 { + ycbtLog.info("MeasurementResult: mode \(payload[0], privacy: .public) → \(payload[1], privacy: .public)") + } + return [.commandAck(commandId: cmd)] + + default: + return [.commandAck(commandId: cmd)] + } + } + + /// `04 13` MeasurStatusAndResults (dataType 1043) — the ring's live "measurement in progress / done" + /// push, the counterpart of the `03 2f` start we sent: `[type@0][state@1]` then that type's value(s). + /// `type` is the same mode byte we started with (`YCBTMeasurementMode`). `state` is a progress/abort + /// code that the SDK forwards without ever naming — so we don't invent an enum for it either; a frame + /// with no usable value simply acks. + /// + /// The SDK gates the whole parse on `payload.count >= 24` (the ring pads the frame). We gate per type + /// on the bytes it actually reads instead: the offsets are identical either way, and a shorter frame + /// from a different firmware would otherwise silently drop a real reading. + private func measurementStatusEvents(_ p: [UInt8], now: Date) -> [RingDecodedEvent] { + guard p.count >= 3 else { return [] } + let value = p[2] + let fraction = p.count >= 4 ? p[3] : 0 + + switch p[0] { + case YCBTMeasurementMode.heartRate: + return value > 0 ? [.heartRateSample(bpm: Int(value), timestamp: now)] : [] + + case YCBTMeasurementMode.bloodPressure: + guard value > 0, fraction > 0 else { return [] } + return [.bloodPressureSample(systolic: Int(value), diastolic: Int(fraction), timestamp: now)] + + case YCBTMeasurementMode.spo2: + guard RingEventBridge.spo2Range.contains(Int(value)) else { return [] } + return [.spo2Result(value: Int(value), timestamp: now)] + + case YCBTMeasurementMode.temperature: + guard value > 0 else { return [] } + return [.temperatureSample(celsius: YCBTHealthRecords.composite(value, fraction), timestamp: now)] + + case YCBTMeasurementMode.bloodSugar: + // Tenths of mmol/L, as everywhere else in this SDK (`int * 10 + frac`) — see + // `YCBTHealthRecords.bloodSugarMgdl`. UNVERIFIED scale, gated by the bridge's mg/dL range. + let tenths = Int(value) * 10 + Int(fraction) + guard tenths > 0 else { return [] } + return [.bloodSugarSample(mgdl: YCBTHealthRecords.bloodSugarMgdl(tenthsOfMmol: tenths), timestamp: now)] + + default: + // Respiratory rate (3), uric acid (6), ketone (7), blood fat (9): no live `RingDecodedEvent` + // exists for any of them, and PulseLoop can't start those measurements in the first place — + // only a ring-initiated one could land here. Respiratory rate does reach SwiftData, but via the + // history record, so nothing is lost by not fabricating a live event stamped `now`. + return [] + } + } +} diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift new file mode 100644 index 00000000..06867307 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -0,0 +1,117 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// YCBT driver. Owns the length-prefixed CRC16 framing and the split-channel topology: the command +/// characteristic `be940001` is *both* the write target and a notify source (command replies), while +/// `be940003` carries the async live/history stream. The standard `180D`/`2A37` Heart Rate +/// characteristic is deliberately left unsubscribed — see the BLE-topology note below. +/// +/// Because `be940001` is simultaneously the write and a notify characteristic, `RingBLEClient`'s +/// discovery subscribes any `notifyUUIDs` entry even when it also matches `writeUUID`. +@MainActor +final class YCBTDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let decoder = YCBTDecoder() + /// GATT fragments → whole logical frames. A history data frame regularly exceeds `MTU-3` and is + /// split across notifications, so a frame must never be validated against one notification's length + /// — doing so throws away every fragmented frame as garbage. + private let assembler = YCBTFrameAssembler() + /// The history state machine. The driver owns it because only the driver sees frames (the sync + /// engine sees decoded events), and it is handed to the engine so `runStartup` can seed the queue. + private let transfer: YCBTHistoryTransfer + + init(writer: RingCommandWriter) { + self.writer = writer + self.transfer = YCBTHistoryTransfer(writer: writer) + } + + // MARK: BLE topology + // + // Only the proprietary `be940000` service is used. The standard `180D`/`2A37` Heart Rate + // characteristic is intentionally NOT subscribed: on the TK5 it emits a cached resting HR + // periodically even when the ring is off the finger (observed as a constant ~87 bpm), which would + // override a real on-demand measurement. The official app never subscribes it either — live HR + // comes solely from the proprietary `06 01` stream, which reflects actual finger contact. + let serviceUUIDs: [CBUUID] = [CBUUID(string: YCBTUUIDs.service)] + let writeUUID = CBUUID(string: YCBTUUIDs.command) + let notifyUUIDs: [CBUUID] = [ + CBUUID(string: YCBTUUIDs.command), // command replies (also the write char) + CBUUID(string: YCBTUUIDs.stream), // async live + history stream + ] + let batteryServiceUUID: CBUUID? = nil // battery is in-band (GetDeviceInfo 02 00, payload[5]) + let batteryCharUUID: CBUUID? = nil + + // MARK: Framing + func frame(_ command: Data) -> Data { + // Logical command is `[type, cmd, payload…]`; insert the total-length field and append CRC16. + YCBTFrame.frame([UInt8](command)) + } + + // MARK: Lifecycle + + /// A reconnect re-uses this driver, so a frame left half-assembled when the old link dropped would + /// otherwise be completed with bytes from the new one (and a history transfer would still think it + /// was mid-type). + func connectionDidStart() { + assembler.reset() + transfer.cancel() + } + + /// Cancelling on the next *connect* is too late for the transfer: its stall watchdog is a timer, and + /// a ring that drops out of range mid-dump leaves it running. It would step through the rest of the + /// catalog while we're disconnected — one `05 xx` query every 10 s — into the write queue, and the + /// reconnect would flush those stale queries before the handshake, desyncing the fresh transfer + /// against dumps it never asked for. Killing it at disconnect is the only place that can't race. + func connectionDidEnd() { + assembler.reset() + transfer.cancel() + } + + // MARK: Inbound decode + + /// Every notification goes through the assembler first (one notification can carry several frames, + /// and one frame several notifications). Health-group frames drive the history transfer; DevControl + /// pushes must be acknowledged; everything else is a stateless decode. + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for logical in assembler.append(data, from: characteristic) { + guard let frame = YCBTFrame(validating: logical) else { + events.append(.unknown(commandId: logical.first ?? 0, raw: logical)) + continue + } + switch frame.type { + case YCBTGroup.health: + events.append(contentsOf: transfer.handle(cmd: frame.cmd, payload: frame.payload)) + case YCBTGroup.devControl: + acknowledgePush(frame) + events.append(contentsOf: decoder.decode(frame)) + default: + events.append(contentsOf: decoder.decode(frame)) + } + } + return events + } + + /// The ring **retransmits an unacknowledged DevControl push** until the app answers `04 {00}`, + /// so the ACK goes out before the frame is even decoded — `YCBTClientImpl.packetDevControlHandle` + /// likewise sends `sendData2Device(dataType, {0})` first and parses second, and a slow decode must + /// never be able to stall the ring. + /// + /// Two deliberate divergences from the SDK: + /// • A 1-byte `0xFB…0xFF` payload is an *error* frame, not a push. The SDK drops those for groups 4 + /// and 6 without replying, and ACKing one would be answering a rejection as though it were a push + /// the ring never sent — worse, it could ping-pong with the ring's own error reply. + /// • The SDK only ACKs the keys in its own hard-coded table. We ACK every non-error key: an + /// unrecognised push still needs its retransmissions stopped (that is the whole point of the ACK), + /// and an ACK for a key the ring never pushed is inert. + private func acknowledgePush(_ frame: YCBTFrame) { + guard YCBTFrameError.detect(in: frame.payload) == nil else { return } + writer?.enqueue(Data(YCBTDevControl.ack(key: frame.cmd))) + } + + func makeSyncEngine() -> RingSyncEngine { + YCBTSyncEngine(writer: writer, transfer: transfer) + } +} diff --git a/PulseLoop/RingProtocol/YCBTEncoder.swift b/PulseLoop/RingProtocol/YCBTEncoder.swift new file mode 100644 index 00000000..2eba5ef0 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTEncoder.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Builds *logical* YCBT commands — `[type, cmd, payload…]` without the length field or CRC, which +/// `YCBTDriver.frame(_:)` appends. +/// +/// The connect handshake is **parameterized**, not a captured byte replay: it mirrors the order the +/// SmartHealth app actually runs (`HomeFragment.getCompile` → `syncSettingData`), with every payload +/// built from the SDK's own definitions and the user's real settings. The Setting-group builders live +/// in `YCBTSettingsEncoder`, shared with the other YCBT families. +/// +/// Wire spec, including the exact bytes of every frame here: `docs/YCBT-Protocol.md` §2–3. +struct YCBTEncoder { + private let settings = YCBTSettingsEncoder() + + /// Set the ring clock (`01 00`), including the Mon=0 weekday byte the old encoder hard-coded to 0. + func setTime(_ date: Date = Date(), calendar: Calendar = .current) -> [UInt8] { + settings.setTime(date, calendar: calendar) + } + + /// The connect handshake, in the SmartHealth app's own order: clock → device interrogation → + /// locale → all-day monitors → user profile → live-status stream. + /// + /// **Never add these to it** — each was once here, and each was a different kind of wrong: + /// • **No `05 xx`.** The Health group is the *history* protocol: `YCBTHistoryTransfer` owns those + /// queries and a stray one here would race it. Worse, `05 40…4E` are the Health **Delete** + /// opcodes — they erase the ring's stored log. The commands that actually make the ring *record* + /// between syncs are the five `01 xx {enable, interval}` monitors below. + /// • **No `04 xx`.** Group 4 is DevControl, the *device→app* push channel. The app's only + /// legitimate `04` write is an ACK for a push it received (`YCBTDriver.acknowledgePush`) — the + /// `04 0e 00` seen in a capture was SmartHealth ACKing a `MeasurementResult`, not a handshake + /// step, and replaying it unprompted does nothing. + func startupSequence( + date: Date = Date(), + measurement: MeasurementSettings = .allOnDefault, + profile: UserProfileValues = UserProfileValues(metric: true, sex: nil, age: nil, heightCm: nil, weightKg: nil), + languageCode: UInt8 = 0, + is24Hour: Bool = true + ) -> [[UInt8]] { + var seq: [[UInt8]] = [] + seq.append(setTime(date)) + // Device interrogation. The 2-byte tags are cosmetic (the firmware ignores the payload of a Get) + // but we keep the app's exact bytes: they cost nothing and keep a byte-diff against a capture clean. + seq.append(logical(YCBTGroup.get, YCBTCommand.getDeviceInfo, [0x47, 0x43])) + seq.append(logical(YCBTGroup.get, YCBTCommand.getSupportFunction, [0x47, 0x46])) + seq.append(logical(YCBTGroup.get, YCBTCommand.getChipScheme, [])) + seq.append(logical(YCBTGroup.get, YCBTCommand.getDeviceName, [0x47, 0x50])) + seq.append(logical(YCBTGroup.get, YCBTCommand.getUserConfig, [0x43, 0x46])) + seq.append(settings.language(languageCode)) + seq.append(settings.units(metric: profile.metric, is24Hour: is24Hour)) + seq.append(contentsOf: settings.monitorCommands(measurement)) + seq.append(settings.userInfo(profile)) + seq.append(enableLiveStatus()) + return seq + } + + /// Re-push the all-day monitors without the rest of the handshake (the live "Save" path). + func monitorCommands(_ measurement: MeasurementSettings) -> [[UInt8]] { + settings.monitorCommands(measurement) + } + + /// Push the user's real height/weight/sex/age (`01 03`). + func userInfo(_ profile: UserProfileValues) -> [UInt8] { + settings.userInfo(profile) + } + + /// Read device info (`02 00`) — battery and firmware come back in the reply. + func deviceInfoRequest() -> [UInt8] { + logical(YCBTGroup.get, YCBTCommand.getDeviceInfo, [0x47, 0x43]) + } + + /// Enable the ring's **live status auto-push** (`03 09 01 00 02`). Once sent, the ring streams + /// `06 00` status frames (current step count / distance / calories) on be940003 continuously while + /// connected — verified in the captures, where the first `06 00` appears immediately after this + /// command and then repeats for the rest of the session. Without it the app only sees the one-time + /// history dump, so today's live step count never updates. (`03 09 00 00 02` disables it.) + func enableLiveStatus() -> [UInt8] { + logical(YCBTGroup.appControl, YCBTCommand.liveStatusPush, [0x01, 0x00, 0x02]) + } + + // MARK: - Health history + + /// Ask for one history type: `05 `, empty payload. The reply is a header → data frames → + /// terminal block, all driven by `YCBTHistoryTransfer`. + func healthHistoryRequest(_ type: YCBTHistoryType) -> [UInt8] { + YCBTHealthCommand.historyRequest(type) + } + + /// The mandatory end-of-transfer ACK: `05 80 {00}` accepted / `{04}` CRC failure. The ring does not + /// release the next type until it arrives. + func historyBlockAck(status: UInt8) -> [UInt8] { + YCBTHealthCommand.historyBlockAck(status: status) + } + + // MARK: - Live actions + + /// Live measurement start/stop via `03 2f` with a `[enable:1][mode:1]` payload. The **mode byte + /// selects the sensor**: 0x00 heart rate (green LED) → `06 01` stream, 0x01 blood pressure → `06 03`, + /// 0x02 SpO₂ (red/IR LED) → `06 02`, 0x0a HRV → `06 03`. Using the wrong mode lights the wrong LED + /// and yields no reading, so each metric must use its own start. + /// + /// **The stop echoes its own mode** — it is not mode-agnostic. Every SmartHealth measure screen calls + /// `appStartMeasurement(enable, type)` with its own type in *both* directions + /// (`BaseMeasureActivity.playStopMeasure`), so the `03 2f 00 00` stop seen in a capture was simply + /// the HR screen's stop with mode 0, not a wildcard. Stopping an SpO₂ sweep with mode 0 tells the + /// ring to stop *heart rate*, leaving the SpO₂ sweep running. + func heartRateStart() -> [UInt8] { liveMeasurement(enable: true, mode: YCBTMeasurementMode.heartRate) } + func heartRateStop() -> [UInt8] { liveMeasurement(enable: false, mode: YCBTMeasurementMode.heartRate) } + func spo2Start() -> [UInt8] { liveMeasurement(enable: true, mode: YCBTMeasurementMode.spo2) } + func spo2Stop() -> [UInt8] { liveMeasurement(enable: false, mode: YCBTMeasurementMode.spo2) } + func hrvStart() -> [UInt8] { liveMeasurement(enable: true, mode: YCBTMeasurementMode.hrv) } + func hrvStop() -> [UInt8] { liveMeasurement(enable: false, mode: YCBTMeasurementMode.hrv) } + func bloodPressureStart() -> [UInt8] { liveMeasurement(enable: true, mode: YCBTMeasurementMode.bloodPressure) } + func bloodPressureStop() -> [UInt8] { liveMeasurement(enable: false, mode: YCBTMeasurementMode.bloodPressure) } + + /// Find device — make the ring buzz (`03 00`, `CMD.KEY_AppControl.FindDevice == 0`), with the exact + /// three payload bytes SmartHealth's own "find ring" button sends (`appFindDevice(1, 5, 2)`). + /// + /// **UNVERIFIED:** the SDK never names those three arguments, so replaying the app's literal values + /// is the only way to be sure of the ring's response — hence they are not parameterized. If the ring + /// doesn't buzz on the hardware checkpoint, this is the first line to look at. + func findDevice() -> [UInt8] { + logical(YCBTGroup.appControl, YCBTCommand.findDevice, [0x01, 0x05, 0x02]) + } + + // MARK: - Helpers + + private func liveMeasurement(enable: Bool, mode: UInt8) -> [UInt8] { + logical(YCBTGroup.appControl, YCBTCommand.liveMeasurement, [enable ? 0x01 : 0x00, mode]) + } + + private func logical(_ group: UInt8, _ cmd: UInt8, _ payload: [UInt8]) -> [UInt8] { + [group, cmd] + payload + } +} diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift new file mode 100644 index 00000000..3beb7d4c --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -0,0 +1,362 @@ +import Foundation + +/// Pure buffer→events decoders for the YCBT health-history record types. Layouts and strides are +/// specified in `docs/YCBT-Protocol.md` §4.4, from `DataUnpack.unpackHealthData`. +/// +/// **These run over the fully reassembled transfer buffer, never over a single frame.** The ring +/// concatenates fixed-size records and then chops the stream at frame boundaries wherever they happen +/// to fall, so a record routinely straddles two data frames. Slicing per-frame silently drops the +/// straddling record and misaligns everything after it — `DataUnpack.unpackHealthData` is likewise +/// handed one buffer, not one frame. +/// +/// **Layering:** a decoder here only drops the ring's *"no sample"* fillers (a zero value in a slot the +/// firmware never leaves blank when it has a reading). Plausibility ranges live in exactly one place, +/// `RingEventBridge` — duplicating them here would let the two drift apart. +/// +/// Timestamps go through `YCBTBytes`: 2000-epoch +/// seconds in the device's local wall clock. +enum YCBTHealthRecords { + /// Decode a completed transfer. The stride each buffer is sliced at comes from the same + /// `YCBTHistoryType` table the transfer machine drives the queue from, so the two cannot disagree. + static func decode(_ buffer: [UInt8], type: YCBTHistoryType) -> [RingDecodedEvent] { + switch type { + case .sport: return sport(buffer) + case .sleep: return sleep(buffer) + case .heart: return heartRate(buffer) + case .blood: return bloodPressure(buffer) + case .all: return combinedVitals(buffer) + case .spo2: return spo2(buffer) + case .temperature: return temperature(buffer) + case .comprehensive: return comprehensive(buffer) + case .bodyData: return bodyData(buffer) + default: return [] // a type outside the catalog (a family added a decoder-less key) + } + } + + // MARK: - Sport (query 0x02, 14-byte records) + + /// `[start:u32][end:u32][steps:u16@8][distanceMeters:u16@10][calories:u16@12]` (`DataUnpack` case 2). + /// + /// These are **interval** buckets (each covers start→end), not a running total, so they ride + /// `.activityBucket`: upsert by start epoch, day total = sum of distinct buckets — idempotent across + /// re-syncs even though we never delete records from the ring. The All record's step field is the + /// opposite (a cumulative daily counter → `.activityUpdate`, a per-day `max` ratchet); the queue asks + /// for sport *before* all, so the cumulative counter always has the last word on a day's total. + /// + /// Calories are deliberately dropped: `.activityBucket` has no calorie channel (the ring's estimate + /// is unverified, and the live status stream already feeds the day's calorie ratchet). + static func sport(_ buffer: [UInt8]) -> [RingDecodedEvent] { + records(in: buffer, size: 14).compactMap { r in + let steps = YCBTBytes.u16(r, 8) + let distance = YCBTBytes.u16(r, 10) + guard steps > 0 || distance > 0 else { return nil } + return .activityBucket(timestamp: YCBTBytes.date(YCBTBytes.u32(r, 0)), + steps: steps, distanceMeters: Double(distance)) + } + } + + // MARK: - Heart rate (query 0x06, 6-byte records) + + /// `[ts:u32][mode:1][hr:1]` (`DataUnpack` case 6). `hr == 0` is an unworn sample, not a reading. + static func heartRate(_ buffer: [UInt8]) -> [RingDecodedEvent] { + records(in: buffer, size: 6).compactMap { r in + let hr = r[5] + guard hr > 0 else { return nil } + return .historyMeasurement(kind: .heartRate, value: Double(hr), + timestamp: YCBTBytes.date(YCBTBytes.u32(r, 0))) + } + } + + // MARK: - Blood pressure (query 0x08, 8-byte records) + + /// `[ts:u32][isInflated@4][systolic@5][diastolic@6][heartRate@7]` (`DataUnpack` case 8). + /// + /// Emitted as two `.historyMeasurement`s rather than a `.bloodPressureSample`: history rows **upsert** + /// on (kind, timestamp) while live samples **append**, and the ring replays its whole log on every + /// sync (we never send the Health-Delete opcodes). A `.bloodPressureSample` here would therefore + /// duplicate every stored reading on every re-sync. Live/spot BP keeps `.bloodPressureSample`. + /// + /// `isInflated` flags the ring's own cuff-style sweep; it doesn't gate validity, so it isn't read. + static func bloodPressure(_ buffer: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for r in records(in: buffer, size: 8) { + let ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + events.append(contentsOf: bloodPressureEvents(systolic: r[5], diastolic: r[6], timestamp: ts)) + if r[7] > 0 { + events.append(.historyMeasurement(kind: .heartRate, value: Double(r[7]), timestamp: ts)) + } + } + return events + } + + // MARK: - Combined vitals (query 0x09, 20-byte records) + + /// The ring's per-interval "All" record (`DataUnpack` case 9): + /// `[ts:u32][steps:u16@4][hr@6][sys@7][dia@8][spo2@9][resp@10][hrv@11][cvrr@12][tempInt@13]` + /// `[tempFrac@14][bodyFatInt@15][bodyFatFrac@16][bloodSugar@17]`. + /// + /// HR at @6 is deliberately not emitted: the paired heart-rate history carries the same samples at + /// the same epochs. Body fat at @15–16 has no `MeasurementKind` and is skipped. cvrr @12 likewise. + /// + /// Steps are a **cumulative daily counter** (rises through the day, resets at midnight), so they go + /// out as an `.activityUpdate` — a per-day `max()` ratchet — not an additive bucket. Distance and + /// calories are zeroed so that `max()` leaves any live-status values intact. + static func combinedVitals(_ buffer: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for r in records(in: buffer, size: 20) { + let ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + events.append(.activityUpdate(timestamp: ts, steps: YCBTBytes.u16(r, 4), + distanceMeters: 0, calories: 0)) + events.append(contentsOf: bloodPressureEvents(systolic: r[7], diastolic: r[8], timestamp: ts)) + if r[9] > 0 { + events.append(.historyMeasurement(kind: .spo2, value: Double(r[9]), timestamp: ts)) + } + if r[10] > 0 { + events.append(.historyMeasurement(kind: .respiratoryRate, value: Double(r[10]), timestamp: ts)) + } + if r[11] > 0 { + events.append(.historyMeasurement(kind: .hrv, value: Double(r[11]), timestamp: ts)) + } + events.append(contentsOf: temperatureEvents(integer: r[13], fraction: r[14], timestamp: ts)) + if r[17] > 0 { + events.append(.historyMeasurement(kind: .bloodSugar, + value: bloodSugarMgdl(tenthsOfMmol: Int(r[17])), timestamp: ts)) + } + } + return events + } + + // MARK: - SpO₂ (query 0x1A, 6-byte records) + + /// `[ts:u32][type@4][value@5]` (`DataUnpack` case 26). `type` distinguishes the ring's automatic + /// all-day sampling from a user-triggered spot reading; PulseLoop stores both the same way. + static func spo2(_ buffer: [UInt8]) -> [RingDecodedEvent] { + records(in: buffer, size: 6).compactMap { r in + guard r[5] > 0 else { return nil } + return .historyMeasurement(kind: .spo2, value: Double(r[5]), + timestamp: YCBTBytes.date(YCBTBytes.u32(r, 0))) + } + } + + // MARK: - Temperature (query 0x1E, 7-byte records) + + /// `[ts:u32][type@4][int@5][frac@6]` (`DataUnpack` case 30 — which advances **7** bytes per record + /// even though its bounds check only demands 5). The value is `int` and `frac` *string-concatenated* + /// (see `composite`), so it is °C. + static func temperature(_ buffer: [UInt8]) -> [RingDecodedEvent] { + records(in: buffer, size: 7).flatMap { r in + temperatureEvents(integer: r[5], fraction: r[6], timestamp: YCBTBytes.date(YCBTBytes.u32(r, 0))) + } + } + + // MARK: - Comprehensive (query 0x2F, 44-byte records) + + /// The ring's "lab panel" sweep (`DataUnpack` case 47). Only blood sugar is decoded — + /// `[ts:u32][bloodSugarModel@4][int@5][frac@6]`. Uric acid (@7–9), ketones (@10–12) and the four + /// lipid fractions that follow have no `MeasurementKind`, so they are left on the floor rather than + /// force-fitted into one. + static func comprehensive(_ buffer: [UInt8]) -> [RingDecodedEvent] { + records(in: buffer, size: 44).compactMap { r in + let tenths = Int(r[5]) * 10 + Int(r[6]) + guard tenths > 0 else { return nil } + return .historyMeasurement(kind: .bloodSugar, value: bloodSugarMgdl(tenthsOfMmol: tenths), + timestamp: YCBTBytes.date(YCBTBytes.u32(r, 0))) + } + } + + // MARK: - Body data (query 0x33, 28-byte records) + + /// `[ts:u32][loadIdx i/f@4-5][hrv i/f@6-7][pressure i/f@8-9][body i/f@10-11][sympathetic i/f@12-13]` + /// `[sdnn:u16@14][vo2max@16][pnn50@17][rmssd:u16@18][lf:u16@20][hf:u16@22][lfHf@24]` + /// (`DataUnpack` case 51). + /// + /// The SDK's `pressure` is the **stress** score and `body` the **fatigue** score (that is how the + /// SmartHealth UI labels `BodyData.getPressureValue()` / `getBodyStateValue()`). This record is the + /// proof that the ring *stores* stress rather than the app deriving it — the old TK5 capability note + /// said the opposite. Load index, sympathetic tone, SDNN, pNN50, RMSSD and LF/HF have no + /// `MeasurementKind`; they are left on the floor rather than force-fitted into one. + /// + /// Those two scores go through `score` (digit-concatenated, the app's 1…100 scale) while HRV goes + /// through `composite` (milliseconds) — see both doc comments for why the same byte pair is read two + /// different ways. + /// + /// The fields from @16 on are read only when the record carries them, mirroring the SDK's + /// `length >= cursor + 25` gate for the rumoured short-prefix firmware. (In the Java that branch is + /// dead — its loop guard already demands 28 bytes — but the guard costs nothing and a firmware that + /// really did ship a short record must not fabricate a VO₂max out of the next record's bytes.) + static func bodyData(_ buffer: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for r in records(in: buffer, size: 28) { + let ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + if r[6] > 0 { + events.append(.historyMeasurement(kind: .hrv, value: composite(r[6], r[7]), timestamp: ts)) + } + if r[8] > 0 { + events.append(.historyMeasurement(kind: .stress, value: score(r[8], r[9]), timestamp: ts)) + } + if r[10] > 0 { + events.append(.historyMeasurement(kind: .fatigue, value: score(r[10], r[11]), timestamp: ts)) + } + if r.count > 16, r[16] > 0 { + events.append(.historyMeasurement(kind: .vo2max, value: Double(r[16]), timestamp: ts)) + } + } + return events + } + + // MARK: - Sleep (query 0x04, variable-length sessions) + + /// Sleep is the one variable-length type. The buffer holds **back-to-back sessions**, each a + /// 20-byte header followed by 8-byte stage segments (`DataUnpack` case 4): + /// + /// header: `[flags:2][recordLen:u16@2][start:u32@4][end:u32@8][counts/totals@12…19]` + /// `recordLen` = the whole session's byte count *including* this header. + /// segment: `[tag:1][segStart:u32 LE][len:u24 LE]` — the duration is **three** bytes, so a long + /// segment (>18h) can't be truncated by a u16 read. + /// + /// Stage classification is `tag & 0x0F` (the app's own `sleepTimeSummary`): 1 deep, 2 light, 3 REM, + /// 4 awake, 5 nap — the high nibble is a flag mask, so exact-matching the whole tag byte + /// (`0xF1…0xF4`) is wrong. **An unknown tag must be skipped, never terminal**: breaking out of the + /// loop on one lets a single nap segment (`0xF5`) truncate the rest of the night. + /// + /// Segments are also **deduplicated by start time within the session**, exactly as the SDK does + /// (`DataUnpack` case 4 keeps a list of the starts it has already taken and skips a repeat). Some + /// firmware repeats a segment inside one session; because the timeline is laid out *positionally* + /// from the session start, a repeat would both inflate that stage's minutes and shift every later + /// block by its duration — and the shifted copies re-insert as new blocks on the next re-sync. + static func sleep(_ buffer: [UInt8]) -> [RingDecodedEvent] { + let headerLength = 20 + let segmentLength = 8 + + var events: [RingDecodedEvent] = [] + var cursor = 0 + while cursor + headerLength <= buffer.count { + let recordLength = YCBTBytes.u16(buffer, cursor + 2) + let segmentsStart = cursor + headerLength + // Trust the record length, but never past the bytes we actually hold (a truncated transfer + // must not walk off the end — the SDK's own loop has no such guard). + let declared = max(0, recordLength - headerLength) / segmentLength + let available = (buffer.count - segmentsStart) / segmentLength + let segmentCount = min(declared, available) + + var stages: [SleepStage] = [] + var sessionStart: Date? + var seenStarts: Set = [] + for index in 0.. SleepStage? { + switch tag & 0x0f { + case 1: return .deep + case 2: return .light + case 3: return .rem + case 4: return .awake + case 5: return .unknown + default: return nil + } + } + + // MARK: - Shared field decoding + + /// Systolic/diastolic as two upserting history rows. Both bytes are zero on a record the ring never + /// ran a BP sweep for; the plausible *range* is the bridge's business, not ours. + private static func bloodPressureEvents(systolic: UInt8, diastolic: UInt8, timestamp: Date) -> [RingDecodedEvent] { + guard systolic > 0, diastolic > 0 else { return [] } + return [ + .historyMeasurement(kind: .bloodPressureSystolic, value: Double(systolic), timestamp: timestamp), + .historyMeasurement(kind: .bloodPressureDiastolic, value: Double(diastolic), timestamp: timestamp), + ] + } + + /// The ring's "no temperature sample" fraction marker. SmartHealth's own chart drops on it + /// **independently of the integer part** (`TemperatureActivity`: `int <= 42 && int >= 33 && frac != 15`), + /// so it is a sentinel, not a fraction that happens to be 15. + private static let temperatureFiller: UInt8 = 15 + + /// Temperature from an int/fraction pair, shared by the dedicated record and the All record. + /// + /// Two fillers, not one. The captured All records carry `int = 0, frac = 15`, but a record stamped + /// `int = 36, frac = 15` is the *same* "never measured" marker with a stale integer left behind — + /// and 36.15 °C sails straight through the bridge's 30…45 °C gate, so it would be upserted on this + /// and every future sync (the ring replays its whole log). Both halves of SmartHealth's own filter + /// therefore belong here; the plausibility *range* still lives only in the bridge. + private static func temperatureEvents(integer: UInt8, fraction: UInt8, timestamp: Date) -> [RingDecodedEvent] { + guard integer > 0, fraction != temperatureFiller else { return [] } + return [.historyMeasurement(kind: .temperature, value: composite(integer, fraction), timestamp: timestamp)] + } + + /// The SDK never adds an integer and its fraction *numerically* — it **string-concatenates** them + /// (`Float.parseFloat(int + "." + frac)`: `DataUnpack` case 30, and `BodyData.calculateCompositeValue` + /// for hrv/temperature). The fraction's scale is therefore implied by its digit count: 5 → `.5`, + /// 50 → `.5`, 25 → `.25`. Reproducing that exactly is the only way to land on the number SmartHealth + /// shows for the same bytes. + static func composite(_ integer: UInt8, _ fraction: UInt8) -> Double { + Double("\(integer).\(fraction)") ?? Double(integer) + } + + /// Stress / fatigue are the one pair that is **not** the decimal composite. The ring scores them + /// 0–10 with one decimal, and SmartHealth displays that ×10 on a 1…100 scale: its history list reads + /// `BodyData.getCompositePressure()` = `Integer.parseInt(int + "" + frac)` and its live screen reads + /// `(int)(Float.parseFloat(int + "." + frac) * 10)` (`PressureMeasureActivity:66`) — both filtered to + /// `TransUtils.PRESSURE_VISIBLE_MIN/MAX` = 1…100. So bytes `(5, 3)` are the **53** the app puts on + /// screen, not 5.3. Decoding them as a composite would file every score 10× low *inside* PulseLoop's + /// own 1…100 stress/fatigue scale (the one jring already reports on), where no range gate can catch it. + /// + /// HRV in the same record is deliberately *not* one of these: it is milliseconds — the All record + /// carries the same quantity as one whole byte and the app's own HRV range is 1…180 — so it keeps the + /// decimal composite (`45, 6` → 45.6 ms, not 456). + static func score(_ integer: UInt8, _ fraction: UInt8) -> Double { + Double("\(integer)\(fraction)") ?? Double(integer) + } + + /// mg/dL per mmol/L — the standard glucose molar-mass factor. + static let mgdlPerMmol = 18.016 + + /// Blood sugar arrives as **tenths of a mmol/L**, not whole mmol/L. SmartHealth stores the + /// comprehensive record as `integer * 10 + fraction` and the All record's single byte into the *same* + /// column (`HealthMetric.bloodSugarLevel`), then filters that column to 11…333 — + /// `TransUtils.BLOOD_SUGAR_VISIBLE_MIN/MAX`, whose float twins are 1.1 and 33.3 mmol/L. So a raw 55 + /// is 5.5 mmol/L ≈ 99 mg/dL, which is exactly the magnitude a fasting reading should have. + /// + /// PulseLoop persists `.bloodSugar` in mg/dL (jring's 0x24 packet does), hence the conversion. + /// **UNVERIFIED on hardware:** no captured record carried a non-zero value, so the on-device + /// checkpoint must cross-check one reading against the SmartHealth app before this scale is trusted. + static func bloodSugarMgdl(tenthsOfMmol raw: Int) -> Double { + Double(raw) / 10.0 * mgdlPerMmol + } + + // MARK: - Helpers + + /// Slice the reassembled buffer into fixed-size records, dropping a short trailing remainder + /// (the SDK's loop guard is likewise `cursor + stride <= length`). + private static func records(in buffer: [UInt8], size: Int) -> [[UInt8]] { + guard size > 0 else { return [] } + var out: [[UInt8]] = [] + var i = 0 + while i + size <= buffer.count { + out.append(Array(buffer[i..<(i + size)])) + i += size + } + return out + } +} diff --git a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift new file mode 100644 index 00000000..2201e090 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift @@ -0,0 +1,237 @@ +import Foundation + +/// The YCBT history state machine — protocol-driven, not timer-driven. +/// +/// Per type the ring answers a `05 ` request with: +/// header `05 ` payload ≥ 10 → `[recordCount:u16][totalPackets:u32][totalBytes:u32]` +/// payload ≤ 9 → nothing stored for this type +/// data `05 ` N frames whose payloads **concatenate** into one buffer (records straddle +/// frame boundaries, which is why nothing may be decoded per-frame) +/// terminal `05 80` `[totalPackets:u16][totalBytes:u16][crc16:u16]` over that buffer +/// +/// and then **waits for an ACK** (`05 80 {00}` accepted / `{04}` CRC failure) before releasing the next +/// type. `YCBTClientImpl.packetHealthHandle` sends the ACK *before* it parses; we do the same, so a +/// slow decode can't stall the ring. +/// +/// **Completion is the ring's terminal block, never a timer.** The watchdog below is a *safety net +/// only*: it never ACKs (an ACK without a verified terminal block claims data we don't hold) and is +/// never a completion signal — it just abandons a type the ring has gone silent on. +/// +/// Full spec, with a byte-level example: `docs/YCBT-Protocol.md` §4. +@MainActor +final class YCBTHistoryTransfer { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private enum State: Equatable { + case idle + /// Query written; waiting for the header (or a "no data" / error reply). + case requestSent(YCBTHistoryType) + /// Header seen; accumulating data frames until the terminal block. + case receiving(YCBTHistoryType) + + var type: YCBTHistoryType? { + switch self { + case .idle: return nil + case let .requestSent(type), let .receiving(type): return type + } + } + } + + private weak var writer: RingCommandWriter? + + private var state: State = .idle + private var queue: [YCBTHistoryType] = [] + private var buffer: [UInt8] = [] + /// A CRC mismatch buys the type exactly one re-request; a second failure gives up on it. + private var retriedCurrentType = false + /// Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. + private var unsupported: Set = [] + + /// Ceiling on the reassembled buffer. Sized from the header when we have one so a desynced stream + /// (data frames with no header, or a type that never terminates) can't grow it without bound. + private static let defaultBufferCap = 64 * 1024 + private var bufferCap = YCBTHistoryTransfer.defaultBufferCap + + /// Timeouts are injectable purely so tests can exercise the stall path without sleeping for 10s. + init( + writer: RingCommandWriter?, + inactivitySeconds: TimeInterval = 10, + absoluteCapSeconds: TimeInterval = 30 + ) { + self.writer = writer + self.inactivitySeconds = inactivitySeconds + self.absoluteCapSeconds = absoluteCapSeconds + } + + // MARK: - Driving the queue + + /// True while a type is being requested or received. + var isActive: Bool { state != .idle } + + /// Seed the queue and request the first type. Types the ring already rejected this session are + /// skipped. + /// + /// **A transfer already in flight wins.** There are now three callers (the connect handshake, the + /// post-workout vitals backfill, the 30-minute periodic pass), and a second `start` would abandon the + /// in-flight type mid-dump: the ring keeps streaming its data frames regardless, so they would land in + /// the *new* type's buffer and fail its terminal CRC. Deferring costs nothing — the watchdog + /// guarantees an in-flight transfer always completes or is abandoned. + func start(types: [YCBTHistoryType]) { + guard !isActive else { return } + queue = types.filter { !unsupported.contains($0.queryKey) } + publishOutOfBand(advance()) + } + + /// Abandon any in-flight transfer (disconnect / teardown). + func cancel() { + cancelWatchdog() + state = .idle + queue.removeAll() + buffer.removeAll() + } + + /// Request the next type, or report completion when the queue drains. Returns the events the + /// caller should surface (only ever the completion signal — progress comes from the header). + @discardableResult + private func advance() -> [RingDecodedEvent] { + cancelWatchdog() + buffer.removeAll(keepingCapacity: false) + bufferCap = Self.defaultBufferCap + retriedCurrentType = false + guard !queue.isEmpty else { + state = .idle + return [.historySyncFinished] + } + sendQuery(queue.removeFirst()) + return [] + } + + /// Write `05 ` and arm the stall watchdog. Also used for the single CRC retry, so it must + /// not touch `retriedCurrentType`. + private func sendQuery(_ type: YCBTHistoryType) { + state = .requestSent(type) + typeDeadline = Date().addingTimeInterval(absoluteCapSeconds) + writer?.enqueue(Data(YCBTHealthCommand.historyRequest(type))) + armWatchdog(for: type) + } + + // MARK: - Inbound + + /// Feed every validated Health-group (`type == 0x05`) frame here. Frames arriving while idle are + /// ignored — the ring occasionally trails a stray block after we've moved on. + func handle(cmd: UInt8, payload: [UInt8]) -> [RingDecodedEvent] { + guard let type = state.type else { return [] } + + // A 1-byte 0xFB…0xFF payload is a rejection, not data — check before anything reads offsets. + if let error = YCBTFrameError.detect(in: payload) { + if error.isPermanent { unsupported.insert(type.queryKey) } + return advance() + } + + switch cmd { + case type.queryKey: + return handleHeader(type, payload: payload) + case type.ackKey: + appendData(payload) + armWatchdog(for: type) + return [] + case YCBTHealth.terminalBlock: + return handleTerminal(type, payload: payload) + default: + return [] // a frame for some other type — not ours to interpret + } + } + + /// Header: `[recordCount:u16][totalPackets:u32][totalBytes:u32]`. A payload of 9 bytes or fewer is + /// the SDK's "no stored data" reply — there is no transfer to ACK, so we simply move on. + private func handleHeader(_ type: YCBTHistoryType, payload: [UInt8]) -> [RingDecodedEvent] { + guard payload.count >= YCBTHealth.headerPayloadLength else { return advance() } + let totalBytes = YCBTBytes.u32(payload, 6) + buffer.removeAll(keepingCapacity: true) + bufferCap = max(totalBytes, Self.defaultBufferCap) + state = .receiving(type) + armWatchdog(for: type) + return [.historySyncProgress(stage: "Syncing \(type.label)…")] + } + + /// Data frames concatenate. We accept them even if the header was missed (a lost first + /// notification): the terminal CRC is the real integrity check, and it will fail us into the retry + /// path rather than persisting a misaligned buffer. + private func appendData(_ payload: [UInt8]) { + guard buffer.count + payload.count <= bufferCap else { return } + buffer.append(contentsOf: payload) + } + + /// Terminal: verify the CRC16 over everything we accumulated, ACK, then decode. Order matters — the + /// SDK ACKs first, and the ring gates the next type on it. + /// + /// A `05 80` carries no type identity, so it is always attributed to whatever type is current — and + /// after a watchdog skip (or the CRC retry), the *previous* type's late terminal lands here. That is + /// not inert: its CRC can't match a buffer it wasn't computed over, so it would NACK the ring into + /// re-dumping a type we didn't lose *and* spend the current type's one retry. A terminal that arrives + /// before this type's header, with nothing accumulated, is therefore stale by construction — a + /// genuinely empty type never reaches a terminal at all, because the SDK signals "no data" with the + /// ≤9-byte header that `handleHeader` already advances on. + private func handleTerminal(_ type: YCBTHistoryType, payload: [UInt8]) -> [RingDecodedEvent] { + if case .requestSent = state, buffer.isEmpty { return [] } + guard payload.count >= YCBTHealth.terminalPayloadLength else { return advance() } + let expected = UInt16(YCBTBytes.u16(payload, 4)) + let matches = YCBTFrame.crc16(buffer) == expected + + writer?.enqueue(Data(YCBTHealthCommand.historyBlockAck( + status: matches ? YCBTHealth.ackAccepted : YCBTHealth.ackCrcFailure + ))) + + guard matches else { return retryOrSkip(type) } + return YCBTHealthRecords.decode(buffer, type: type) + advance() + } + + /// One re-request per type on a corrupt transfer; if that also fails, drop the type rather than + /// looping the ring forever. + private func retryOrSkip(_ type: YCBTHistoryType) -> [RingDecodedEvent] { + guard !retriedCurrentType else { return advance() } + retriedCurrentType = true + buffer.removeAll(keepingCapacity: true) + sendQuery(type) + return [] + } + + // MARK: - Stall watchdog (safety net) + + /// Inactivity window, re-armed by every history frame for the in-flight type. + private let inactivitySeconds: TimeInterval + /// Hard ceiling per type, so a ring that dribbles frames forever still can't wedge the queue. + private let absoluteCapSeconds: TimeInterval + + private var watchdog: Task? + private var typeDeadline: Date? + + /// Fires only on silence: the type is declared stalled and skipped. It must never ACK (an ACK + /// without a verified terminal block tells the ring we accepted data we don't have) and must never + /// stand in for completion. + private func armWatchdog(for type: YCBTHistoryType) { + watchdog?.cancel() + let deadline = typeDeadline ?? Date().addingTimeInterval(absoluteCapSeconds) + let fireAt = min(Date().addingTimeInterval(inactivitySeconds), deadline) + let delay = max(0, fireAt.timeIntervalSinceNow) + watchdog = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled, let self, self.state.type == type else { return } + self.publishOutOfBand(self.advance()) + } + } + + private func cancelWatchdog() { + watchdog?.cancel() + watchdog = nil + typeDeadline = nil + } + + /// `handle` returns its events to the driver, which publishes them. `start` and the watchdog have no + /// such return channel, so the one event they can produce — completion — is published here. + private func publishOutOfBand(_ events: [RingDecodedEvent]) { + guard events.contains(where: { if case .historySyncFinished = $0 { return true } else { return false } }) + else { return } + Task { await PulseEventBus.shared.publish(.syncProgress(stage: "done")) } + } +} diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift new file mode 100644 index 00000000..01b4a351 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -0,0 +1,500 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Yucheng **YCBT** protocol primitives — GATT topology, framing, byte/epoch helpers, opcodes, and the +/// health-record type table. This is the wire language the TK5 speaks and, byte-identically, the +/// SmartHealth-flavoured Colmi rings. Everything here is deliberately device-agnostic: what varies +/// between families is their advertised identity and which capability bits they claim, both of which +/// live in their coordinator, so a second family reuses this file verbatim. +/// +/// Ground truth is the decompiled vendor SDK (`com.yucheng.ycbtsdk`, v4.0.10): `CMD.java` (opcodes), +/// `Constants.java` (16-bit dataTypes), `DataUnpack.java` (record parsing) and +/// `YCBTClientImpl.java` (framing, queue, history assembly). Written up in `docs/YCBT-Protocol.md`, +/// which is the reference to read before touching any byte offset here. +/// +/// **Wire format** (both the command channel `be940001` and the async stream `be940003`): +/// `[type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE]` +/// where `len` is the *total* frame length (header + payload + crc) and the CRC is +/// **CRC16/CCITT-FALSE** (poly 0x1021, init 0xFFFF, no reflection) over every byte before it. +/// A command's 16-bit `dataType` in the SDK is exactly `(type << 8) | cmd`. +/// +/// This is intentionally separate from `RingProtocol.swift` (jring) and `ColmiProtocol.swift` (the +/// QRing-flavoured Colmi rings): YCBT shares nothing at the wire level with either — its own `be94…` +/// service, a length-prefixed CRC16/CCITT-FALSE frame, and a split command/stream channel. Decoded +/// output is normalized into the shared `RingDecodedEvent` so the rest of the app stays +/// device-agnostic. + +/// The GATT topology every YCBT ring exposes. +/// +/// **A note on the AE00 service:** the `fedcba`/"pass" handshake there is not a login for the health +/// protocol — it is **JieLi RCSP**, the chipset vendor's own challenge-response auth, and it +/// authorizes the JieLi feature set only (OTA, watch-face upload, log extraction). The YC health +/// commands on `be940001` are plaintext, CRC-framed, and carry no auth: an independent code path in +/// the SDK. PulseLoop does no firmware updates and no watch faces, so it deliberately implements none +/// of it (the AES key is native to `libjl_rcsp.so` and isn't in the decompile anyway). +/// See `docs/YCBT-Protocol.md` §9 — including the one residual risk, that a *firmware* could still +/// refuse YC commands pre-auth even though the SDK's two paths are independent. +enum YCBTUUIDs { + /// Primary protocol service. + static let service = "be940000-7333-be46-b7ae-689e71722bd5" + /// Command channel — the app writes here AND receives command replies here (write + indicate). + static let command = "be940001-7333-be46-b7ae-689e71722bd5" + /// Async stream — live HR / steps / SpO₂ and downloaded history records (indicate). + static let stream = "be940003-7333-be46-b7ae-689e71722bd5" + + /// Standard BLE Heart Rate service + measurement char. Present on the ring but **deliberately not + /// subscribed** (see `YCBTDriver`): on the TK5 it emits a cached resting HR even off-finger + /// (~87 bpm), which would mask real on-demand readings. Kept here only to document the GATT layout. + static let heartRateService = "180D" + static let heartRateMeasurement = "2A37" +} + +/// Frame parsing + building for the YCBT length-prefixed CRC16 protocol. +struct YCBTFrame { + let type: UInt8 + let cmd: UInt8 + let payload: [UInt8] + + /// Parse and CRC-validate one inbound frame. Returns nil on a short frame or CRC mismatch. + init?(validating data: Data) { + let bytes = [UInt8](data) + guard bytes.count >= 6 else { return nil } + let declared = Int(bytes[2]) | (Int(bytes[3]) << 8) + guard declared == bytes.count else { return nil } + let crcGiven = UInt16(bytes[bytes.count - 2]) | (UInt16(bytes[bytes.count - 1]) << 8) + guard YCBTFrame.crc16(bytes[0..<(bytes.count - 2)]) == crcGiven else { return nil } + self.type = bytes[0] + self.cmd = bytes[1] + self.payload = Array(bytes[4..<(bytes.count - 2)]) + } + + /// Build a framed packet from a logical command `[type, cmd, payload…]`: insert the total-length + /// field after the two header bytes and append the little-endian CRC16. + static func frame(_ logical: [UInt8]) -> Data { + guard logical.count >= 2 else { return Data(logical) } + let total = logical.count + 4 // + 2-byte length field + 2-byte CRC + var out: [UInt8] = [logical[0], logical[1], UInt8(total & 0xff), UInt8((total >> 8) & 0xff)] + out.append(contentsOf: logical[2...]) + let crc = crc16(out[0..> 8) & 0xff)) + return Data(out) + } + + /// CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no input/output reflection, no final xor). + static func crc16(_ bytes: S) -> UInt16 where S.Element == UInt8 { + var crc: UInt16 = 0xFFFF + for b in bytes { + crc ^= UInt16(b) << 8 + for _ in 0..<8 { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : (crc << 1) + } + } + return crc + } +} + +/// Little-endian + epoch helpers. YCBT timestamps are **seconds since 2000-01-01 UTC**, not the Unix +/// epoch (confirmed against the capture's wall-clock time). +enum YCBTBytes { + /// Seconds between 1970-01-01 and 2000-01-01 (the YCBT epoch offset). + static let epochOffset: TimeInterval = 946_684_800 + + static func u16(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 2 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) + } + + /// 3-byte little-endian. Sleep segment durations are u24 (`DataUnpack` reads bytes 5,6,7 of an + /// 8-byte segment), so a u16 read truncates any segment longer than 18h12m. + static func u24(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 3 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) | (Int(b[i + 2]) << 16) + } + + static func u32(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 4 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) | (Int(b[i + 2]) << 16) | (Int(b[i + 3]) << 24) + } + + /// Convert a ring timestamp (2000-epoch seconds) to a `Date`. The ring has no timezone concept — + /// its clock is set from local wall-clock fields (see `YCBTEncoder.setTime`) and ticks in local + /// time, so decoding must un-apply the device's UTC offset to recover the true absolute instant. + /// Without this, `Calendar.current` re-applies that same offset when a caller later extracts + /// local components (e.g. `Calendar.wakingDay(forSleepStart:)`'s hour check), doubling it instead + /// of cancelling it. Uses the *current* offset as an approximation of the offset in effect when + /// the timestamp was recorded — correct for same-session syncs, only wrong across a DST + /// transition that happens between recording and decoding. + static func date(_ ringSeconds: Int, timeZone: TimeZone = .current) -> Date { + let offset = TimeInterval(timeZone.secondsFromGMT()) + return Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset - offset) + } + + /// Convert a `Date` to ring seconds (2000-epoch), the inverse of `date(_:timeZone:)`. + static func ringSeconds(_ date: Date, timeZone: TimeZone = .current) -> Int { + let offset = TimeInterval(timeZone.secondsFromGMT(for: date)) + return Int(date.timeIntervalSince1970 - epochOffset + offset) + } +} + +/// Frame `type` byte — the command group. `Constants.DATATYPE` splits every opcode this way. +enum YCBTGroup { + static let setting: UInt8 = 0x01 // clock, user info, units, monitor enables + static let get: UInt8 = 0x02 // device info, support bitmap, name, user config + static let appControl: UInt8 = 0x03 // live-measurement start/stop, live-status push + static let devControl: UInt8 = 0x04 // device→app pushes (find phone, SOS, measurement done) + static let health: UInt8 = 0x05 // history: queries, data frames, terminal block + static let real: UInt8 = 0x06 // device→app realtime stream +} + +/// The `cmd` bytes we act on, by group (`YCBTGroup`). The Setting-group keys live in `YCBTSettingKey` +/// and the Health-group history keys in `YCBTHistoryType`. +/// +/// The Get-group names here were previously **swapped**: `0x00` was called `status` and `0x01` +/// `deviceInfo`. `CMD.KEY_Get` says otherwise — `DeviceInfo = 0` (the reply `unpackDeviceInfoData` +/// parses, carrying battery + firmware) and `SupportFunction = 1` (the capability bitmap). +enum YCBTCommand { + // Group 0x02 (Get) + static let getDeviceInfo: UInt8 = 0x00 // battery @payload[5], state @[4], firmware "[3].[2]" + static let getSupportFunction: UInt8 = 0x01 // capability bitmap (see YCBTSupportFunction) + static let getDeviceName: UInt8 = 0x03 + static let getUserConfig: UInt8 = 0x07 + static let getChipScheme: UInt8 = 0x1b // JieLi vs Nordic — gates the (unimplemented) AE00 auth + + // Group 0x03 (AppControl) + static let findDevice: UInt8 = 0x00 // make the ring buzz (`CMD.KEY_AppControl.FindDevice`) + static let liveMeasurement: UInt8 = 0x2f // [enable, mode] — mode picks the sensor/LED + static let liveStatusPush: UInt8 = 0x09 // enable the ring's continuous 06 00 status stream + + // Group 0x06 (Real — async stream on be940003) + static let liveStatus: UInt8 = 0x00 // steps + distance/calories, repeated + static let liveHeartRate: UInt8 = 0x01 // 1-byte bpm + static let liveSpo2: UInt8 = 0x02 // 1-byte SpO₂ % + static let liveVitals: UInt8 = 0x03 // SBP/DBP/hr/hrv/spo2/temp — the BP *and* HRV live feed + static let liveWearingStatus: UInt8 = 0x13 // [ts:u32][worn] + static let liveBattery: UInt8 = 0x15 // [chargingStatus][percent] +} + +/// One health-history record type: the query key we write, the ack key its data frames carry back, and +/// the fixed record stride the reassembled buffer is sliced at. This table is the single source of +/// truth for both `YCBTHistoryTransfer` (which types to ask for, which frames belong to which type) +/// and `YCBTHealthRecords` (how to cut the buffer) — the two must never disagree. +/// +/// Keys are `CMD.KEY_Health` (query) and its paired `…Ack` constant; strides are the loop increments in +/// `DataUnpack.unpackHealthData`. +struct YCBTHistoryType: Equatable, Hashable, Sendable { + /// `05 ` with an empty payload asks the ring for every stored record of this type. + let queryKey: UInt8 + /// The `cmd` the ring's data frames carry (a *different* key from the query — e.g. heart queries + /// with `0x06` and streams back on `0x15`). + let ackKey: UInt8 + /// Fixed record size in the reassembled buffer. `nil` ⇒ variable-length (sleep sessions carry + /// their own length field), so the decoder walks it rather than slicing at a stride. + let recordStride: Int? + /// Human label for the sync-progress UI ("Syncing sleep…"). + let label: String + + static let sport = YCBTHistoryType(queryKey: 0x02, ackKey: 0x11, recordStride: 14, label: "activity") + static let sleep = YCBTHistoryType(queryKey: 0x04, ackKey: 0x13, recordStride: nil, label: "sleep") + static let heart = YCBTHistoryType(queryKey: 0x06, ackKey: 0x15, recordStride: 6, label: "heart rate") + static let blood = YCBTHistoryType(queryKey: 0x08, ackKey: 0x17, recordStride: 8, label: "blood pressure") + static let all = YCBTHistoryType(queryKey: 0x09, ackKey: 0x18, recordStride: 20, label: "vitals") + static let spo2 = YCBTHistoryType(queryKey: 0x1a, ackKey: 0x22, recordStride: 6, label: "blood oxygen") + static let temperature = YCBTHistoryType(queryKey: 0x1e, ackKey: 0x26, recordStride: 7, label: "temperature") + static let comprehensive = YCBTHistoryType(queryKey: 0x2f, ackKey: 0x30, recordStride: 44, label: "metabolic") + static let bodyData = YCBTHistoryType(queryKey: 0x33, ackKey: 0x34, recordStride: 28, label: "body data") + + /// Every type the SDK's `DataSyncUtils` can request, in its own ascending-key sync order — and every + /// type `YCBTHealthRecords` decodes. Both YCBT families query the whole catalog, whatever their + /// capability set says: a type the ring doesn't implement answers with a no-data header or a `0xFC` + /// (unsupported key), which `YCBTHistoryTransfer` skips — permanently, for `0xFC`. Asking is + /// therefore cheaper than keeping a second, capability-derived list that could disagree with the + /// ring's own answer. + /// + /// Deliberately absent, with no decoder: sport-mode workout records (`0x2D`), fall (`0x29`), + /// health-monitoring (`0x2B`), sedentary (`0x37`), ambient light (`0x20`), temp+humidity (`0x1C`), + /// location (`0x35`) and power-on/off (`0x76`) — none map onto a PulseLoop metric today. + static let catalog: [YCBTHistoryType] = [ + .sport, .sleep, .heart, .blood, .all, .spo2, .temperature, .comprehensive, .bodyData, + ] +} + +/// The measurement-mode byte — one table shared by the two commands that must agree on it: the +/// **`03 2f` start/stop** payload we write (`{enable, mode}`) and the **`04 13` status/result push** the +/// ring answers with (`[type][state]…`). They are the same numbering, verified case by case against +/// SmartHealth's own measure screens (each `BaseMeasureActivity` subclass returns its mode from +/// `getType()`, which is passed straight to `appStartMeasurement`) and against the 1043 switch in +/// `DataUnpack.unpackParseData`. +/// +/// Only the modes a YCBT ring can actually run are listed; PulseLoop drives a subset of them. +enum YCBTMeasurementMode { + static let heartRate: UInt8 = 0x00 + static let bloodPressure: UInt8 = 0x01 + static let spo2: UInt8 = 0x02 + static let respiratoryRate: UInt8 = 0x03 + static let temperature: UInt8 = 0x04 + static let bloodSugar: UInt8 = 0x05 + static let uricAcid: UInt8 = 0x06 + static let bloodKetone: UInt8 = 0x07 + static let bloodFat: UInt8 = 0x09 + static let hrv: UInt8 = 0x0a + static let stress: UInt8 = 0x0c +} + +/// Group 4 (**DevControl**) — the ring→app push channel: measurement progress/results, SOS, find-phone, +/// sedentary reminders. The app never *initiates* a `04 xx`; the only `04` frame it writes is the ACK +/// below. +enum YCBTDevControl { + static let findPhone: UInt8 = 0x00 // 1024 — ring pressed "find my phone" + static let sos: UInt8 = 0x05 // 1029 + static let measurementResult: UInt8 = 0x0e // 1038 — [measureType][result] + static let measurementStatus: UInt8 = 0x13 // 1043 — [type][state] + the value for that type + static let sedentaryReminder: UInt8 = 0x16 // 1046 + static let sosCall: UInt8 = 0x17 // 1047 — full SOS + GPS record + + /// `04 {00}` — the mandatory push ACK. **The ring retransmits a push until it arrives**, so + /// `YCBTClientImpl.packetDevControlHandle` sends it (`sendData2Device(dataType, {0})`) *before* it + /// even parses the payload. We do the same. + static func ack(key: UInt8) -> [UInt8] { + [YCBTGroup.devControl, key, 0x00] + } + + /// `result` byte of a `04 0e` MeasurementResult push, per `BaseMeasureActivity.onDataResponse`: + /// 1 = success, 2 = failed, anything else = cancelled. The push carries **no measured value** — + /// SmartHealth reacts to a success by re-reading history, which is where the reading actually lands. + static let resultSuccess: UInt8 = 0x01 +} + +/// The Health group's two control keys and the ACK status bytes. +enum YCBTHealth { + /// `05 80` — inbound it terminates a transfer (`[totalPackets:u16][totalBytes:u16][crc16:u16]`); + /// outbound it is the mandatory block ACK. + static let terminalBlock: UInt8 = 0x80 + /// Reassembled buffer matched the terminal frame's CRC16. + static let ackAccepted: UInt8 = 0x00 + /// CRC mismatch — the ring may re-send. + static let ackCrcFailure: UInt8 = 0x04 + /// A header frame carries `[recordCount:u16][totalPackets:u32][totalBytes:u32]`; the SDK treats a + /// payload of 9 bytes or fewer as "this type has no stored data". + static let headerPayloadLength = 10 + /// The terminal block's payload length. + static let terminalPayloadLength = 6 +} + +/// Logical (unframed) Health-group commands. Shared so the transfer machine and a family's encoder +/// can never drift apart on the exact bytes; the driver's `frame(_:)` adds length + CRC. +enum YCBTHealthCommand { + /// Ask for one history type: `05 ` with an **empty** payload. There is no cursor or time + /// range — the ring always dumps everything it has stored for that type. + static func historyRequest(_ type: YCBTHistoryType) -> [UInt8] { + [YCBTGroup.health, type.queryKey] + } + + /// The mandatory end-of-transfer ACK: `05 80 {status}`. Without it the ring will not release the + /// next type (`YCBTClientImpl.packetHealthHandle` sends `1408 = 0x0580` before it even parses). + static func historyBlockAck(status: UInt8) -> [UInt8] { + [YCBTGroup.health, YCBTHealth.terminalBlock, status] + } +} + +/// Device-side rejection of a command. The SDK's `isError` treats **any** 1-byte response payload in +/// `0xFB…0xFF` as an error status rather than data — for *every* group, which is why this is +/// frame-level rather than a Health-group concern: the DevControl push path checks it too (an error +/// frame is a rejection, not a push, and must not be ACKed). +enum YCBTFrameError: UInt8, Sendable { + case unsupportedCommand = 0xfb // the group byte isn't implemented + case unsupportedKey = 0xfc // the cmd byte isn't implemented on this firmware + case length = 0xfd + case data = 0xfe + case crc = 0xff + + /// Detect an error frame. Must be checked *before* interpreting a payload as a header/record/push, + /// because a 1-byte error payload is otherwise indistinguishable from a short header. + static func detect(in payload: [UInt8]) -> YCBTFrameError? { + guard payload.count == 1 else { return nil } + return YCBTFrameError(rawValue: payload[0]) + } + + /// True when the ring is telling us it will *never* answer this type on this firmware, so the + /// transfer machine can stop asking for the rest of the session. + var isPermanent: Bool { + self == .unsupportedCommand || self == .unsupportedKey + } +} + +/// Reassembles GATT notifications into whole logical frames. +/// +/// A logical frame longer than `MTU-3` is split across notifications (and, symmetrically, several +/// short frames can land in a single notification). Validation keys off the declared total length at +/// bytes [2..3], exactly as `YCBTClientImpl`'s receive parser does: it buffers until `len` bytes are +/// in hand. Without this, every multi-notification history frame is dropped as garbage — which is one +/// half of why TK5 history never landed. +/// +/// Garbage (a truncated tail after a disconnect, a stray notification) is resynced by dropping one +/// byte at a time until a plausible header appears, so one bad byte can't poison the rest of a session. +@MainActor +final class YCBTFrameAssembler { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// Header + CRC with an empty payload — the shortest frame that can exist. + private let minFrameLength = 6 + /// No YCBT frame comes close to this; a larger declared length means we're mid-garbage and should + /// resync rather than wait forever for bytes that will never arrive. + private let maxFrameLength = 1024 + + /// Partial frames, per characteristic: the command channel and the async stream interleave, and a + /// fragment from one must never be concatenated onto the other. + private var pending: [CBUUID: [UInt8]] = [:] + + /// Drop every partial frame. A fresh driver is built per connection, so this exists for reconnects + /// that reuse one (and for tests). + func reset() { + pending.removeAll() + } + + /// Feed one notification; returns the complete logical frames it completed (0, 1, or several). + func append(_ data: Data, from characteristic: CBUUID) -> [Data] { + var buffer = pending[characteristic] ?? [] + buffer.append(contentsOf: data) + + var frames: [Data] = [] + while buffer.count >= 4 { + let declared = Int(buffer[2]) | (Int(buffer[3]) << 8) + guard isPlausibleGroup(buffer[0]), declared >= minFrameLength, declared <= maxFrameLength else { + buffer.removeFirst() // resync: this can't be a frame start + continue + } + guard buffer.count >= declared else { break } // still waiting on the rest of this frame + frames.append(Data(buffer[0.. Bool { + (YCBTGroup.setting...YCBTGroup.real).contains(byte) + } +} + +/// Parser for the `02 01` **SupportFunction** reply: a variable-length bit array (bit 7 of each byte +/// first) in which the firmware declares what it actually implements. Mirrors +/// `DataUnpack.saveDeviceSupportFunctionData`. +/// +/// This is what lets one driver serve a whole *family* of rings whose SKUs disagree: a family declares +/// a capability as `bitmapGatedCapabilities` and the connected unit's own bitmap decides whether it is +/// really there (see `WearableCoordinator.refinedCapabilities`). Getting a bit wrong is not a cosmetic +/// error — it either hides a metric the ring has, or renders a card / "Measure" button the ring will +/// never fill — so the table below maps **only** bits whose meaning is pinned by an actual caller in +/// the vendor app, and omits everything else. +enum YCBTSupportFunction { + /// One capability bit: its byte, its bit index (7 = MSB, matching the SDK's `(b >> n) & 1`), and the + /// **payload length the SDK demands before it will read that byte at all**. + /// + /// `minLength` is not a bounds check — it reproduces `saveDeviceSupportFunctionData`'s own nested + /// `if (bArr.length >= N)` blocks (and its caller's `>= 14` in `YCBTClientImpl`), which admit one + /// whole firmware-generation block at a time. The distinction is real: a 16-byte bitmap *has* a + /// physical byte 15, but the vendor SDK — the thing every YCBT firmware was built and tested + /// against — refuses to read it below 18 bytes, so a ring may well leave garbage there. Every gate + /// is ≥ `byte + 1`, so this one check also length-guards the access. + private struct Bit { + let byte: Int + let bit: Int + let minLength: Int + let capability: WearableCapability + } + + /// Bit → capability, each named with the `Constants.FunctionConstant` the SDK stores it under. + /// + /// Evidence for the metric bits is the vendor app's *own* "should I show this card?" switch, + /// `HomeFragmentModelUtil.checkedFunction`, which pins each flag to a named metric screen — + /// 心率→HEARTRATE, 睡眠→SLEEP, 血压→BLOOD (so `ISHASBLOOD` is blood *pressure*), 血氧→BLOODOXYGEN, + /// HRV→HRV, 温度→TEMP, 血糖→BLOODSUGAR, 压力→PRESSURE (so `IS_HAS_PRESSURE` is *stress*, not BP). + /// `DataSyncUtils` corroborates by gating each history query on the same flag, and adds STEPCOUNT. + private static let bits: [Bit] = [ + Bit(byte: 0, bit: 7, minLength: 14, capability: .steps), // ISHASSTEPCOUNT + Bit(byte: 0, bit: 6, minLength: 14, capability: .sleep), // ISHASSLEEP + Bit(byte: 0, bit: 3, minLength: 14, capability: .heartRate), // ISHASHEARTRATE + Bit(byte: 0, bit: 0, minLength: 14, capability: .bloodPressure), // ISHASBLOOD + Bit(byte: 1, bit: 3, minLength: 14, capability: .spo2), // ISHASBLOODOXYGEN + Bit(byte: 1, bit: 1, minLength: 14, capability: .hrv), // ISHASHRV + Bit(byte: 8, bit: 0, minLength: 14, capability: .temperature), // ISHASTEMP + Bit(byte: 17, bit: 3, minLength: 18, capability: .bloodSugar), // ISHASBLOODSUGAR + Bit(byte: 22, bit: 6, minLength: 23, capability: .stress), // IS_HAS_PRESSURE + + // Find-my-ring. `DeviceSupportFunctionUtil.isHasFindDevice` reads it, and `MeAntiLostActivity` + // hides the whole screen without it. + Bit(byte: 6, bit: 4, minLength: 14, capability: .findDevice), // ISHASFINDDEVICE + + // Spot-measurement support — a *separate* question from whether the ring logs the metric, which + // is why these are their own bits: a ring can trend SpO₂ all day and still refuse to measure it + // on demand. Each one gates exactly the start button on its measure screen in the vendor app + // (`llStartButton.setVisibility(GONE)` when absent), which is the same promise PulseLoop's + // `manual…` capabilities make to `VitalsView`, so they map 1:1. + Bit(byte: 15, bit: 1, minLength: 18, capability: .manualHeartRate), // ISHATESTHEART → HeartRateActivity + Bit(byte: 15, bit: 2, minLength: 18, capability: .manualBloodPressure), // ISHASTESTBLOOD → BloodPressureActivity + Bit(byte: 15, bit: 3, minLength: 18, capability: .manualSpo2), // ISHASTESTSPO2 → BloodOxygenActivity + Bit(byte: 23, bit: 0, minLength: 24, capability: .manualHrv), // IS_HAS_HRV_MEASUREMENT → HRVActivity + ] + + // Bits deliberately NOT mapped, and why — a bit we can't *name* is a capability we'd be inventing: + // + // • ISHASREALDATA (0.5) — the obvious candidate for `.realtimeHeartRate`/`.realtimeSteps`, but no + // caller anywhere in the app or SDK reads it (only a dead `DeviceSupportFunctionUtil` getter), so + // neither its meaning nor its scope is pinned — and it could not distinguish the two anyway. + // • ISHASFACTORYSETTING (6.3) — confidently named, but PulseLoop implements no YCBT factory-reset or + // power-off command, so a derived `.factoryReset`/`.powerOff` could never be honoured. + // • IS_HAS_BATTERY_INFO_UPLOAD (22.5) — declares the unsolicited `06 15` push, not whether battery is + // readable. Battery is in-band on `02 00` for every YCBT ring, so `.battery` stays a baseline. + // • `.remSleep`, `.spo2History`, `.fatigue` — no bit names them. They are sub-features of bits that + // don't distinguish them (REM-ness of the sleep timeline, the all-day `05 1A` SpO₂ log, the + // body-data record's fatigue field), so the family's baseline stays the only source for them. + // • `.measurementInterval` — IS_HAS_INDEPENDENT_AUTOMATIC_TIME_MEASUREMENT (20.5) is the nearest, but + // "independent" is unpinned by any caller: it may mean "has per-metric intervals" or "has an + // interval at all", and those gate different screens. + // • ECG / dial / notification / per-sport bits — PulseLoop has no capability for any of them. + + /// The capabilities this unit claims. A payload too short to clear even the SDK's own `>= 14` gate + /// yields the empty set — every bit's `minLength` is at least that — which under the additive-only + /// refinement means "no opinion", i.e. the family's baseline stands. That is the safe direction: a + /// truncated or garbage reply must never be read as the ring *denying* a capability. + static func capabilities(from payload: [UInt8]) -> Set { + var out: Set = [] + for bit in bits where isSet(payload, bit) { + out.insert(bit.capability) + } + return out + } + + /// Raw bit array (MSB first within each byte) for the debug feed / diagnostics — the bitmap has far + /// more bits than we map, and an unrecognised device is easier to triage from the raw view. + static func rawBits(from payload: [UInt8]) -> [Bool] { + payload.flatMap { byte in (0..<8).map { (byte >> (7 - $0)) & 1 == 1 } } + } + + private static func isSet(_ payload: [UInt8], _ bit: Bit) -> Bool { + guard payload.count >= bit.minLength, bit.byte < payload.count else { return false } + return (payload[bit.byte] >> UInt8(bit.bit)) & 1 == 1 + } +} + +/// The `02 1b` **chipScheme** reply (`DataUnpack.unpackGetChipScheme`): one byte naming the chipset/OTA +/// family. Diagnostic only — PulseLoop does no firmware updates — but it is the frame that says whether +/// the ring's OTA path is JieLi RCSP, which is the auth we deliberately don't implement (see `YCBTUUIDs`). +enum YCBTChipScheme { + /// `bArr[0] & 0xFF`, except that a value ≥ 240 is an error status (`YCBTFrameError`'s `0xFB…0xFF` + /// band), which the SDK folds to 0 = "unknown/other" rather than treating as a scheme id. + static func value(from payload: [UInt8]) -> Int { + guard let first = payload.first, first < 240 else { return 0 } + return Int(first) + } + + /// `InnerUtils.isJieLiChipScheme`: 3, 4 and 5 are the JieLi families (the ones that would need the + /// AE00 RCSP handshake for OTA/watch-faces). + static func isJieLi(_ value: Int) -> Bool { + (3...5).contains(value) + } +} diff --git a/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift new file mode 100644 index 00000000..2e9bccc9 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Setting-group (`0x01`) keys — `Constants.DATATYPE` low bytes. +enum YCBTSettingKey { + static let setTime: UInt8 = 0x00 // SettingTime 256 + static let userInfo: UInt8 = 0x03 // SettingUserInfo 259 + static let units: UInt8 = 0x04 // SettingUnit 260 + static let heartMonitor: UInt8 = 0x0c // SettingHeartMonitor 268 + static let language: UInt8 = 0x12 // SettingLanguage 274 + static let bloodPressureMonitor: UInt8 = 0x1c // SettingBloodPressureMonitor 284 + static let temperatureMonitor: UInt8 = 0x20 // SettingTemperatureMonitor 288 + static let bloodOxygenMonitor: UInt8 = 0x26 // SettingBloodOxygenModeMonitor 294 + static let hrvMonitor: UInt8 = 0x45 // SettingHRVMonitor 325 +} + +/// Byte builders for the Setting group, shared by every YCBT family. Each returns a *logical* command +/// (`[type, cmd, payload…]`); the driver's `frame(_:)` adds the length field and CRC. +/// +/// Every one of these is idempotent and individually ACKed by the ring with a 1-byte status. +struct YCBTSettingsEncoder { + /// The ring's all-day sampler refuses intervals under 30 minutes (SmartHealth clamps the same way + /// for rings: `if (isRing() && interval < 30) interval = 30`). The vendor default is 60. + static let minimumIntervalMinutes = 30 + static let defaultIntervalMinutes = 60 + + /// Clamp a user-chosen cadence into what the firmware will actually accept. PulseLoop's shared + /// `MeasurementSettings` default is 5 minutes (a Colmi cadence), which this floors to 30 rather + /// than silently letting the ring reject the write. + static func clampInterval(_ minutes: Int) -> UInt8 { + guard minutes > 0 else { return UInt8(defaultIntervalMinutes) } + return UInt8(min(255, max(minimumIntervalMinutes, minutes))) + } + + // MARK: - Clock + + /// `01 00` + `[year:u16 LE][month][day][hour][min][sec][weekday]`. + /// + /// The weekday byte is **Mon=0 … Sun=6** (`TimeUtil.makeBleTime`: `dayOfWeek == 1 ? 6 : dayOfWeek - 2`, + /// against Gregorian `Calendar` where Sunday == 1). PulseLoop used to hard-code `0x00`, so the ring + /// believed every day was Monday. + func setTime(_ date: Date = Date(), calendar: Calendar = .current) -> [UInt8] { + let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .weekday], from: date) + let year = UInt16(c.year ?? 2000) + let gregorianWeekday = c.weekday ?? 1 + let weekday = UInt8(gregorianWeekday == 1 ? 6 : gregorianWeekday - 2) + return [YCBTGroup.setting, YCBTSettingKey.setTime, + UInt8(year & 0xff), UInt8((year >> 8) & 0xff), + UInt8(c.month ?? 1), UInt8(c.day ?? 1), + UInt8(c.hour ?? 0), UInt8(c.minute ?? 0), UInt8(c.second ?? 0), + weekday] + } + + // MARK: - Profile / locale + + /// `01 03` + `[heightCm][weightKg][sex][age]`. The ring feeds these into its step, calorie and BP + /// algorithms, so a wrong profile is a wrong reading — PulseLoop used to replay the capture's + /// `aa 40 00 2b` blob (170 cm / 64 kg / 43 y) for every user. + /// + /// UNVERIFIED: the sex byte's polarity. The SDK never asserts the mapping (`settingUserInfo(…, sex, …)` + /// passes it straight through) and the capture carries `0x00`; we send 1 for male, 0 otherwise, + /// which is the vendor convention. A wrong value skews calorie estimates slightly and nothing else. + func userInfo(_ profile: UserProfileValues) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.userInfo, + profile.heightCm, profile.weightKg, profile.gender == 0x01 ? 1 : 0, profile.age] + } + + /// `01 04` + `[distance][weight][temp][timeFormat][bloodSugar][uricAcid]` — 0 = metric everywhere; + /// `timeFormat` is 1 for 12-hour, 0 for 24-hour (the SDK sends `!is24Hour`). Blood-sugar and + /// uric-acid units stay 0 (mmol/L, µmol/L) since PulseLoop displays neither yet. + func units(metric: Bool, is24Hour: Bool = true) -> [UInt8] { + let imperial: UInt8 = metric ? 0 : 1 + return [YCBTGroup.setting, YCBTSettingKey.units, + imperial, imperial, imperial, is24Hour ? 0 : 1, 0, 0] + } + + /// `01 12` + `[languageCode]` (the vendor's own enum; 0 = English, which is what the capture sent). + func language(_ code: UInt8 = 0) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.language, code] + } + + // MARK: - All-day monitors + + /// The five background samplers, each `{enable, intervalMinutes}`. **These — not the `05 4x` burst + /// PulseLoop used to send — are what make the ring record anything between syncs.** `05 40…4E` are + /// the Health *Delete* opcodes. + /// + /// `MeasurementSettings` has no blood-pressure flag (no other family has an all-day BP sampler), and + /// the ring derives BP from the same PPG sweep as heart rate, so BP rides the HR toggle. + /// `stressEnabled` has no YCBT monitor command — the ring stores stress in the body-data history + /// record (`05 33`), it doesn't sample it on its own schedule. + func monitorCommands(_ settings: MeasurementSettings) -> [[UInt8]] { + let interval = Self.clampInterval(settings.hrIntervalMinutes) + return [ + heartMonitor(enabled: settings.hrEnabled, intervalMinutes: interval), + bloodPressureMonitor(enabled: settings.hrEnabled, intervalMinutes: interval), + temperatureMonitor(enabled: settings.temperatureEnabled, intervalMinutes: interval), + bloodOxygenMonitor(enabled: settings.spo2Enabled, intervalMinutes: interval), + hrvMonitor(enabled: settings.hrvEnabled, intervalMinutes: interval), + ] + } + + func heartMonitor(enabled: Bool, intervalMinutes: UInt8) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.heartMonitor, enabled ? 1 : 0, intervalMinutes] + } + + func bloodPressureMonitor(enabled: Bool, intervalMinutes: UInt8) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.bloodPressureMonitor, enabled ? 1 : 0, intervalMinutes] + } + + func temperatureMonitor(enabled: Bool, intervalMinutes: UInt8) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.temperatureMonitor, enabled ? 1 : 0, intervalMinutes] + } + + func bloodOxygenMonitor(enabled: Bool, intervalMinutes: UInt8) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.bloodOxygenMonitor, enabled ? 1 : 0, intervalMinutes] + } + + /// HRV takes a 5-byte payload (`settingHRVMonitor(a,b,c,d,e)`). Only the first two args are named in + /// the SDK's own call sites; UNVERIFIED: the trailing three (window / weekday mask / reserved). + /// Zero-filled — a wrong non-zero guess could arm a schedule we didn't intend, and zeros are what + /// the SDK's own default call passes. + func hrvMonitor(enabled: Bool, intervalMinutes: UInt8) -> [UInt8] { + [YCBTGroup.setting, YCBTSettingKey.hrvMonitor, enabled ? 1 : 0, intervalMinutes, 0, 0, 0] + } +} diff --git a/PulseLoop/RingProtocol/YCBTSyncEngine.swift b/PulseLoop/RingProtocol/YCBTSyncEngine.swift new file mode 100644 index 00000000..fd563923 --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTSyncEngine.swift @@ -0,0 +1,175 @@ +import Foundation + +/// YCBT sync engine. Connect is a parameterized handshake (clock → device interrogation → locale → +/// all-day monitors → user profile → live-status stream), followed by the history sync. +/// +/// History is **not** driven from here. It is a protocol state machine in `YCBTHistoryTransfer` (owned +/// by the driver, the only thing that sees frames): request → header → data frames → terminal block → +/// mandatory ACK → next type. The engine only seeds the queue. What ends a dump is the ring's terminal +/// block, never a timer over decoded events — which is why `handle(_:)` is a no-op. +/// +/// Live HR, SpO₂ **and** HRV share one proprietary stream toggled by `03 2f` with a mode byte. +/// Protocol reference: `docs/YCBT-Protocol.md`. +@MainActor +final class YCBTSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let encoder = YCBTEncoder() + private let transfer: YCBTHistoryTransfer + + /// Every type `YCBTHealthRecords` can decode, in the SDK's own ascending-key sync order. + /// + /// Sport comes before all: sport records are additive buckets that *assign* a past day's step total + /// (sum of buckets), while the All record's cumulative counter only ever ratchets it up — so asking + /// in this order lets the counter have the last word if the ring's buckets under-report. + /// + /// A ring that doesn't implement a type answers with a no-data header or a `0xFC` (unsupported key) + /// error; `YCBTHistoryTransfer` skips both and — for `0xFC` — stops asking for the rest of the + /// session. Querying the full catalog is therefore free, and which types actually return data is + /// exactly the capability evidence the on-device checkpoint is looking for. + private static let historyTypes: [YCBTHistoryType] = [ + .sport, .sleep, .heart, .blood, .all, .spo2, .temperature, .comprehensive, .bodyData, + ] + + /// The post-workout backfill subset: only the logs a workout can have added to. Sleep, body data and + /// the metabolic panel cannot have changed in the last 40 minutes, and they are the slow transfers — + /// so a workout finishing doesn't drag the whole nine-type catalog across the link. + private static let vitalsTypes: [YCBTHistoryType] = [.heart, .all, .spo2] + + /// Pushed in by `RingSyncCoordinator` before `runStartup`, so the handshake carries the user's real + /// configuration. Defaults keep a freshly-paired ring logging until the store is read. + private var measurementSettings = MeasurementSettings.allOnDefault + private var userProfile = UserProfileValues(metric: true, sex: nil, age: nil, heightCm: nil, weightKg: nil) + + init(writer: RingCommandWriter?, transfer: YCBTHistoryTransfer) { + self.writer = writer + self.transfer = transfer + } + + // MARK: Startup + + func runStartup() { + for command in encoder.startupSequence(measurement: measurementSettings, profile: userProfile) { + writer?.enqueue(Data(command)) + } + // The transfer machine writes the first `05 ` query itself and advances off the ring's + // terminal blocks. No `.activitySyncReset` is published: history steps arrive as + // `.activityUpdate` (a per-day max ratchet) and history measurements upsert by (kind, timestamp) + // in `EventPersistenceSubscriber`, so a re-sync is already idempotent — and the reset case is a + // documented no-op in the bus. + transfer.start(types: Self.historyTypes) + } + + /// History is protocol-driven now — nothing here advances it. + func handle(_ event: RingDecodedEvent) {} + + // MARK: History passes + // + // Both re-enter `YCBTHistoryTransfer.start`, which is a no-op while a transfer is already in flight — + // so a periodic pass, a post-workout backfill and a pull-to-refresh can never cut each other short. + + /// Re-run the full queue without re-sending the connect handshake. Driven by `RingSyncCoordinator`'s + /// 30-minute periodic pass (SmartHealth's own cadence) while connected. + func syncHistory() { + transfer.start(types: Self.historyTypes) + } + + /// Post-workout backfill: pull just the vitals logs so samples the ring recorded while the phone was + /// away or suspended land in the session that just finished (`ActivityRecorderService.linkSample` + /// windows them in). + func syncVitalsHistory() { + transfer.start(types: Self.vitalsTypes) + } + + // MARK: All-day measurement config (the five `01 xx {enable, interval}` monitors) + + /// Store without sending — `runStartup` emits the monitors as part of the connect handshake. + func setMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + } + + /// Store *and* push immediately — the live "Save" path while connected. + func applyMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + for command in encoder.monitorCommands(settings) { + writer?.enqueue(Data(command)) + } + } + + // MARK: User profile (`01 03`) + + func setUserProfile(_ profile: UserProfileValues) { + userProfile = profile + } + + func applyUserProfile(_ profile: UserProfileValues) { + userProfile = profile + writer?.enqueue(Data(encoder.userInfo(profile))) + } + + // MARK: Clock / battery + + /// The ring's stored records are stamped from its own RTC in local wall-clock, so a timezone change + /// must be pushed or every subsequent record decodes to the wrong instant. + func resyncTime() { + writer?.enqueue(Data(encoder.setTime())) + } + + /// Battery is in-band: it rides the `02 00` GetDeviceInfo reply (payload[5]). + func requestBattery() { + writer?.enqueue(Data(encoder.deviceInfoRequest())) + } + + // MARK: Live actions (proprietary 06-stream on be940003, mode-selected by 03 2f) + // + // The `03 2f` payload is [enable, mode]; the mode byte picks the sensor/LED (HR 0x00 → 06 01, + // BP 0x01 → 06 03, SpO₂ 0x02 → 06 02, HRV 0x0a → 06 03). Each metric starts *and stops* its own mode + // — the stop is not mode-agnostic (see `YCBTEncoder`). Only one mode runs at a time, so start-then-stop + // per measurement is correct. + + func startHeartRate() { + writer?.enqueue(Data(encoder.heartRateStart())) + } + + func stopHeartRate() { + writer?.enqueue(Data(encoder.heartRateStop())) + } + + func startSpO2() { + writer?.enqueue(Data(encoder.spo2Start())) + } + + func stopSpO2() { + writer?.enqueue(Data(encoder.spo2Stop())) + } + + func startHRV() { + writer?.enqueue(Data(encoder.hrvStart())) + } + + func stopHRV() { + writer?.enqueue(Data(encoder.hrvStop())) + } + + /// On-demand blood pressure (`03 2f {01,01}`). The reading streams back on `06 03` as + /// `[SBP][DBP][hr]…` — the same frame the HRV mode uses, at fixed offsets — and the ring also pushes a + /// `04 13` status/result frame as it goes. `RingSyncCoordinator.measureBloodPressure` polls for the + /// pair and stops the sweep. + func startBloodPressure() { + writer?.enqueue(Data(encoder.bloodPressureStart())) + } + + func stopBloodPressure() { + writer?.enqueue(Data(encoder.bloodPressureStop())) + } + + func findDevice() { + writer?.enqueue(Data(encoder.findDevice())) + } + + func setGoal(steps: Int) { + // `SettingGoal 01 02` exists in the SDK but its payload shape is unverified for this ring; + // PulseLoop persists the goal app-side regardless. + } +} diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 5883dbd4..56aa3a8b 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -211,6 +211,10 @@ enum MetricsService { value = Double(Int.random(in: 20...70)) case .bloodSugar: value = Double(Int.random(in: 85...110)) + case .respiratoryRate: + value = Double(Int.random(in: 12...18)) + case .vo2max: + value = Double(Int.random(in: 35...50)) } let row = MeasurementRepository.insertMeasurement( kind: kind, diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index bbb32e37..5c4536c8 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -127,6 +127,12 @@ final class RingSyncCoordinator { private var streamTask: Task? private var clockChangeTask: Task? + private var periodicSyncTask: Task? + + /// SmartHealth re-reads its ring's stored history every 30 minutes while connected; PulseLoop matches + /// that cadence. The ring has no cursor — it always dumps everything it holds for a type — so a + /// tighter interval would just re-transfer the same records for nothing. + private let periodicSyncInterval: TimeInterval = 30 * 60 init(client: RingBLEClient, context: ModelContext) { self.client = client @@ -145,33 +151,6 @@ final class RingSyncCoordinator { observeClockChanges() } - /// The jring's RTC runs on local wall-clock time — its sleep detection and day-indexed history - /// queries key off it. When the phone crosses a timezone or a DST boundary, push the new clock so - /// the ring doesn't keep bucketing days at the old offset. Rings whose firmware ignores its own - /// RTC get a default no-op `resyncTime()`. - private func observeClockChanges() { - guard clockChangeTask == nil else { return } - let names: [Notification.Name] = [ - .NSSystemTimeZoneDidChange, // user switched timezone - UIApplication.significantTimeChangeNotification, // DST rollover / midnight / clock set - ] - clockChangeTask = Task { [weak self] in - await withTaskGroup(of: Void.self) { group in - for name in names { - group.addTask { - for await _ in NotificationCenter.default.notifications(named: name) { - guard let self else { return } - await MainActor.run { - guard self.client.state == .connected else { return } - self.engine?.resyncTime() - } - } - } - } - } - } - } - // MARK: - Actions /// Canonical startup sequence run on connect. Delegated to the active device's sync engine @@ -522,9 +501,13 @@ final class RingSyncCoordinator { // Ring came back mid-workout: the new connection's engine doesn't know a stream was // running, so re-issue the live HR command. restartWorkoutHeartRateIfActive() + startPeriodicSync() case let .deviceStateChanged(state, _): // Any non-connected transition (disconnect / failure) ends an in-flight sync. - if state != .connected { endSync() } + if state != .connected { + endSync() + stopPeriodicSync() + } case let .syncProgress(stage): updateSync(stage: stage) default: @@ -536,7 +519,13 @@ final class RingSyncCoordinator { /// Apply a `.syncProgress` stage. The `"done"` sentinel (emitted on history-sync finish) ends /// the sync; any other stage keeps the bar up and re-arms the stall timeout. + /// + /// A stage — any stage — is the one signal that the ring is *actually* handing data over, which is + /// what "Synced Xm ago" claims. Stamping it here (rather than at the periodic tick, where + /// `syncHistory()` is a no-op on jring/Colmi) keeps the label honest for the families whose history + /// only comes down with the connect handshake. private func updateSync(stage: String) { + lastSyncAt = Date() guard stage != "done" else { endSync(); return } syncStage = stage armSyncTimeout() @@ -610,6 +599,77 @@ final class RingSyncCoordinator { } } +// MARK: - Long-lived background tasks + +/// The coordinator's two open-ended tasks, kept out of the (already large) class body: both are armed +/// once per connection, both are `[weak self]` so a deallocated coordinator ends them, and neither owns +/// state beyond the `Task` handle it parks back on the coordinator. +private extension RingSyncCoordinator { + /// The jring's RTC runs on local wall-clock time — its sleep detection and day-indexed history + /// queries key off it. When the phone crosses a timezone or a DST boundary, push the new clock so + /// the ring doesn't keep bucketing days at the old offset. Rings whose firmware ignores its own + /// RTC get a default no-op `resyncTime()`. + func observeClockChanges() { + guard clockChangeTask == nil else { return } + let names: [Notification.Name] = [ + .NSSystemTimeZoneDidChange, // user switched timezone + UIApplication.significantTimeChangeNotification, // DST rollover / midnight / clock set + ] + clockChangeTask = Task { [weak self] in + await withTaskGroup(of: Void.self) { group in + for name in names { + group.addTask { + for await _ in NotificationCenter.default.notifications(named: name) { + guard let self else { return } + await MainActor.run { + guard self.client.state == .connected else { return } + self.engine?.resyncTime() + } + } + } + } + } + } + } + + /// Top up the app's copy of the ring's log every 30 minutes **while connected**. + /// + /// This is emphatically *not* background sync: iOS gives us no BLE time while the ring is + /// disconnected, so a ring worn away from the phone still only hands over its log on the next + /// connect, and the docs must keep saying so. This only closes the "app has been open for hours and + /// the data is still from the connect handshake" gap. + /// + /// Suppressed while a transfer is already running (`isSyncing`) or a workout is streaming live HR — a + /// history dump would compete with the stream for the link, and `syncWorkoutVitals()` already covers + /// that window at finish. `YCBTHistoryTransfer.start` refuses a re-entrant transfer anyway, so this is + /// the polite half of a belt-and-braces pair. Rings whose history only comes down with the handshake + /// (jring/Colmi) get a no-op `syncHistory()`, so the timer costs them nothing. + /// + /// Armed from `.deviceStateChanged(.connected,)`, which is re-published on every `02 00` reply and not + /// just on the first connect — hence the idempotence guard. + func startPeriodicSync() { + guard periodicSyncTask == nil else { return } + let interval = UInt64(periodicSyncInterval * 1_000_000_000) + periodicSyncTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: interval) + guard !Task.isCancelled, let self else { return } + guard self.isConnected, !self.isSyncing, !self.workoutHRActive else { continue } + // The engine's own `.syncProgress` stages drive the progress bar (and its `"done"` stage + // clears it), so a ring with no new records shows nothing. They also stamp `lastSyncAt` + // — the tick itself must not, or a family whose `syncHistory()` is a no-op (jring/Colmi) + // would show "Synced just now" every 30 minutes without a byte on the wire. + self.engine?.syncHistory() + } + } + } + + func stopPeriodicSync() { + periodicSyncTask?.cancel() + periodicSyncTask = nil + } +} + // MARK: - RingSyncGating /// The coach-notification path drives sync through this narrow seam so it can be faked in tests. diff --git a/PulseLoop/Views/PairingComponents.swift b/PulseLoop/Views/PairingComponents.swift index d09d5ff1..46742b0e 100644 --- a/PulseLoop/Views/PairingComponents.swift +++ b/PulseLoop/Views/PairingComponents.swift @@ -58,6 +58,54 @@ struct SupportBadge: View { } } +// MARK: - AppVariantPicker + +/// "Which app came with your ring?" — a segmented pick of the native app a model ships with. +/// +/// This is a question and not a detection because it cannot be detected: the same physical Colmi ring +/// is sold with either the QRing or the SmartHealth firmware, the two speak entirely different +/// protocols, and the OEM sets the local name — so both can advertise `R09_ABCD`. The scan can only +/// *hint* (see `ColmiSmartHealthCoordinator.Advertisement`, all provisional); the user's answer is what +/// actually selects the driver. Cards with a single firmware show no picker. +/// +/// Styled as the brand pills are (glass capsule, accent-tinted when selected) so it reads as one more +/// filter on the same screen rather than a settings control that wandered in. +struct AppVariantPicker: View { + let options: [AppVariantOption] + @Binding var selection: RingAppVariant + + var body: some View { + VStack(spacing: 6) { + Text("Which app came with your ring?") + .font(PulseFont.caption2) + .foregroundStyle(PulseColors.textMuted) + HStack(spacing: 8) { + ForEach(options) { option in + let isSelected = option.variant == selection + Button { + selection = option.variant + } label: { + Text(option.variant.displayName) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(isSelected ? .white : PulseColors.textSecondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .pulseGlass(Capsule(), interactive: true, tint: isSelected ? PulseColors.accent : nil) + .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isSelected) + } + .buttonStyle(.plain) + .frame(minHeight: 44) + .contentShape(Capsule()) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + } + .pulseGlassContainer(spacing: 8) // morph the pills as the selection moves + } + .frame(maxWidth: .infinity) + .sensoryFeedback(.selection, trigger: selection) + } +} + // MARK: - SignalStrengthDots /// Three small circles indicating BLE signal strength for a discovered ring. diff --git a/PulseLoop/Views/PairingView.swift b/PulseLoop/Views/PairingView.swift index ad420341..d88ac75c 100644 --- a/PulseLoop/Views/PairingView.swift +++ b/PulseLoop/Views/PairingView.swift @@ -45,6 +45,11 @@ struct PairingView: View { @State private var isLooking = false @State private var didFireConnected = false @State private var connectedAppeared = false + /// The app variant the user picked, or nil while they haven't touched the picker — which is what + /// lets the discovery hint supply the default without ever overriding a deliberate choice. + @State private var pickedVariant: RingAppVariant? + /// The ring the user last tapped, so the wrong-variant recovery can re-dial the same one. + @State private var lastAttemptedRingID: UUID? private static let allBrandsTab = "All" private let allModels = WearableModel.catalog @@ -71,11 +76,62 @@ struct PairingView: View { return models[min(selectedIndex, models.count - 1)] } - /// Discovered rings whose matched family equals the selected model's family (recognized first), - /// falling back to all named devices if nothing matches yet so the user is never stuck. + // MARK: - App variant (which native app the selected ring shipped with) + + /// The app the *advertisement* hints at, from the family the scan tagged a discovered row with. Only + /// ever a default: a QRing- and a SmartHealth-Colmi can advertise the identical local name, so the + /// hint is provisional by construction (`ColmiSmartHealthCoordinator.Advertisement`) and the user's + /// pick is the authority. + private var hintedVariant: RingAppVariant? { + ble.discovered.lazy.compactMap(\.deviceType).compactMap(selectedModel.variant(for:)).first + } + + /// The card's current variant — what the picker shows and what the blurb/badge describe. Row-free: + /// a *connect* resolves the variant against the tapped row instead (`connectingVariant(for:)`). + /// nil for a single-firmware card (no picker, no override — auto-detection as before). + private var effectiveVariant: RingAppVariant? { + selectedModel.variant(picked: pickedVariant, rowFamily: nil, hinted: hintedVariant) + } + + private var variantBinding: Binding { + Binding( + get: { effectiveVariant ?? .qring }, + set: { pickedVariant = $0 } + ) + } + + /// The app a tap on this row would connect as (see `WearableModel.variant(picked:rowFamily:hinted:)`). + private func connectingVariant(for ring: RingBLEClient.DiscoveredRing) -> RingAppVariant? { + selectedModel.variant(picked: pickedVariant, rowFamily: ring.deviceType, hinted: hintedVariant) + } + + /// Connect to a tapped row, driving the app *that row* resolves to. + /// + /// The picker is parked on that same app first, so everything the screen then says about the attempt + /// — the blurb, the "Limited support" badge, and the one-tap `variantRetry` — describes the driver + /// actually in flight, rather than a hint that may have come from a different ring in the scan. + private func connect(to ring: RingBLEClient.DiscoveredRing) { + lastAttemptedRingID = ring.id + let variant = connectingVariant(for: ring) + pickedVariant = variant + ble.connect( + to: ring.id, + selectedModelID: selectedModel.id, + preferredFamily: selectedModel.preferredFamily( + picked: variant, + rowFamily: ring.deviceType, + hinted: hintedVariant + ) + ) + } + + /// Discovered rings whose matched family is one the selected model can resolve to (recognized + /// first), falling back to all named devices if nothing matches yet so the user is never stuck. + /// Matching on *every* family the card can be — not just its default — is what keeps a row the scan + /// tagged `.colmiSmartHealth` visible under the Colmi card. private var matchingRings: [RingBLEClient.DiscoveredRing] { - let family = selectedModel.family - let matches = ble.discovered.filter { $0.deviceType == family } + let families = selectedModel.families + let matches = ble.discovered.filter { $0.deviceType.map(families.contains) ?? false } return matches.isEmpty ? ble.discovered : matches } @@ -154,11 +210,14 @@ struct PairingView: View { if let error = ble.lastError, ble.state != .connected, !forcePairingUIForTesting { - Text(error) - .font(.caption) - .foregroundStyle(PulseColors.danger) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + VStack(spacing: 10) { + Text(error) + .font(.caption) + .foregroundStyle(PulseColors.danger) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + variantRetry + } } } @@ -184,13 +243,15 @@ struct PairingView: View { TabView(selection: $selectedIndex) { ForEach(Array(models.enumerated()), id: \.element.id) { index, model in + // The picker only applies to the card in front; every other page shows its default. + let variant = model.id == selectedModel.id ? effectiveVariant : nil VStack(spacing: 10) { RingArtView(tint: model.tint, imageName: model.imageName) Text(model.displayName) .font(PulseFont.numberL) .foregroundStyle(PulseColors.textPrimary) - SupportBadge(level: model.supportLevel) // nothing for fully-supported models - CapabilityChips(blurb: model.blurb) // §2 replaces blurb Text + SupportBadge(level: model.supportLevel(for: variant)) // nothing when fully supported + CapabilityChips(blurb: model.blurb(for: variant)) // §2 replaces blurb Text } .frame(maxWidth: .infinity) // constant page width so content doesn't drive reflow .tag(index) @@ -204,11 +265,23 @@ struct PairingView: View { .id(selectedBrand) // recreate on brand change so pages swap instantly (no page-slide) modelDotRow // §2 fixed-height dot area keeps layout stable across tabs + + // Below the carousel, not on the page: the app variant is a property of the *selection*, and + // pages that have no variants (jring, TK5) must not grow a gap where the picker would be. + if !selectedModel.appVariants.isEmpty { + AppVariantPicker(options: selectedModel.appVariants, selection: variantBinding) + .padding(.top, 2) + } } .onChange(of: selectedIndex) { _, _ in // Re-scan/filter as the user changes their selected model. if isLooking { ble.startScanning() } } + .onChange(of: selectedModel.id) { _, _ in + // A different ring is a different question: drop the previous card's answer so the new one + // starts from its own discovery hint. + pickedVariant = nil + } } /// Page position shown as a liquid-glass scroll bar: a faint glass track with an @@ -319,7 +392,7 @@ struct PairingView: View { VStack(spacing: 8) { ForEach(matchingRings) { ring in Button { - ble.connect(to: ring.id, selectedModelID: selectedModel.id) + connect(to: ring) } label: { ringRow(ring) } @@ -345,13 +418,36 @@ struct PairingView: View { .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } + /// The wrong-pick escape hatch. A user-initiated connect that dies in `.failed` on a two-app card is, + /// far more often than not, the other app's driver — the ring never answered because we were speaking + /// the wrong protocol at it. One tap flips the picker and re-dials the same ring, which is the whole + /// reason `RingBLEClient` bothers to time the attempt out instead of spinning forever. + @ViewBuilder + private var variantRetry: some View { + if ble.state == .failed, + let variant = effectiveVariant, + let other = selectedModel.otherVariant(than: variant), + let ringID = lastAttemptedRingID { + SecondaryButton(title: "Try as \(other.displayName)", systemImage: "arrow.triangle.2.circlepath") { + pickedVariant = other + ble.connect( + to: ringID, + selectedModelID: selectedModel.id, + preferredFamily: selectedModel.family(for: other) + ) + } + } + } + private func ringRow(_ ring: RingBLEClient.DiscoveredRing) -> some View { let signalLevel: String = ring.rssi >= -65 ? "Strong signal" : ring.rssi >= -80 ? "Medium signal" : "Weak signal" - // Identity wins when the advertisement resolved a model; otherwise fall back to the family. - let support = WearableModel.model(id: ring.wearableModelID)?.supportLevel - ?? ring.deviceType?.supportLevel ?? .full + // Maturity is a property of the family we would actually drive, so the matched family wins over + // the model card: a Colmi tagged `.colmiSmartHealth` is an unproven driver even though the same + // card, on QRing, is a fully-supported one. Falls back to the model when the scan matched nothing. + let support = ring.deviceType?.supportLevel + ?? WearableModel.model(id: ring.wearableModelID)?.supportLevel ?? .full return HStack { Image(systemName: ring.isLikelyRing ? "circle.hexagongrid.circle.fill" : "dot.radiowaves.left.and.right") .foregroundStyle(ring.isLikelyRing ? PulseColors.accent : PulseColors.textMuted) diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index be205b91..6f1d69df 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -234,7 +234,9 @@ struct DeviceHeroCard: View { private func ringImageName(for type: RingDeviceType?) -> String? { switch type { case .jring: return "jring" - case .colmiR02: return nil + // Both Colmi families are the same physical ring line (they differ only in firmware), and the + // line has no single representative image — fall back to the generic ring. + case .colmiR02, .colmiSmartHealth: return nil case .tk5: return "tk5" case nil: return nil } diff --git a/PulseLoop/Views/Settings/MeasurementSettingsView.swift b/PulseLoop/Views/Settings/MeasurementSettingsView.swift index ae2bccd5..39d27114 100644 --- a/PulseLoop/Views/Settings/MeasurementSettingsView.swift +++ b/PulseLoop/Views/Settings/MeasurementSettingsView.swift @@ -8,7 +8,9 @@ import SwiftData /// /// Note: on the jring only the HR interval maps to a command — SpO₂/stress/HRV/temperature ride its /// combined-sensor packet and its background log, with no per-metric opcode to switch them off, so -/// those toggles are inert there. +/// those toggles are inert there. On the TK5 the same is true of **stress alone**: the ring derives it +/// from the HRV sweep and files it in the body-data record, and the YCBT setting catalog has no stress +/// monitor key to toggle (SpO₂/HRV/temperature each do have one). struct MeasurementSettingsView: View { @Environment(\.modelContext) private var modelContext @Environment(RingBLEClient.self) private var ble diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index c9f51df1..35abc046 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -7,6 +7,11 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case jring case colmiR02 case tk5 + /// Colmi rings that ship with the **SmartHealth** app instead of QRing. Same hardware line as + /// `.colmiR02`, entirely different firmware: they speak YCBT (the TK5's protocol), so they share + /// that driver, not `ColmiDriver`. Which of the two a given ring is cannot be read off its + /// advertisement — the user declares it at pairing (see `RingAppVariant`). + case colmiSmartHealth /// Human-facing default name when no advertised name is available. var displayName: String { @@ -14,6 +19,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .jring: return "SMART_RING" case .colmiR02: return "Colmi / Yawell ring" case .tk5: return "TK5 ring" + case .colmiSmartHealth: return "Colmi ring (SmartHealth)" } } } @@ -43,6 +49,19 @@ protocol WearableCoordinator { /// Everything this device can do — drives capability-gated UI. var capabilities: Set { get } + /// Capabilities this family will accept **only if the connected unit's own capability bitmap claims + /// them** (YCBT's `02 01` reply; see `YCBTSupportFunction`). Empty by default. + /// + /// Exists because a *family* is not a *SKU*: two Colmi rings that speak the identical protocol can + /// differ on whether they have a temperature or blood-pressure sensor at all. A static set is then + /// wrong in one of two ways — it hides a metric the ring really has, or it promises one it doesn't + /// and the card renders permanently empty. Listing a capability here says "this family *may* have + /// it; ask the device." + /// + /// Deliberately additive-only (see `refinedCapabilities`): the bitmap can never remove a baseline + /// capability, so a family that lists nothing here is bit-for-bit unaffected by any of this. + var bitmapGatedCapabilities: Set { get } + /// Fallback display name + icon for the device card / discovered-ring rows. var displayName: String { get } var iconSystemName: String { get } @@ -54,4 +73,22 @@ protocol WearableCoordinator { extension WearableCoordinator { var displayName: String { Self.deviceType.displayName } + + /// Nothing is bitmap-gated unless a family opts in, so jring / QRing-Colmi / TK5 keep their static + /// sets and never consult a bitmap. + var bitmapGatedCapabilities: Set { [] } + + /// Fold a device-reported capability bitmap into this family's capability set. + /// + /// `baseline ∪ (gated ∩ derived)` — the bitmap may only **add**, and only from the set the family + /// pre-approved. Two properties fall out of that, and both are load-bearing: + /// + /// - A bitmap can never *remove* a baseline capability. Firmware bitmaps under-report in the field + /// (an old firmware simply sends a shorter array), and a device whose metric card vanished mid- + /// session because of a truncated reply is a worse bug than one extra card. + /// - A bitmap can never *add* a capability the family didn't list. A bit we mapped wrongly, or a + /// metric PulseLoop has no decoder for, therefore cannot conjure a UI surface out of nothing. + func refinedCapabilities(bitmapDerived: Set) -> Set { + capabilities.union(bitmapGatedCapabilities.intersection(bitmapDerived)) + } } diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 747e6e8b..baf170d4 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -46,6 +46,18 @@ protocol WearableDriver: AnyObject { /// checksum verification and reassembly is hidden here. func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] + /// A GATT link has just come up. Auto-reconnect re-uses the *same* driver instance (only a fresh + /// pairing rebuilds it), so any partially-reassembled frame or in-flight transfer left over from + /// the dropped link must be discarded here or it corrupts the first frames of the new one. + /// Default: no-op (jring/Colmi carry no cross-connection reassembly state). + func connectionDidStart() + + /// The GATT link has dropped. Clearing on the *next* connect is not enough for a driver whose state + /// machine has its own timers: they keep firing across the reconnect gap and enqueue commands into a + /// write queue that the new link will happily flush *before* the handshake. Anything self-driving + /// must be stopped here. Default: no-op. + func connectionDidEnd() + /// The stateful brain: startup sequence + (for Colmi) the response-driven history machine. func makeSyncEngine() -> RingSyncEngine } @@ -54,6 +66,8 @@ extension WearableDriver { /// Most devices have a single write characteristic. var commandUUID: CBUUID? { nil } func usesCommandChannel(for frame: Data) -> Bool { false } + func connectionDidStart() {} + func connectionDidEnd() {} } /// User-chosen all-day measurement configuration, passed as a plain value from the app layer into a @@ -139,7 +153,7 @@ protocol RingSyncEngine: AnyObject { func startCombinedVitals() func stopCombinedVitals() /// Start/stop an on-demand HRV stream. Only devices whose live protocol has a dedicated HRV mode - /// (TK5) implement these; the default is a no-op (see the extension below). + /// (YCBT) implement these; the default is a no-op (see the extension below). func startHRV() func stopHRV() func findDevice() @@ -179,6 +193,12 @@ protocol RingSyncEngine: AnyObject { /// vitals log do nothing (default no-op below). func syncVitalsHistory() + /// Re-run the ring's history pass **without** re-sending the connect handshake — the periodic + /// top-up while connected (`RingSyncCoordinator`'s 30-minute timer). Only devices whose history is a + /// standalone, re-runnable transfer implement it (YCBT); on the others it is a no-op and the + /// timer costs nothing, rather than them re-handshaking on a timer they never asked for. + func syncHistory() + /// Re-request the ring's current battery level over its own protocol (Colmi: 0x03). jring reports /// battery via GATT (read separately by the client), so it has nothing to do here — default no-op. func requestBattery() @@ -197,7 +217,7 @@ extension RingSyncEngine { func startHRV() {} func stopHRV() {} - /// Default: devices without a blood-pressure sensor (Colmi/TK5) have nothing to measure. + /// Default: devices without a blood-pressure sweep (Colmi) have nothing to measure. func startBloodPressure() {} func stopBloodPressure() {} @@ -226,6 +246,10 @@ extension RingSyncEngine { /// Default: devices without a readable vitals log have nothing to backfill. func syncVitalsHistory() {} + /// Default: devices whose history only comes down as part of the connect handshake have no + /// standalone pass to re-run. + func syncHistory() {} + /// Default: devices that report battery over GATT (e.g. jring) have no in-band battery command. func requestBattery() {} } diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index bc76f080..54c7d0ef 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -12,6 +12,8 @@ struct WearableModel: Identifiable { let displayName: String /// Marketing brand, used to group models under the pairing screen's brand tabs. let brand: String + /// The family this card resolves to by default — i.e. when it has no `appVariants`, or before the + /// user has picked one. let family: RingDeviceType let tint: Color let blurb: String @@ -21,11 +23,71 @@ struct WearableModel: Identifiable { /// Asset-catalog image name for this ring's product art. When nil, `RingArtView` falls back to a /// generic ring photo. Set this per model once real ring images are added to the catalog. var imageName: String? = nil + /// The native apps this exact product is sold with, when there is more than one (the Colmi line). + /// Empty for single-firmware models, which stay fully auto-detected and show no picker. + var appVariants: [AppVariantOption] = [] /// Maturity of this model's driver, mirrored from its `family` (the real source of truth). var supportLevel: WearableSupportLevel { family.supportLevel } } +/// Which phone app a ring shipped with — and therefore which protocol its firmware speaks. +/// +/// The Colmi line is sold with **two firmwares**: QRing's (the GadgetBridge-derived Yawell protocol) +/// and SmartHealth's (Yucheng YCBT — byte-identical to what the TK5 speaks). The local name is set by +/// the OEM, not by the app, so an R09 of either kind can advertise `R09_ABCD`: no part of the +/// advertisement reliably separates them. This is therefore a **user-declared** fact, asked for at +/// pairing, and the answer — not the scan — picks the driver. +/// +/// Raw values are display copy; nothing persists them (the *family* is what's persisted). +enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { + case qring = "QRing" + case smartHealth = "SmartHealth" + + var id: String { rawValue } + var displayName: String { rawValue } + + /// The app whose driver serves this family, or nil for a family sold with only one app. + /// Exhaustive on purpose: a new family must state whether it has a sibling. + init?(family: RingDeviceType) { + switch family { + case .colmiR02: self = .qring + case .colmiSmartHealth: self = .smartHealth + case .jring, .tk5: return nil + } + } + + /// The sibling app — the one to offer after a wrong pick. There are exactly two. + var other: RingAppVariant { self == .qring ? .smartHealth : .qring } +} + +/// One entry in a model card's app-variant picker: the app, the driver family picking it selects, and +/// the capability blurb to show while it is picked (the two firmwares expose different metric sets). +struct AppVariantOption: Identifiable { + let variant: RingAppVariant + let family: RingDeviceType + let blurb: String + + var id: String { variant.rawValue } +} + +/// Copy for a user-initiated connect attempt that never completed. Pure, so the pairing screen's +/// recovery affordance and its tests reason about exactly the message `RingBLEClient` will show. +/// +/// The wrong-app pick is the failure this exists for: choose SmartHealth for a QRing ring and the +/// installed driver looks for service UUIDs the ring does not have — the BLE link opens, GATT +/// discovery finds nothing, `.connected` never arrives. "Couldn't connect" would give the user nothing +/// to act on, so the message names the app we tried and the one to try instead. +enum RingConnectFailure { + static func message(family: RingDeviceType?) -> String { + guard let family, let variant = RingAppVariant(family: family) else { + return "The ring didn't respond. Move it closer, wake it by tapping it, and try again." + } + return "This ring didn't answer as a \(variant.displayName) ring. If it came with the " + + "\(variant.other.displayName) app, switch the app type and try again." + } +} + /// How proven PulseLoop's driver for a wearable *family* is. Drives the "Limited support" badge in /// pairing/onboarding/Settings. Families we've shipped and tested are `.full` and render no badge at /// all; a family reconstructed from thin evidence is `.limited`. @@ -47,7 +109,10 @@ extension RingDeviceType { var supportLevel: WearableSupportLevel { switch self { case .jring, .colmiR02: return .full - case .tk5: return .limited + // Both YCBT families are unproven on hardware. The SmartHealth-Colmi has never been connected + // at all — its advertisement, its capability bitmap and its history types are all still + // predictions (plan B6 is what settles them). + case .tk5, .colmiSmartHealth: return .limited } } } @@ -73,7 +138,8 @@ extension WearableModel { ) static let colmiR12 = colmiFamily("colmi-r12", "Colmi R12", brand: "Colmi", pattern: "^COLMI R12_.*") - // TK5 — its own protocol (be940 service, SmartHealth app). Advertises as "TK5 <4 hex>". + // TK5 — the YCBT protocol (be940 service, SmartHealth app), shared with the SmartHealth-flavoured + // Colmi rings. Advertises as "TK5 <4 hex>", which is unambiguous, so it needs no app-variant picker. // Blurb mirrors `TK5Coordinator.capabilities`; the driver is `.limited` (see `supportLevel`). static let tk5 = WearableModel( id: "tk5", displayName: "TK5", brand: "TK", family: .tk5, @@ -87,6 +153,25 @@ extension WearableModel { static let yawellR11 = colmiFamily("yawell-r11", "Yawell R11", brand: "Yawell", pattern: "^R11_[0-9A-F]{4}$") static let h59 = colmiFamily("h59", "H59 Ring", brand: "H59", pattern: "^H59_.*") + /// Every Colmi-line ring is sold with one of two apps, and its advertised name doesn't say which — + /// so every Colmi card offers both. QRing is listed first: it is the mature, hardware-proven driver + /// and therefore the default when nothing in the scan hints otherwise. + /// + /// The blurbs differ because the firmwares do. The SmartHealth one lists only what *every* YCBT ring + /// certainly does (`ColmiSmartHealthCoordinator.capabilities`); its per-SKU sensors — temperature, + /// blood pressure, stress — are claimed at connect time from the ring's own capability bitmap, so + /// promising them on the card would be a promise we can't keep for every unit. + private static let colmiAppVariants: [AppVariantOption] = [ + AppVariantOption( + variant: .qring, family: .colmiR02, + blurb: "HR · SpO₂ · HRV · Stress · Temp · Sleep" + ), + AppVariantOption( + variant: .smartHealth, family: .colmiSmartHealth, + blurb: "HR · SpO₂ · HRV · Sleep · Steps" + ), + ] + private static func colmiFamily( _ id: String, _ name: String, @@ -98,10 +183,87 @@ extension WearableModel { WearableModel( id: id, displayName: name, brand: brand, family: .colmiR02, tint: PulseColors.hrv, blurb: "HR · SpO₂ · HRV · Stress · Temp · Sleep", - advertisedNamePatterns: [pattern], imageName: imageName ?? id + advertisedNamePatterns: [pattern], imageName: imageName ?? id, + appVariants: colmiAppVariants ) } + // MARK: - App variants + + /// Every driver family this card can resolve to — its default plus each app variant's. The pairing + /// screen filters discovered rows on this, so a Colmi row the scan tagged `.colmiSmartHealth` is + /// still offered under the Colmi card the user is looking at. + var families: Set { Set([family] + appVariants.map(\.family)) } + + /// The driver family a picked app selects. `nil` (nothing picked, or a single-firmware card) keeps + /// the card's default family, so non-variant models behave exactly as before. + func family(for variant: RingAppVariant?) -> RingDeviceType { + option(for: variant)?.family ?? self.family + } + + /// The capability chips to show for a picked app — the two firmwares expose different metric sets. + func blurb(for variant: RingAppVariant?) -> String { + option(for: variant)?.blurb ?? self.blurb + } + + func supportLevel(for variant: RingAppVariant?) -> WearableSupportLevel { + family(for: variant).supportLevel + } + + /// The app implied by a family this card can resolve to — used to default the picker from what the + /// scan tagged a discovered row. + func variant(for family: RingDeviceType) -> RingAppVariant? { + appVariants.first { $0.family == family }?.variant + } + + /// The app to drive when connecting to **one specific discovered row**, in strict precedence: + /// the user's own pick, then the family the scan tagged *that row* with, then the card-level hint, + /// then the card's default. + /// + /// `rowFamily` outranking `hinted` is the load-bearing part. The hint is a property of the *card*, + /// taken from the first variant-mapped ring in the whole scan — which may be a completely different + /// peripheral. With a QRing-Colmi and a SmartHealth-Colmi both in range (this project's owner has + /// exactly that), letting the hint decide would drive one ring with the other ring's protocol even + /// though the scan had already identified both correctly. A row that the scan claimed knows better + /// than a hint borrowed from its neighbour; only a deliberate pick beats it. + /// + /// nil for a single-firmware card (no picker, no override — auto-detection exactly as before). + func variant( + picked: RingAppVariant?, + rowFamily: RingDeviceType?, + hinted: RingAppVariant? + ) -> RingAppVariant? { + guard !appVariants.isEmpty else { return nil } + return picked ?? rowFamily.flatMap(variant(for:)) ?? hinted ?? appVariants.first?.variant + } + + /// The family to *force* on a connect to one discovered row, or nil to leave the scan's auto-match + /// in charge. + /// + /// Only an app-variant card overrides, and only for a row that could plausibly be it: forcing the + /// carousel's family on an unrelated row would hand a jring the Colmi driver merely because the user + /// hadn't swiped away from the Colmi card. A row the scan recognized as *nothing* is fair game — the + /// alternative is the jring fallback, which is certainly wrong. + func preferredFamily( + picked: RingAppVariant?, + rowFamily: RingDeviceType?, + hinted: RingAppVariant? + ) -> RingDeviceType? { + guard let variant = variant(picked: picked, rowFamily: rowFamily, hinted: hinted) else { return nil } + guard rowFamily.map(families.contains) ?? true else { return nil } + return family(for: variant) + } + + /// The other app on this card, offered as one-tap recovery after a wrong pick. + func otherVariant(than variant: RingAppVariant) -> RingAppVariant? { + appVariants.first { $0.variant != variant }?.variant + } + + private func option(for variant: RingAppVariant?) -> AppVariantOption? { + guard let variant else { return nil } + return appVariants.first { $0.variant == variant } + } + /// Every supported model. The pairing screen groups these by `brand` and sorts each tab /// alphabetically, so this array's order is not user-visible. static let catalog: [WearableModel] = [ @@ -129,15 +291,20 @@ extension WearableModel { /// Bluetooth identity wins when available; the user's carousel choice is the fallback for /// service-only or otherwise generic advertisements. + /// + /// Matched on `families`, not `family`, so a card still identifies the ring once an app variant has + /// re-pointed the connection at a different driver — an R09 connecting as `.colmiSmartHealth` is + /// still a "Colmi R09", and without this it would resolve to no model at all and the device card + /// would lose its name and product art. static func resolve( advertisedName: String?, selectedModelID: String?, family: RingDeviceType ) -> WearableModel? { - if let detected = model(advertisedName: advertisedName), detected.family == family { + if let detected = model(advertisedName: advertisedName), detected.families.contains(family) { return detected } - if let selected = model(id: selectedModelID), selected.family == family { + if let selected = model(id: selectedModelID), selected.families.contains(family) { return selected } return family == .jring ? .jring : nil diff --git a/PulseLoopTests/ColmiDecoderTests.swift b/PulseLoopTests/ColmiDecoderTests.swift index 482efb29..7818ea84 100644 --- a/PulseLoopTests/ColmiDecoderTests.swift +++ b/PulseLoopTests/ColmiDecoderTests.swift @@ -195,6 +195,23 @@ final class ColmiDecoderTests: XCTestCase { XCTAssertEqual(spo2s.first, 97) } + /// The QRing-Colmi SpO₂ log is emitted with no floor of its own (`lo > 0, hi > 0`), and before the + /// shared bridge grew a per-kind history gate, every value it produced was persisted. A severe + /// nocturnal desaturation is the reading a user with sleep apnea most needs kept — the gate is there + /// to drop the ring's "no sample" fillers and misframed bytes, not real hypoxemia — so it has to + /// survive the whole decoder → bridge path, not just the decoder. + func testSevereDesaturationSurvivesTheSharedHistoryGate() throws { + var payload: [UInt8] = [0x00, 60, 76] // hour 0: min 60 %, max 76 % → mean 68 % + payload.append(contentsOf: [UInt8](repeating: 0, count: 46)) + let frame = bigData(type: ColmiCommandID.bigDataSpo2, payload: payload) + let decoded = try XCTUnwrap(decoder.decodeBigData(frame, calendar: calendar).first) + guard case let .historyMeasurement(kind, value, _) = decoded, kind == .spo2 else { + return XCTFail("expected an SpO₂ history sample, got \(decoded.kind)") + } + XCTAssertEqual(value, 68) + XCTAssertEqual(RingEventBridge.events(for: decoded).count, 1, "the bridge dropped a real desaturation") + } + func testSleepBigDataMapsStages() { // packetLength ≥ 2; days=1; day record: daysAgo=0, dayBytes=8, start=480(08:00), end=540(09:00), // then stage pairs: (deep,30)(rem,30). j runs 4..( + predicate: #Predicate { $0.kindRaw == kindRaw } + )) + XCTAssertEqual(rows.count, 1, "\(sample.kind) never reached SwiftData") + XCTAssertEqual(rows.first?.value ?? .nan, sample.value, accuracy: 0.001) + XCTAssertEqual(rows.first?.unit, sample.unit) + XCTAssertEqual(rows.first?.sourceRaw, MeasurementSource.history.rawValue) + } + } + /// Live samples are events, not log slots: two readings at the same instant both persist. func testLiveSamplesAreNotDeduplicated() throws { let context = try TestSupport.makeContext() diff --git a/PulseLoopTests/MeasurementSettingsTests.swift b/PulseLoopTests/MeasurementSettingsTests.swift index fd817587..590c69c2 100644 --- a/PulseLoopTests/MeasurementSettingsTests.swift +++ b/PulseLoopTests/MeasurementSettingsTests.swift @@ -57,18 +57,22 @@ final class MeasurementSettingsTests: XCTestCase { // MARK: - Capability gating /// Colmi configures its interval via the 0x16 pref; jring via byte [6] of its 0x19 background - /// monitoring command. TK5 has no interval command. + /// monitoring command; TK5 via the five YCBT monitor writes (`01 0C/1C/20/26/45 {enable, interval}`, + /// interval floored at the firmware's 30-minute minimum). func testMeasurementIntervalCapabilityPerDevice() { XCTAssertTrue(ColmiCoordinator().capabilities.contains(.measurementInterval)) XCTAssertTrue(JringCoordinator().capabilities.contains(.measurementInterval)) - XCTAssertFalse(TK5Coordinator().capabilities.contains(.measurementInterval)) + XCTAssertTrue(TK5Coordinator().capabilities.contains(.measurementInterval)) } - /// Only the jring can be *asked* for a blood-pressure reading (0x23 mode 1). The TK5 reports BP - /// from its live stream but has no on-demand BP command, and Colmi has no BP sensor at all. - func testManualBloodPressureIsJringOnly() { + /// A ring can be *asked* for a blood-pressure reading only if its live protocol has a BP mode: the + /// jring's `0x23` mode 1, and — as of A4 — the TK5's `03 2f {01,01}`, which is exactly what + /// SmartHealth's own BP screen sends (`appStartMeasurement(1, 1)`); the reading streams back on + /// `06 03`. The note here used to claim the TK5 had no on-demand BP command. Colmi has no BP sensor + /// at all. + func testManualBloodPressureRequiresALiveBPMode() { XCTAssertTrue(JringCoordinator().capabilities.contains(.manualBloodPressure)) - XCTAssertFalse(TK5Coordinator().capabilities.contains(.manualBloodPressure)) + XCTAssertTrue(TK5Coordinator().capabilities.contains(.manualBloodPressure)) XCTAssertFalse(ColmiCoordinator().capabilities.contains(.manualBloodPressure)) XCTAssertFalse(ColmiCoordinator().capabilities.contains(.bloodPressure)) } diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 229a1cbb..c1371341 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -5,10 +5,44 @@ import UIKit /// The pairing flow must recognize the whole Colmi/Yawell ring family by advertised name (they all /// share `ColmiDriver`), without the jring coordinator wrongly claiming them. +/// +/// Since Phase B it must also do something strictly harder: separate the *two* Colmi families, which +/// speak entirely different protocols and can advertise the identical local name. That is why the +/// advertisement is only a hint here and the user's app-type pick is the authority — the cross-claim +/// matrix below pins the hint's behavior, and `testPreferredFamilyOverridesAutoMatch` pins the override +/// that makes a wrong hint harmless. @MainActor final class PairingMatchingTests: XCTestCase { private let noAdv = AdvertisementInfo(serviceUUIDs: [], manufacturerData: nil) + private func bytes(_ hex: String) -> Data { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i.. Bool { ColmiCoordinator.matches(name: name, advertisement: noAdv) } @@ -41,10 +75,177 @@ final class PairingMatchingTests: XCTestCase { } func testCatalogFamiliesAreRegistered() { - // Every model in the carousel maps to a family that has a registered coordinator. + // Every family every carousel card can resolve to — including via its app variants — must have a + // registered coordinator, or picking that variant would silently fall back to the jring driver. let registeredTypes = Set(RingBLEClient.coordinators.map { $0.deviceType }) for model in WearableModel.catalog { - XCTAssertTrue(registeredTypes.contains(model.family), "no coordinator for \(model.displayName)") + for family in model.families { + XCTAssertTrue(registeredTypes.contains(family), "no coordinator for \(model.displayName) (\(family))") + } + } + } + + // MARK: - Registry cross-claim matrix (B3) + + /// The one ordering constraint in the registry, stated as a test so a re-sort can't silently break + /// it: behind `ColmiCoordinator` — whose matcher needs only the name — no SmartHealth ring would + /// ever be claimed. + func testSmartHealthColmiPrecedesQRingColmi() { + let order = RingBLEClient.coordinators.map { $0.deviceType } + guard let smartHealth = order.firstIndex(of: .colmiSmartHealth), + let qring = order.firstIndex(of: .colmiR02) else { + return XCTFail("both Colmi coordinators must be registered") + } + XCTAssertLessThan(smartHealth, qring) + } + + /// Registry order + each coordinator's matcher, end to end: every ring lands on exactly one driver. + func testRegistryCrossClaimMatrix() { + let cases: [(String, String?, AdvertisementInfo, RingDeviceType?)] = [ + ("jring", "SMART_RING", noAdv, .jring), + ("QRing-Colmi advertising its service", "R09_00AA", qringAdv, .colmiR02), + ("QRing-Colmi with a bare advertisement", "R09_00AA", noAdv, .colmiR02), + ("SmartHealth-Colmi", "R09_00AA", smartHealthAdv, .colmiSmartHealth), + ("SmartHealth-Colmi (COLMI-prefixed name)", "COLMI R10_xyz", smartHealthAdv, .colmiSmartHealth), + ("TK5", "TK5 24AA", tk5Adv, .tk5), + ("TK5, name only", "TK5 24AA", noAdv, .tk5), + ("unknown peripheral", "Galaxy Watch", noAdv, nil), + ] + for (label, name, advertisement, expected) in cases { + XCTAssertEqual( + RingBLEClient.matchDeviceType(name: name, advertisement: advertisement), + expected, + label + ) + } + } + + /// The TK5's own manufacturer data (`10786501…`) *contains* the `1078` SmartHealth marker — every + /// ring in this SDK family does. Only the name half of the conjunction keeps the TK5 out of the + /// Colmi family, so assert it directly: TK5 auto-detection is exactly what it was. + func testSmartHealthColmiDoesNotClaimTheTK5() { + XCTAssertTrue(tk5Adv.manufacturerData!.hexString.contains( + ColmiSmartHealthCoordinator.Advertisement.manufacturerHexMarker + )) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + XCTAssertTrue(TK5Coordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + } + + /// The marker lives in the manufacturer data's **company-ID slot**, so it is matched as a prefix. An + /// unanchored substring test over the hex string is not even byte-aligned — mfr bytes `a1 07 8f` + /// stringify to `"a1078f"` — and Colmi's manufacturer payload commonly embeds the MAC, so an unlucky + /// QRing-Colmi would be tagged as the SmartHealth family, defaulting the picker (and the first + /// connect) to a protocol its firmware doesn't speak. + func testSmartHealthMarkerIsAnchoredToTheCompanyIDSlot() { + let straddling = AdvertisementInfo(serviceUUIDs: [], manufacturerData: bytes("a1078fcc")) + XCTAssertTrue(straddling.manufacturerData!.hexString.contains("1078")) // the trap it must not fall into + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: straddling)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "R09_00AA", advertisement: straddling), .colmiR02) + } + + /// The conjunction, term by term. Each half alone is not enough. + func testSmartHealthColmiRequiresAllThreeSignals() { + // Colmi name + marker + no QRing service → claimed. + XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: smartHealthAdv)) + // Colmi name, but no manufacturer data at all → not claimed (falls through to QRing-Colmi). + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: noAdv)) + // Colmi name + marker, but the ring advertises a QRing service → disqualified. + for uuid in ColmiSmartHealthCoordinator.Advertisement.qringServiceUUIDs { + let conflicted = AdvertisementInfo( + serviceUUIDs: [uuid], + manufacturerData: smartHealthAdv.manufacturerData + ) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: conflicted)) + XCTAssertEqual( + RingBLEClient.matchDeviceType(name: "R09_00AA", advertisement: conflicted), .colmiR02 + ) + } + // Marker, but not a Colmi-line name → not claimed. + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: smartHealthAdv)) + } + + // MARK: - The user's pick is authoritative (B4) + + /// The load-bearing rule of the whole variant design: an explicit family beats the auto-match. This + /// is what makes the provisional advertisement heuristic safe to be wrong — in *either* direction. + func testPreferredFamilyOverridesAutoMatch() { + func family(preferred: RingDeviceType?, autoMatched: RingDeviceType?) -> RingDeviceType { + RingBLEClient.coordinatorType(preferredFamily: preferred, autoMatched: autoMatched).deviceType + } + // The hint said QRing, the user says SmartHealth (and vice versa) — the user wins both ways. + XCTAssertEqual(family(preferred: .colmiSmartHealth, autoMatched: .colmiR02), .colmiSmartHealth) + XCTAssertEqual(family(preferred: .colmiR02, autoMatched: .colmiSmartHealth), .colmiR02) + // A ring the scan recognized as nothing still gets the declared driver, not the jring fallback. + XCTAssertEqual(family(preferred: .colmiSmartHealth, autoMatched: nil), .colmiSmartHealth) + // No declaration → the auto-match still rules, unchanged. + XCTAssertEqual(family(preferred: nil, autoMatched: .tk5), .tk5) + XCTAssertEqual(family(preferred: nil, autoMatched: .colmiR02), .colmiR02) + XCTAssertEqual(family(preferred: nil, autoMatched: .jring), .jring) + // Neither → jring, preserving the pre-existing reconnect-to-unknown-peripheral behavior. + XCTAssertEqual(family(preferred: nil, autoMatched: nil), .jring) + } + + // MARK: - Which ring a tap actually connects (B4) + + /// The card-level hint is taken from whichever ring sorted first in the scan — which, with two Colmi + /// rings in range, is not necessarily the one the user tapped. This user owns exactly that pair, so + /// the tapped row's *own* scan tag has to outrank the hint: otherwise tapping the correctly-identified + /// SmartHealth R09 while a nearer QRing ring set the hint would install `ColmiDriver` against a YCBT + /// ring — the auto-match was right and we'd have overridden it with a hint from another peripheral. + func testATappedRowOutranksAHintSourcedFromAnotherRing() { + let card = WearableModel.colmiR09 + // Hint says QRing (from the other ring); the tapped row is the SmartHealth one. + XCTAssertEqual(card.variant(picked: nil, rowFamily: .colmiSmartHealth, hinted: .qring), .smartHealth) + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: .colmiSmartHealth, hinted: .qring), .colmiSmartHealth) + // …and the mirror image: a SmartHealth hint must not drag a QRing-tagged row onto the YCBT stack. + XCTAssertEqual(card.variant(picked: nil, rowFamily: .colmiR02, hinted: .smartHealth), .qring) + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: .colmiR02, hinted: .smartHealth), .colmiR02) + } + + /// The rule the whole design rests on, one level up from `testPreferredFamilyOverridesAutoMatch`: the + /// user's pick beats even a row the scan claimed. Only the *scan* is a hint; the human is not. + func testAnExplicitPickOutranksTheRowsOwnTag() { + let card = WearableModel.colmiR09 + XCTAssertEqual(card.preferredFamily(picked: .smartHealth, rowFamily: .colmiR02, hinted: .qring), .colmiSmartHealth) + XCTAssertEqual(card.preferredFamily(picked: .qring, rowFamily: .colmiSmartHealth, hinted: .smartHealth), .colmiR02) + } + + /// A row the scan recognized as nothing is fair game for the card's hint/default — the alternative is + /// the jring fallback, which is certainly wrong. A row it recognized as *another family* is not: + /// forcing the carousel's family on it would hand a jring the Colmi driver merely because the user + /// hadn't swiped away from the Colmi card. + func testUnrecognizedRowsTakeTheCardDefaultAndUnrelatedRowsKeepTheirOwnDriver() { + let card = WearableModel.colmiR09 + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: nil, hinted: .smartHealth), .colmiSmartHealth) + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: nil, hinted: nil), .colmiR02) // card default + XCTAssertNil(card.preferredFamily(picked: .smartHealth, rowFamily: .jring, hinted: .smartHealth)) + XCTAssertNil(card.preferredFamily(picked: .smartHealth, rowFamily: .tk5, hinted: nil)) + // Single-firmware cards never override anything: jring/TK5 pairing is auto-detection, as before. + for model in [WearableModel.jring, WearableModel.tk5] { + XCTAssertNil(model.variant(picked: .smartHealth, rowFamily: .colmiSmartHealth, hinted: .smartHealth)) + XCTAssertNil(model.preferredFamily(picked: .smartHealth, rowFamily: nil, hinted: .smartHealth)) + } + } + + func testColmiCardsOfferBothAppsAndTK5OffersNone() { + for model in WearableModel.catalog where model.family == .colmiR02 { + XCTAssertEqual(model.appVariants.map(\.variant), [.qring, .smartHealth], model.displayName) + XCTAssertEqual(model.families, [.colmiR02, .colmiSmartHealth], model.displayName) + XCTAssertEqual(model.family(for: .qring), .colmiR02) + XCTAssertEqual(model.family(for: .smartHealth), .colmiSmartHealth) + XCTAssertEqual(model.family(for: nil), .colmiR02) // untouched picker = the card's default + XCTAssertEqual(model.variant(for: .colmiSmartHealth), .smartHealth) + XCTAssertEqual(model.otherVariant(than: .smartHealth), .qring) + XCTAssertEqual(model.otherVariant(than: .qring), .smartHealth) + XCTAssertNotEqual(model.blurb(for: .smartHealth), model.blurb(for: .qring)) + } + // Single-firmware cards: no picker, no override, no behavior change anywhere. + for model in [WearableModel.jring, WearableModel.tk5] { + XCTAssertTrue(model.appVariants.isEmpty, model.displayName) + XCTAssertEqual(model.families, [model.family], model.displayName) + XCTAssertEqual(model.blurb(for: .smartHealth), model.blurb, model.displayName) + XCTAssertEqual(model.family(for: .smartHealth), model.family, model.displayName) + XCTAssertNil(model.otherVariant(than: .qring), model.displayName) } } @@ -87,25 +288,92 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(model?.id, WearableModel.colmiR12.id) } + /// A Colmi connecting as `.colmiSmartHealth` is still a "Colmi R09" — without variant-aware resolve + /// it would identify as no model at all and the device card would lose its name and product art. + func testResolveIsVariantAware() { + XCTAssertEqual( + WearableModel.resolve(advertisedName: "R09_00AA", selectedModelID: nil, family: .colmiSmartHealth)?.id, + "colmi-r09" + ) + XCTAssertEqual( + WearableModel.resolve(advertisedName: "R09_00AA", selectedModelID: nil, family: .colmiR02)?.id, + "colmi-r09" + ) + XCTAssertEqual( + WearableModel.resolve(advertisedName: nil, selectedModelID: "colmi-r10", family: .colmiSmartHealth)?.id, + "colmi-r10" + ) + // A card that can't be this family still doesn't resolve to it. + XCTAssertNil(WearableModel.resolve(advertisedName: "TK5 24AA", selectedModelID: nil, family: .colmiSmartHealth)) + XCTAssertNil(WearableModel.resolve(advertisedName: "R09_00AA", selectedModelID: nil, family: .tk5)) + } + func testUnknownLegacyColmiHasNoExactModel() { XCTAssertNil(WearableModel.resolve(advertisedName: nil, selectedModelID: nil, family: .colmiR02)) XCTAssertEqual(RingDeviceType.colmiR02.displayName, "Colmi / Yawell ring") + XCTAssertEqual(RingDeviceType.colmiSmartHealth.displayName, "Colmi ring (SmartHealth)") } func testColmiR11ReusesYawellR11Image() { XCTAssertEqual(WearableModel.colmiR11.imageName, WearableModel.yawellR11.imageName) } + // MARK: - SmartHealth-Colmi capabilities (B3) + + /// The two YCBT families drive one shared stack, so this family can only ever claim what the stack + /// implements — i.e. a subset of the (hardware-exercised) TK5's set. A capability outside that is a + /// card that can never fill. + func testSmartHealthColmiClaimsNothingTheYCBTStackCannotDeliver() { + let colmi = ColmiSmartHealthCoordinator() + let everythingItCouldClaim = colmi.capabilities.union(colmi.bitmapGatedCapabilities) + XCTAssertTrue(everythingItCouldClaim.isSubset(of: TK5Coordinator().capabilities)) + // It must not inherit jring-only or QRing-only actions the YCBT stack has no command for. + for absent: WearableCapability in [.combinedVitalsMeasurement, .powerOff, .factoryReset] { + XCTAssertFalse(everythingItCouldClaim.contains(absent), absent.rawValue) + } + } + + /// A gate no bit can ever satisfy is not a deferred decision — it is a dead promise: it reads as + /// "supported if the ring says so" while being permanently unreachable. Every gated capability must + /// be derivable from the bitmap parser. + func testEveryGatedCapabilityIsDerivableFromTheBitmap() { + let allOnes = [UInt8](repeating: 0xFF, count: 32) + let derivable = YCBTSupportFunction.capabilities(from: allOnes) + XCTAssertFalse(ColmiSmartHealthCoordinator().bitmapGatedCapabilities.isEmpty) + XCTAssertTrue(ColmiSmartHealthCoordinator().bitmapGatedCapabilities.isSubset(of: derivable)) + } + + /// The gated set resolves through B2's additive-only refinement formula: a silent ring keeps the + /// baseline, a claiming ring gains exactly what it claimed, and nothing outside the pre-approved + /// list can ever be added. + func testBitmapRefinementForTheSmartHealthColmi() { + let colmi = ColmiSmartHealthCoordinator() + // Baseline and gated are disjoint — a gated capability the baseline already grants is a no-op. + XCTAssertTrue(colmi.capabilities.isDisjoint(with: colmi.bitmapGatedCapabilities)) + // A ring that claims nothing (or answers with a truncated bitmap): the baseline stands. + XCTAssertEqual(colmi.refinedCapabilities(bitmapDerived: []), colmi.capabilities) + // A ring with a temperature + BP sensor gains exactly those two… + XCTAssertEqual( + colmi.refinedCapabilities(bitmapDerived: [.temperature, .bloodPressure]), + colmi.capabilities.union([.temperature, .bloodPressure]) + ) + // …and a bitmap claiming something the family never pre-approved cannot conjure it up. + XCTAssertEqual(colmi.refinedCapabilities(bitmapDerived: [.powerOff]), colmi.capabilities) + } + // MARK: - Support level func testSupportLevelIsPerFamily() { XCTAssertEqual(RingDeviceType.jring.supportLevel, .full) XCTAssertEqual(RingDeviceType.colmiR02.supportLevel, .full) XCTAssertEqual(RingDeviceType.tk5.supportLevel, .limited) + XCTAssertEqual(RingDeviceType.colmiSmartHealth.supportLevel, .limited) } - /// The TK5 is the only limited-support family, and only limited families get a badge. - func testOnlyTK5CarriesTheLimitedSupportBadge() { + /// Both YCBT families are unproven, and only unproven families get a badge. A Colmi card therefore + /// carries one *only* while its picker is on SmartHealth — the same physical ring, a different + /// driver's maturity. + func testLimitedSupportFamiliesCarryTheBadge() { XCTAssertEqual(WearableModel.tk5.supportLevel, .limited) XCTAssertEqual(WearableModel.tk5.supportLevel.badgeLabel, "Limited support") @@ -113,6 +381,46 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(model.supportLevel, .full, model.displayName) XCTAssertNil(model.supportLevel.badgeLabel, model.displayName) } + for model in WearableModel.catalog where model.family == .colmiR02 { + XCTAssertEqual(model.supportLevel(for: .qring), .full, model.displayName) + XCTAssertEqual(model.supportLevel(for: .smartHealth), .limited, model.displayName) + } + } + + // MARK: - Wrong-choice failure path (B5) + + /// The message a stalled connect shows. It has to name both apps: the one we tried (so the user knows + /// what was attempted) and the one to try instead (so the failure is actionable in one tap). + func testConnectFailureMessageNamesBothApps() { + for family: RingDeviceType in [.colmiSmartHealth, .colmiR02] { + let message = RingConnectFailure.message(family: family) + XCTAssertTrue(message.contains("QRing"), message) + XCTAssertTrue(message.contains("SmartHealth"), message) + } + // The one we tried is named first — the sentence is "didn't answer as a ring". + XCTAssertTrue(RingConnectFailure.message(family: .colmiSmartHealth) + .hasPrefix("This ring didn't answer as a SmartHealth ring.")) + XCTAssertTrue(RingConnectFailure.message(family: .colmiR02) + .hasPrefix("This ring didn't answer as a QRing ring.")) + } + + /// A single-firmware family has no other app to suggest, so it must not offer one. + func testConnectFailureMessageIsGenericWithoutVariants() { + for family: RingDeviceType? in [.jring, .tk5, nil] { + let message = RingConnectFailure.message(family: family) + XCTAssertFalse(message.isEmpty) + XCTAssertFalse(message.contains("QRing"), message) + XCTAssertFalse(message.contains("SmartHealth"), message) + } + } + + func testAppVariantMapsToAndFromItsFamily() { + XCTAssertEqual(RingAppVariant(family: .colmiR02), .qring) + XCTAssertEqual(RingAppVariant(family: .colmiSmartHealth), .smartHealth) + XCTAssertNil(RingAppVariant(family: .jring)) + XCTAssertNil(RingAppVariant(family: .tk5)) + XCTAssertEqual(RingAppVariant.qring.other, .smartHealth) + XCTAssertEqual(RingAppVariant.smartHealth.other, .qring) } /// Every catalog model must name an imageset that exists, or `RingArtView` renders an empty diff --git a/PulseLoopTests/RingIdentityMemoryTests.swift b/PulseLoopTests/RingIdentityMemoryTests.swift new file mode 100644 index 00000000..8ac65c2f --- /dev/null +++ b/PulseLoopTests/RingIdentityMemoryTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import PulseLoop + +/// What `RingBLEClient` remembers about a ring *between* connections: its family, its exact catalog +/// model, and what its own capability bitmap claimed. +/// +/// All three have to be re-adopted before a new session publishes its first `.deviceIdentified`, because +/// that event overwrites the persisted `Device` row wholesale — anything the client can't name at that +/// moment is not merely missing from the UI, it is erased from the store. +@MainActor +final class RingIdentityMemoryTests: XCTestCase { + private let deviceTypeKey = "ring.lastDeviceType" + private let modelKey = "ring.lastWearableModel" + private let capabilitiesKey = "ring.lastCapabilities" + + /// An R09 whose `02 01` bitmap claimed a temperature sensor on a previous connect. + private var rememberedSmartHealthCapabilities: Set { + ColmiSmartHealthCoordinator().capabilities.union([.temperature]) + } + + /// Seed (and, on teardown, clear) the client's cross-connection memory. Starts from a clean slate so + /// a test never inherits another's — or a previous run's — remembered ring. + private func remember(deviceType: String? = nil, model: String? = nil, capabilities: Set? = nil) { + let defaults = UserDefaults.standard + let keys = [deviceTypeKey, modelKey, capabilitiesKey] + keys.forEach { defaults.removeObject(forKey: $0) } + addTeardownBlock { + keys.forEach { UserDefaults.standard.removeObject(forKey: $0) } + } + deviceType.map { defaults.set($0, forKey: deviceTypeKey) } + model.map { defaults.set($0, forKey: modelKey) } + capabilities.map { defaults.set($0.csv, forKey: capabilitiesKey) } + } + + /// iOS killed the app mid-session and relaunched it for a BLE event. `willRestoreState` has no + /// advertisement to re-derive anything from, so it re-adopts what was remembered. Before it did, the + /// restored session reached `.connected` with a nil model id and nulled `Device.wearableModelID` — + /// the device card lost its name and product art until some later reconnect happened to carry a + /// resolvable GAP name. + func testRestoredSessionReadoptsFamilyModelAndClaimedCapabilities() { + remember(deviceType: "colmiSmartHealth", model: "colmi-r09", capabilities: rememberedSmartHealthCapabilities) + let client = RingBLEClient(startManager: false) + + client.adoptRememberedIdentity() + + XCTAssertEqual(client.activeDeviceType, .colmiSmartHealth) + XCTAssertEqual(client.activeWearableModelID, "colmi-r09") + XCTAssertTrue(client.activeCapabilities.contains(.temperature)) + } + + /// The bitmap arrives partway into a handshake; `Device.capabilitiesRaw` is overwritten at the start + /// of one. So a connect that never gets an answer — a dropped `02 01` reply, or firmware that answers + /// with a short array — must not be able to strip a capability the ring already told us it has: the + /// Vitals/Today cards for it would vanish for the whole session *and* while offline afterwards. + func testAConnectWhoseBitmapNeverArrivesKeepsWhatTheRingAlreadyClaimed() { + remember(capabilities: rememberedSmartHealthCapabilities) + let client = RingBLEClient(startManager: false) + + client.installDriver(ColmiSmartHealthCoordinator.self) // no `.supportFunctions` follows + + XCTAssertEqual(client.activeCapabilities, rememberedSmartHealthCapabilities) + } + + /// The seed is fed through the same additive-only refinement as the bitmap itself, so it can only + /// re-grant what *this* family gates. A set remembered from another ring can't leak a sensor into a + /// family that has none — which is also why jring and QRing-Colmi (which gate nothing) are provably + /// untouched by any of this. + func testRememberedCapabilitiesCannotLeakIntoAFamilyThatGatesNothing() { + remember(capabilities: TK5Coordinator().capabilities) + let client = RingBLEClient(startManager: false) + + client.installDriver(JringCoordinator.self) + XCTAssertEqual(client.activeCapabilities, JringCoordinator().capabilities) + + client.installDriver(ColmiCoordinator.self) + XCTAssertEqual(client.activeCapabilities, ColmiCoordinator().capabilities) + } + + /// Nothing remembered (a first-ever pairing): the family baseline, exactly as before. + func testAFirstConnectStartsFromTheFamilyBaseline() { + remember() + let client = RingBLEClient(startManager: false) + client.installDriver(ColmiSmartHealthCoordinator.self) + XCTAssertEqual(client.activeCapabilities, ColmiSmartHealthCoordinator().capabilities) + XCTAssertFalse(client.activeCapabilities.contains(.temperature)) + } +} diff --git a/PulseLoopTests/TK5CoordinatorTests.swift b/PulseLoopTests/TK5CoordinatorTests.swift new file mode 100644 index 00000000..306f4ecf --- /dev/null +++ b/PulseLoopTests/TK5CoordinatorTests.swift @@ -0,0 +1,44 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// The TK5 *device*: how it is recognized in a scan, and what it claims it can do. +/// +/// Deliberately separate from `YCBTDecoderTests`. The protocol the TK5 speaks is shared byte-for-byte +/// with every other SmartHealth-family ring, so the decoder/encoder/driver tests are family-neutral; +/// what is TK5-only is its advertised identity and its capability set, and those belong here. +final class TK5CoordinatorTests: XCTestCase { + private func bytes(_ hex: String) -> Data { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i.. Data { - var out = [UInt8]() - var i = hex.startIndex - while i < hex.endIndex { - let n = hex.index(i, offsetBy: 2) - out.append(UInt8(hex[i... - let body = bytes("01000e00ea0707060c220e00") - let crc = TK5Frame.crc16([UInt8](body)) - XCTAssertEqual(crc, 0xc726) // little-endian on the wire = 26 c7 - } - - func testFrameBuildsLengthAndCRC() { - // Logical [type, cmd, payload…] → full framed packet identical to the capture. - let logical: [UInt8] = [0x01, 0x00, 0xea, 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00] - let framed = TK5Frame.frame(logical) - XCTAssertEqual(framed.hexString, "01000e00ea0707060c220e0026c7") - } - - func testValidatingRejectsBadCRC() { - var raw = [UInt8](bytes("01000e00ea0707060c220e0026c7")) - raw[raw.count - 1] ^= 0xff - XCTAssertNil(TK5Frame(validating: Data(raw))) - } - - func testValidatingRejectsWrongDeclaredLength() { - // Declared length (0x00ff) doesn't match actual byte count. - XCTAssertNil(TK5Frame(validating: bytes("0100ff00ea0707060c220e0026c7"))) - } - - // MARK: Decode — verified fields - - func testLiveHeartRateDecodes() { - // 06 01 0700 — captured live HR of 0x52 = 82 bpm. - let frame = TK5Frame(validating: bytes("06010700521a55"))! - guard case let .heartRateSample(bpm, _) = decoder.decode(frame).first else { - return XCTFail("expected heartRateSample") - } - XCTAssertEqual(bpm, 82) - } - - func testLiveStatusDecodesSteps() { - // 06 00 0c00 7b02 9701 1a00 — steps 0x027b = 635. - let frame = TK5Frame(validating: bytes("06000c007b0297011a00b60d"))! - guard case let .activityUpdate(_, steps, distance, calories) = decoder.decode(frame).first else { - return XCTFail("expected activityUpdate") - } - XCTAssertEqual(steps, 635) - XCTAssertEqual(distance, 407) // 0x0197 (capture-inferred) - XCTAssertEqual(calories, 26) // 0x001a (capture-inferred) - } - - func testStatusDecodesBattery() { - // 02 00 status; battery at payload[5] = 0x64 = 100. - let frame = TK5Frame(validating: bytes("02001e00a30012010064000100030000000001000000010000000000ef10"))! - let events = decoder.decode(frame) - guard case let .battery(percent) = events.last else { - return XCTFail("expected battery event") - } - XCTAssertEqual(percent, 100) - } - - func testHistoryShortFramePacksEightHourlyHR() { - // One 05 15 frame carries eight 6-byte HR records (overnight hourly samples); all must decode. - let frame = TK5Frame(validating: bytes( - "051536001cf0de3100471afede310042260cdf31003f3b1adf31003e4328df3100425136df31003c6444df3100419852df31003aa951"))! - let hr = decoder.decode(frame).compactMap { event -> Double? in - if case let .historyMeasurement(.heartRate, value, _) = event { return value } else { return nil } - } - XCTAssertEqual(hr, [71, 66, 63, 62, 66, 60, 65, 58]) - } - - func testHistoryLongFramePacksPeriodicSpo2AndHRV() { - // One 05 18 frame carries eight 20-byte combined-vitals records; each yields SpO₂ + HRV. - let frame = TK5Frame(validating: bytes( - "0518a6001cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf310000" + - "3f7049610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f00" + - "0000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df310000" + - "3a6a465f0c8002000f000000d89dc5a3"))! - let events = decoder.decode(frame) - let spo2 = events.compactMap { e -> Double? in - if case let .historyMeasurement(.spo2, v, _) = e { return v } else { return nil } - } - let hrv = events.compactMap { e -> Double? in - if case let .historyMeasurement(.hrv, v, _) = e { return v } else { return nil } - } - XCTAssertEqual(spo2, [98, 97, 97, 96, 97, 96, 97, 95]) - XCTAssertEqual(hrv, [52, 43, 177, 95, 33, 33, 61, 128]) - // Periodic BP at offsets 7/8 — last record (06:00) verified 106/70 against the app. - let bp = events.compactMap { e -> (Int, Int)? in - if case let .bloodPressureSample(s, d, _) = e { return (s, d) } else { return nil } - } - XCTAssertEqual(bp.map(\.0), [115, 112, 112, 109, 111, 111, 110, 106]) // systolic - XCTAssertEqual(bp.last?.1, 70) // 06:00 diastolic - // Cumulative per-day steps emitted as activityUpdate (max per day); this frame's first record - // carries the 23:00 daily total of 3336. - let steps = events.compactMap { e -> Int? in - if case let .activityUpdate(_, s, _, _) = e { return s } else { return nil } - } - XCTAssertEqual(steps.max(), 3336) - } - - func testLiveExtendedDisambiguatesBPvsHRV() { - // 06 03 in BP mode: [sys][dia]… → 111/74 (verified against the app's 6:52 reading). - let bpFrame = TK5Frame(validating: bytes("060314006f4a44000000000000000000000074f1"))! - guard case let .bloodPressureSample(sys, dia, _) = decoder.decode(bpFrame).first else { - return XCTFail("expected bloodPressureSample") - } - XCTAssertEqual([sys, dia], [111, 74]) - // 06 03 in HRV mode: [0,0,0,hrv]… → HRV, not misread as BP. - let hrvFrame = TK5Frame(validating: bytes("06031400000000b1000000000000000000001579"))! - guard case let .hrvSample(value, _) = decoder.decode(hrvFrame).first else { - return XCTFail("expected hrvSample") - } - XCTAssertEqual(value, 177) - } - - func testHistoryRecordDecodesHRV() { - // Two 05 18 records copied verbatim from the capture; payload[11] = HRV, verified against the - // app's displayed values (48 ms @1:00, 79 ms @1:32). - for (hex, expected) in [ - ("05181a005f63de317b02446e4b620e3006000f000000d9ebc397", 48.0), - ("05181a00ee6ade31de0200000000004f00000f000000b5e74ff0", 79.0), - ] { - let frame = TK5Frame(validating: bytes(hex))! - let hrv = decoder.decode(frame).compactMap { event -> Double? in - if case let .historyMeasurement(.hrv, value, _) = event { return value } else { return nil } - } - XCTAssertEqual(hrv, [expected], "HRV decode for \(hex)") - } - } - - // MARK: Timestamp decode - - func testDateDecodeRecoversTrueInstantAcrossTimeZone() { - // The ring has no timezone concept: TK5Encoder.setTime sends *local* wall-clock fields, and - // the ring stores them naively as if they were UTC (no timezone byte in the wire format — - // see docs/TK5-Protocol.md). Simulate that here for a non-UTC zone and confirm decode still - // recovers the true absolute instant, rather than shifting it by a full UTC-offset (which is - // what put last night's sleep session on the wrong side of the app's 7 PM day boundary). - let tz = TimeZone(identifier: "America/New_York")! - var localCalendar = Calendar(identifier: .gregorian) - localCalendar.timeZone = tz - - // Snap to the second so DateComponents round-trips exactly. - let trueInstant = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) - let localFields = localCalendar.dateComponents( - [.year, .month, .day, .hour, .minute, .second], from: trueInstant - ) - - // What the ring's own naive clock would store: the local fields, reinterpreted as if they - // were UTC (no timezone concept on-device). - var utcCalendar = Calendar(identifier: .gregorian) - utcCalendar.timeZone = TimeZone(identifier: "UTC")! - let ringNaiveInstant = utcCalendar.date(from: localFields)! - let ringSecondsFromRing = Int(ringNaiveInstant.timeIntervalSince1970 - TK5Bytes.epochOffset) - - let decoded = TK5Bytes.date(ringSecondsFromRing, timeZone: tz) - - XCTAssertEqual(decoded.timeIntervalSince1970, trueInstant.timeIntervalSince1970, accuracy: 1) - } - - func testRingSecondsAndDateRoundTrip() { - let tz = TimeZone(identifier: "America/New_York")! - let date = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) - - let ringSeconds = TK5Bytes.ringSeconds(date, timeZone: tz) - let decoded = TK5Bytes.date(ringSeconds, timeZone: tz) - - XCTAssertEqual(decoded.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1) - } - - // MARK: Sleep - - func testSleepDecodeMatchesAppBreakdown() { - // Reassembled 05 13 sleep record (3 frames concatenated). Stage tags f1=deep/f2=light/f3=rem, - // verified against the app's on-screen breakdown (deep 93 / light 249 / rem 130 min; the small - // deltas below are per-segment minute rounding). - let record = bytes( - "affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c9" + - "0400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200" + - "f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f345" + - "18df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf" + - "31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c1" + - "0000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000" + - "f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f" + - "47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df" + - "31050100f15f53df311e0100f27d54df31400400") - guard case let .sleepTimeline(_, stages) = decoder.decodeSleep([UInt8](record)).first else { - return XCTFail("expected sleepTimeline") - } - // Per-minute rounding means totals land within a couple minutes of the app's displayed values. - XCTAssertEqual(Double(stages.filter { $0 == .light }.count), 249, accuracy: 3) - XCTAssertEqual(Double(stages.filter { $0 == .deep }.count), 93, accuracy: 3) - XCTAssertEqual(Double(stages.filter { $0 == .rem }.count), 130, accuracy: 3) - XCTAssertFalse(stages.contains(.awake)) - } - - // MARK: Live-measurement mode selection - - func testLiveMeasurementModesUseDistinctSensors() { - // The 03 2f payload's mode byte selects the sensor/LED; each metric must use its own. - let e = TK5Encoder() - XCTAssertEqual(e.heartRateStart(), [0x03, 0x2f, 0x01, 0x00]) // HR (green) - XCTAssertEqual(e.spo2Start(), [0x03, 0x2f, 0x01, 0x02]) // SpO₂ (red/IR) - XCTAssertEqual(e.hrvStart(), [0x03, 0x2f, 0x01, 0x0a]) // HRV - XCTAssertEqual(e.liveStreamStop(), [0x03, 0x2f, 0x00, 0x00]) // stop (mode-agnostic) - } - - // MARK: Startup - - func testStartupEnablesLiveStatusStream() { - // Without `03 09 01 00 02` the ring never auto-pushes 06 00 status, so live steps freeze. - let seq = TK5Encoder().startupSequence() - XCTAssertTrue(seq.contains([0x03, 0x09, 0x01, 0x00, 0x02]), - "startup must enable the live status auto-push") - } - - // MARK: Coordinator recognition - - @MainActor - func testCoordinatorMatchesTK5Name() { - let noAdv = AdvertisementInfo(serviceUUIDs: [], manufacturerData: nil) - XCTAssertTrue(TK5Coordinator.matches(name: "TK5 24AA", advertisement: noAdv)) - XCTAssertFalse(TK5Coordinator.matches(name: "SMART_RING", advertisement: noAdv)) - XCTAssertFalse(JringCoordinator.matches(name: "TK5 24AA", advertisement: noAdv)) - } - - @MainActor - func testCoordinatorMatchesManufacturerPrefix() { - let adv = AdvertisementInfo( - serviceUUIDs: [], - manufacturerData: bytes("10786501000101120000000000")) - XCTAssertTrue(TK5Coordinator.matches(name: "Unlabeled", advertisement: adv)) - } - - // MARK: History paging - - /// An unworn `05 18` record (SpO₂ out of range, HRV 0, BP 0) decodes to nothing but the day's - /// cumulative step count — which is indistinguishable from a live `06 00` status push. - func testUnwornCombinedVitalsRecordDecodesToStepsOnly() { - let frame = TK5Frame(validating: bytes("05181a001cf0de31080d4700000000000000000000000000c362"))! - let events = decoder.decode(frame) - XCTAssertEqual(events.count, 1) - guard case .activityUpdate = events.first else { - return XCTFail("expected activityUpdate, got \(events)") - } - } -} - -/// `RingBLEClient` only calls the sync engine's `handle` once per *decoded event*, so a frame that -/// decodes to nothing — a sleep continuation, an all-unworn history page — is invisible to the -/// history watchdog. That is how a slow overnight transfer used to get acked away mid-record. The -/// driver therefore announces every history frame, since only it can see the frame type. -@MainActor -final class TK5HistorySyncTests: XCTestCase { - private final class FakeWriter: RingCommandWriter { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - var sent: [Data] = [] - func enqueue(_ command: Data) { sent.append(command) } - } - - private func bytes(_ hex: String) -> Data { - var out = [UInt8]() - var i = hex.startIndex - while i < hex.endIndex { - let n = hex.index(i, offsetBy: 2) - out.append(UInt8(hex[i.. Data { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i... + let body = bytes("01000e00ea0707060c220e00") + let crc = YCBTFrame.crc16([UInt8](body)) + XCTAssertEqual(crc, 0xc726) // little-endian on the wire = 26 c7 + } + + func testFrameBuildsLengthAndCRC() { + // Logical [type, cmd, payload…] → full framed packet identical to the capture. + let logical: [UInt8] = [0x01, 0x00, 0xea, 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00] + let framed = YCBTFrame.frame(logical) + XCTAssertEqual(framed.hexString, "01000e00ea0707060c220e0026c7") + } + + func testValidatingRejectsBadCRC() { + var raw = [UInt8](bytes("01000e00ea0707060c220e0026c7")) + raw[raw.count - 1] ^= 0xff + XCTAssertNil(YCBTFrame(validating: Data(raw))) + } + + func testValidatingRejectsWrongDeclaredLength() { + // Declared length (0x00ff) doesn't match actual byte count. + XCTAssertNil(YCBTFrame(validating: bytes("0100ff00ea0707060c220e0026c7"))) + } + + // MARK: Live stream (be940003, group 0x06) + + func testLiveHeartRateDecodes() { + // 06 01 0700 — captured live HR of 0x52 = 82 bpm. + let frame = YCBTFrame(validating: bytes("06010700521a55"))! + guard case let .heartRateSample(bpm, _) = decoder.decode(frame).first else { + return XCTFail("expected heartRateSample") + } + XCTAssertEqual(bpm, 82) + } + + func testLiveStatusDecodesSteps() { + // 06 00 0c00 7b02 9701 1a00 — steps 0x027b = 635. + let frame = YCBTFrame(validating: bytes("06000c007b0297011a00b60d"))! + guard case let .activityUpdate(_, steps, distance, calories) = decoder.decode(frame).first else { + return XCTFail("expected activityUpdate") + } + XCTAssertEqual(steps, 635) + XCTAssertEqual(distance, 407) // 0x0197 (capture-inferred) + XCTAssertEqual(calories, 26) // 0x001a (capture-inferred) + } + + /// `06 03` is one fixed layout (`unpackRealBloodData`), not two shapes: `[SBP][DBP][hr][hrv][spo2]` + /// `[tempInt][tempFrac]`. The old "BP-vs-HRV heuristic" was reading exactly these offsets without + /// knowing it — in BP mode the ring fills @0/@1 and zeroes @3, in HRV mode the reverse — so both + /// captured frames still decode the same way. + func testLiveVitalsDecodesBothCapturedShapes() { + // 06 03 in BP mode: [sys=111][dia=74][hr=68]… (verified against the app's 6:52 reading). + let bpFrame = YCBTFrame(validating: bytes("060314006f4a44000000000000000000000074f1"))! + let bpEvents = decoder.decode(bpFrame) + guard case let .bloodPressureSample(sys, dia, _) = bpEvents.first else { + return XCTFail("expected bloodPressureSample first, got \(bpEvents)") + } + XCTAssertEqual([sys, dia], [111, 74]) + // The HR the BP sweep also measures used to be thrown away with the rest of the frame. + XCTAssertEqual(bpEvents.compactMap { event -> Int? in + if case let .heartRateSample(bpm, _) = event { return bpm } else { return nil } + }, [68]) + + // 06 03 in HRV mode: [0,0,0,hrv=177]… → HRV alone, not misread as BP. + let hrvFrame = YCBTFrame(validating: bytes("06031400000000b1000000000000000000001579"))! + let hrvEvents = decoder.decode(hrvFrame) + guard case let .hrvSample(value, _) = hrvEvents.first else { + return XCTFail("expected hrvSample first, got \(hrvEvents)") + } + XCTAssertEqual(value, 177) + XCTAssertEqual(hrvEvents.count, 1, "zeroed fields are 'no sample', not readings") + } + + /// SpO₂ and temperature ride the same frame's tail. Temperature is int/frac **string-concatenated** + /// (`36` + `.` + `4`), not `int + frac/100` — the realtime report had that wrong; SmartHealth's own + /// temperature screen does `Double.parseDouble(int + "." + frac)`. + func testLiveVitalsDecodesSpO2AndTemperatureTail() { + // [sys=118][dia=79][hr=70][hrv=42][spo2=97][tempInt=36][tempFrac=4] + let frame = YCBTFrame(validating: YCBTFrame.frame([0x06, 0x03, 118, 79, 70, 42, 97, 36, 4]))! + let events = decoder.decode(frame) + + XCTAssertEqual(events.compactMap { event -> Int? in + if case let .spo2Result(value, _) = event { return value } else { return nil } + }, [97]) + XCTAssertEqual(events.compactMap { event -> Double? in + if case let .temperatureSample(celsius, _) = event { return celsius } else { return nil } + }, [36.4]) + } + + // MARK: Live pushes (group 0x06) + + /// `06 15` (`unpackUploadBatteryLevel`): `[chargingStatus][percent]`. The ring pushes it unprompted, + /// so battery stays fresh without polling `02 00`. + func testBatteryPushDecodes() { + let frame = YCBTFrame(validating: YCBTFrame.frame([0x06, 0x15, 0x01, 0x5b]))! + guard case let .battery(percent) = decoder.decode(frame).first else { + return XCTFail("expected battery") + } + XCTAssertEqual(percent, 91) + } + + /// `06 13` (`unpackWearingStatusData`): `[ts:u32 2000-epoch][status]`. Debug-feed only — it produces + /// no `PulseEvent`, so a wrong polarity guess can't reach the UI. + func testWearingStatusPushDecodes() { + let seconds = YCBTBytes.ringSeconds(Date()) + let ts: [UInt8] = (0..<4).map { UInt8((seconds >> (8 * $0)) & 0xff) } + + let worn = YCBTFrame(validating: YCBTFrame.frame([0x06, 0x13] + ts + [0x01]))! + guard case let .wearingStatus(isWorn, timestamp) = decoder.decode(worn).first else { + return XCTFail("expected wearingStatus") + } + XCTAssertTrue(isWorn) + XCTAssertEqual(timestamp.timeIntervalSince1970, Date().timeIntervalSince1970, accuracy: 2) + + let removed = YCBTFrame(validating: YCBTFrame.frame([0x06, 0x13] + ts + [0x00]))! + guard case let .wearingStatus(isWorn, _) = decoder.decode(removed).first else { + return XCTFail("expected wearingStatus") + } + XCTAssertFalse(isWorn) + + // It must stay out of the typed fan-out — nothing in the app gates on wear state yet. + XCTAssertTrue(RingEventBridge.events(for: .wearingStatus(worn: true, timestamp: Date())).isEmpty) + } + + // MARK: Device pushes (group 0x04, DevControl) + + /// `04 13` MeasurStatusAndResults (1043): `[type][state]` then the value(s) for that type. `type` is + /// the *same* mode byte `03 2f` starts a measurement with, so one table drives both. + func testMeasurementStatusPushDecodesPerType() { + func decodeStatus(_ payload: [UInt8]) -> [RingDecodedEvent] { + decoder.decode(YCBTFrame(validating: YCBTFrame.frame([0x04, 0x13] + payload))!) + } + + guard case let .heartRateSample(bpm, _) = decodeStatus([0x00, 0x01, 72]).first else { + return XCTFail("type 0 → heart rate") + } + XCTAssertEqual(bpm, 72) + + guard case let .bloodPressureSample(sys, dia, _) = decodeStatus([0x01, 0x01, 118, 79]).first else { + return XCTFail("type 1 → blood pressure") + } + XCTAssertEqual([sys, dia], [118, 79]) + + guard case let .spo2Result(spo2, _) = decodeStatus([0x02, 0x01, 98]).first else { + return XCTFail("type 2 → SpO₂") + } + XCTAssertEqual(spo2, 98) + + guard case let .temperatureSample(celsius, _) = decodeStatus([0x04, 0x01, 36, 5]).first else { + return XCTFail("type 4 → temperature") + } + XCTAssertEqual(celsius, 36.5, accuracy: 0.001) + + // Blood sugar is tenths of mmol/L (`int * 10 + frac`), converted to the mg/dL the app stores. + guard case let .bloodSugarSample(mgdl, _) = decodeStatus([0x05, 0x01, 5, 5]).first else { + return XCTFail("type 5 → blood sugar") + } + XCTAssertEqual(mgdl, 5.5 * YCBTHealthRecords.mgdlPerMmol, accuracy: 0.001) + } + + /// A warm-up push (state set, value still 0) must not surface a 0-bpm reading, and a type with no + /// PulseLoop surface (3 = respiratory rate) must not fabricate one. Both just ack. + func testMeasurementStatusPushWithNoValueAcks() { + func decodeStatus(_ payload: [UInt8]) -> RingDecodedEvent? { + decoder.decode(YCBTFrame(validating: YCBTFrame.frame([0x04, 0x13] + payload))!).first + } + guard case .commandAck = decodeStatus([0x00, 0x00, 0x00]) else { + return XCTFail("a 0-value heart-rate push is a warm-up, not a reading") + } + guard case .commandAck = decodeStatus([0x03, 0x01, 16]) else { + return XCTFail("respiratory rate has no live event; it must not be fabricated") + } + } + + /// `04 0e` MeasurementResult carries `[measureType][result]` and **no value** — it acks (and logs). + func testMeasurementResultPushAcks() { + let frame = YCBTFrame(validating: YCBTFrame.frame([0x04, 0x0e, 0x01, 0x02]))! + guard case let .commandAck(commandId) = decoder.decode(frame).first else { + return XCTFail("expected commandAck") + } + XCTAssertEqual(commandId, 0x0e) + } + + // MARK: Command channel (be940001, group 0x02) + + /// `02 00` is **GetDeviceInfo** (`CMD.KEY_Get.DeviceInfo == 0`), not "status": battery state at + /// payload[4], battery percent at [5], firmware as `[3].[2]`. The old decoder had `0x00` and `0x01` + /// swapped and never surfaced a firmware version at all. + func testDeviceInfoDecodesBatteryAndFirmware() { + let frame = YCBTFrame(validating: bytes("02001e00a30012010064000100030000000001000000010000000000ef10"))! + let events = decoder.decode(frame) + + let battery = events.compactMap { event -> Int? in + if case let .battery(percent) = event { return percent } else { return nil } + } + let firmware = events.compactMap { event -> String? in + if case let .firmware(version) = event { return version } else { return nil } + } + XCTAssertEqual(battery, [100]) // payload[5] = 0x64 + XCTAssertEqual(firmware, ["1.18"]) // payload[3].payload[2] = 0x01 . 0x12 + } + + /// The SDK **zero-pads** a single-digit sub-version (`i4 < 10 ? main + ".0" + sub : main + "." + sub`), + /// so sub 5 is the "1.05" the vendor's release notes name. Rendering it "1.5" makes a user comparing + /// against those notes think they are on different firmware. + func testFirmwareSubVersionIsZeroPaddedLikeTheSDK() { + let frame = YCBTFrame(validating: YCBTFrame.frame([0x02, 0x00, 0xa3, 0x00, 0x05, 0x01, 0x00, 0x64]))! + let firmware = decoder.decode(frame).compactMap { event -> String? in + if case let .firmware(version) = event { return version } else { return nil } + } + XCTAssertEqual(firmware, ["1.05"]) + } + + // The `02 01` SupportFunction reply and the bitmap it carries are covered in + // `YCBTSupportFunctionTests` — the bitmap is a capability question, not a frame-decoding one, and + // it now has a dedicated suite. + + // MARK: Timestamp decode + + func testDateDecodeRecoversTrueInstantAcrossTimeZone() { + // The ring has no timezone concept: `setTime` sends *local* wall-clock fields, and the ring + // stores them naively as if they were UTC (no timezone byte in the wire format). Simulate that + // here for a non-UTC zone and confirm decode still recovers the true absolute instant, rather + // than shifting it by a full UTC-offset (which is what put last night's sleep session on the + // wrong side of the app's 7 PM day boundary). + let tz = TimeZone(identifier: "America/New_York")! + var localCalendar = Calendar(identifier: .gregorian) + localCalendar.timeZone = tz + + // Snap to the second so DateComponents round-trips exactly. + let trueInstant = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) + let localFields = localCalendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: trueInstant + ) + + // What the ring's own naive clock would store: the local fields, reinterpreted as if they + // were UTC (no timezone concept on-device). + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(identifier: "UTC")! + let ringNaiveInstant = utcCalendar.date(from: localFields)! + let ringSecondsFromRing = Int(ringNaiveInstant.timeIntervalSince1970 - YCBTBytes.epochOffset) + + let decoded = YCBTBytes.date(ringSecondsFromRing, timeZone: tz) + + XCTAssertEqual(decoded.timeIntervalSince1970, trueInstant.timeIntervalSince1970, accuracy: 1) + } + + func testRingSecondsAndDateRoundTrip() { + let tz = TimeZone(identifier: "America/New_York")! + let date = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) + + let ringSeconds = YCBTBytes.ringSeconds(date, timeZone: tz) + let decoded = YCBTBytes.date(ringSeconds, timeZone: tz) + + XCTAssertEqual(decoded.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1) + } + + /// Sleep segment durations are u24; a u16 read truncates anything over 18h12m. + func testU24ReadsThreeBytesLittleEndian() { + XCTAssertEqual(YCBTBytes.u24([0x40, 0x19, 0x01], 0), 72_000) + XCTAssertEqual(YCBTBytes.u24([0x00, 0x00], 0), 0, "a short buffer reads 0, never out of bounds") + } +} diff --git a/PulseLoopTests/YCBTDriverTests.swift b/PulseLoopTests/YCBTDriverTests.swift new file mode 100644 index 00000000..77691f34 --- /dev/null +++ b/PulseLoopTests/YCBTDriverTests.swift @@ -0,0 +1,110 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// The driver's frame routing, and the one thing it does that no decoder can: **acknowledging the ring's +/// DevControl pushes**. The ring retransmits a push until the app answers `04 {00}`, so a missing +/// ACK doesn't just lose an event — it wedges the link with the same frame over and over. +@MainActor +final class YCBTDriverTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + private let stream = CBUUID(string: YCBTUUIDs.stream) + + /// The wire bytes the ring sees. The driver enqueues the *logical* command; `RingBLEClient` adds the + /// length field and CRC via `frame(_:)`, so both halves are asserted here. + private func wireBytes(_ driver: YCBTDriver, _ logical: Data) -> String { + driver.frame(logical).hexString + } + + // MARK: Group-4 auto-ACK + + /// A measurement-status push (`04 13`) must be ACKed with `04 13 07 00 00 ` **and** decoded. + func testDevControlPushIsAckedAndDecoded() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer) + + // 04 13 | type 0 (heart rate), state 1, 72 bpm + let push = YCBTFrame.frame([0x04, 0x13, 0x00, 0x01, 72]) + let events = driver.ingest(push, from: stream) + + XCTAssertEqual(writer.sent, [Data([0x04, 0x13, 0x00])], "every push is ACKed with `04 {00}`") + XCTAssertEqual(wireBytes(driver, writer.sent[0]), "0413070000e19d") + + guard case let .heartRateSample(bpm, _) = events.first else { + return XCTFail("the push must still decode, got \(events)") + } + XCTAssertEqual(bpm, 72) + } + + /// The ACK goes out for *every* DevControl key, not just the ones we decode — an unhandled push + /// (SOS, find-phone, sedentary) still has to stop retransmitting. It surfaces as a plain ack. + func testUnhandledDevControlPushIsStillAcked() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer) + + // 04 00 — FindMobile ("find my phone" pressed on the ring). No product surface; ACK + log only. + let events = driver.ingest(YCBTFrame.frame([0x04, 0x00, 0x01]), from: stream) + + XCTAssertEqual(writer.sent, [Data([0x04, 0x00, 0x00])]) + XCTAssertEqual(wireBytes(driver, writer.sent[0]), "04000700009a1d") + guard case let .commandAck(commandId) = events.first else { + return XCTFail("expected commandAck, got \(events)") + } + XCTAssertEqual(commandId, 0x00) + } + + /// A 1-byte `0xFB…0xFF` payload on group 4 is an **error frame**, not a push. The SDK drops it + /// silently; ACKing it would answer a rejection with an ACK for a push that never happened. + func testDevControlErrorFrameIsNotAcked() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer) + + _ = driver.ingest(YCBTFrame.frame([0x04, 0x13, 0xfc]), from: stream) + + XCTAssertTrue(writer.sent.isEmpty, "an error frame must never be ACKed") + } + + /// Only group 4 is ACKed. A live-stream frame (group 6) that got an ACK would put a stray `06 xx` + /// write on the wire for every heartbeat the ring streams. + func testLiveStreamFramesAreNotAcked() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer) + + _ = driver.ingest(YCBTFrame.frame([0x06, 0x01, 82]), from: stream) + + XCTAssertTrue(writer.sent.isEmpty) + } + + // MARK: Disconnect + + /// The history transfer is self-driving: its stall watchdog is a timer, so a ring that drops out of + /// range mid-dump leaves it stepping through the rest of the catalog — one `05 xx` query every 10 s — + /// into a write queue `RingBLEClient` only clears *at* disconnect. The reconnect would then flush + /// those stale queries ahead of the handshake, against a fresh transfer that never asked for them. + /// The driver must therefore abandon the transfer when the link **ends**, not when the next begins. + /// + /// An in-flight transfer refuses a re-entrant `start`, so a second `syncHistory()` writing its first + /// query again is the proof that the disconnect really did abandon the first. + func testConnectionDidEndAbandonsTheInFlightHistoryTransfer() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer) + let engine = driver.makeSyncEngine() + + engine.syncHistory() // → `05 02` (sport); the transfer is now in flight + XCTAssertEqual(writer.sent, [Data([0x05, 0x02])]) + + writer.sent.removeAll() + engine.syncHistory() + XCTAssertTrue(writer.sent.isEmpty, "sanity: an in-flight transfer refuses a re-entrant start") + + driver.connectionDidEnd() // the ring went out of range mid-dump + + engine.syncHistory() + XCTAssertEqual(writer.sent, [Data([0x05, 0x02])], "the transfer must have been abandoned on disconnect") + } +} diff --git a/PulseLoopTests/YCBTEncoderTests.swift b/PulseLoopTests/YCBTEncoderTests.swift new file mode 100644 index 00000000..705126a4 --- /dev/null +++ b/PulseLoopTests/YCBTEncoderTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import PulseLoop + +/// Outbound byte correctness for the connect handshake. Two of these guard against frames that are +/// actively harmful: `05 40…4E` are the Health **Delete** opcodes (the old "enable monitoring" burst), +/// and `02 24/26/28` are GetCardInfo/GetSleepStatus/GetMeasurementFunction (the old "history" paging). +final class YCBTEncoderTests: XCTestCase { + private let encoder = YCBTEncoder() + + private func gregorian(_ iso: String) -> Date { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.date(from: iso)! + } + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + // MARK: setTime + + /// The weekday byte is Mon=0 … Sun=6. PulseLoop used to send a literal `0x00`, so the ring believed + /// every day was Monday. 2026-07-06 is a Monday, so the capture's `…0e 00` still holds. + func testSetTimeWeekdayByteIsCorrectForEveryDay() { + // Mon 2026-07-06 → 0 … Sun 2026-07-12 → 6. + for (offset, expected) in (0...6).map({ ($0, UInt8($0)) }) { + let date = gregorian("2026-07-06 12:34:14").addingTimeInterval(TimeInterval(offset) * 86_400) + let command = encoder.setTime(date, calendar: utcCalendar) + XCTAssertEqual(command.last, expected, "weekday byte for day +\(offset)") + } + } + + func testSetTimeMatchesTheCapturedFrame() { + let command = encoder.setTime(gregorian("2026-07-06 12:34:14"), calendar: utcCalendar) + // 01 00 | year 2026 LE | 07 | 06 | 0c | 22 | 0e | weekday 0 (Monday) + XCTAssertEqual(command, [0x01, 0x00, 0xea, 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00]) + } + + // MARK: Monitors + + /// The five real monitor enables, each `{enable, intervalMinutes}`. The ring's firmware refuses an + /// interval below 30 minutes, and PulseLoop's shared default is 5 (a Colmi cadence) — so it must be + /// floored, not passed through. + func testMonitorCommandsClampTheIntervalToThirtyMinutes() { + let settings = MeasurementSettings( + hrEnabled: true, hrIntervalMinutes: 5, + spo2Enabled: true, stressEnabled: true, hrvEnabled: true, temperatureEnabled: true + ) + XCTAssertEqual(encoder.monitorCommands(settings), [ + [0x01, 0x0c, 0x01, 30], // heart rate + [0x01, 0x1c, 0x01, 30], // blood pressure (rides the HR toggle — same PPG sweep) + [0x01, 0x20, 0x01, 30], // temperature + [0x01, 0x26, 0x01, 30], // SpO₂ + [0x01, 0x45, 0x01, 30, 0, 0, 0], // HRV — 5-byte payload, tail UNVERIFIED, zero-filled + ]) + } + + func testMonitorCommandsHonourAnIntervalAboveTheFloorAndTheDisabledFlags() { + let settings = MeasurementSettings( + hrEnabled: true, hrIntervalMinutes: 60, + spo2Enabled: false, stressEnabled: false, hrvEnabled: false, temperatureEnabled: false + ) + XCTAssertEqual(encoder.monitorCommands(settings), [ + [0x01, 0x0c, 0x01, 60], + [0x01, 0x1c, 0x01, 60], + [0x01, 0x20, 0x00, 60], + [0x01, 0x26, 0x00, 60], + [0x01, 0x45, 0x00, 60, 0, 0, 0], + ]) + } + + // MARK: User profile + + /// The old encoder replayed the capture's `01 03 aa 40 00 2b` blob — 170 cm / 64 kg / 43 y — for + /// every user, which skews the ring's own calorie and BP algorithms. + func testUserInfoCarriesTheRealProfile() { + let profile = UserProfileValues(metric: true, sex: "male", age: 31, heightCm: 183, weightKg: 78) + XCTAssertEqual(encoder.userInfo(profile), [0x01, 0x03, 183, 78, 1, 31]) + + let female = UserProfileValues(metric: false, sex: "female", age: 29, heightCm: 165, weightKg: 60) + XCTAssertEqual(encoder.userInfo(female), [0x01, 0x03, 165, 60, 0, 29]) + } + + // MARK: Startup handshake + + func testStartupSendsNoHealthDeleteOrRetiredGetOpcodes() { + let sequence = encoder.startupSequence() + + for command in sequence { + let group = command[0] + let cmd = command[1] + XCTAssertFalse(group == 0x05 && (0x40...0x4e).contains(cmd), + "05 \(String(cmd, radix: 16)) is a Health-DELETE opcode — never send it") + XCTAssertFalse(group == 0x05, + "the handshake must not touch the Health group at all; the transfer machine owns it") + XCTAssertFalse(group == 0x02 && [0x24, 0x26, 0x28].contains(cmd), + "02 \(String(cmd, radix: 16)) is GetCardInfo/GetSleepStatus/GetMeasurementFunction, not history") + } + } + + /// Order matters: the SmartHealth app interrogates the device before it writes settings, and the + /// live-status push is last (`03 09 01 00 02` — without it the ring never streams `06 00` and live + /// steps freeze). + func testStartupOrderMirrorsTheSmartHealthHandshake() { + let sequence = encoder.startupSequence().map { Array($0.prefix(2)) } + + XCTAssertEqual(sequence.first, [0x01, 0x00], "the clock goes first") + XCTAssertEqual(sequence.last, [0x03, 0x09], "the live-status push goes last") + XCTAssertEqual(Array(sequence[1...5]), [ + [0x02, 0x00], // GetDeviceInfo — battery + firmware + [0x02, 0x01], // GetSupportFunction — capability bitmap + [0x02, 0x1b], // GetChipScheme + [0x02, 0x03], // GetDeviceName + [0x02, 0x07], // GetUserConfig + ]) + XCTAssertEqual(encoder.startupSequence().last, [0x03, 0x09, 0x01, 0x00, 0x02]) + + // Settings follow the interrogation: language, units, the five monitors, then the profile. + XCTAssertEqual(Array(sequence[6...]), [ + [0x01, 0x12], [0x01, 0x04], + [0x01, 0x0c], [0x01, 0x1c], [0x01, 0x20], [0x01, 0x26], [0x01, 0x45], + [0x01, 0x03], + [0x03, 0x09], + ]) + } + + // MARK: History commands + + func testHistoryRequestAndBlockAckBytes() { + XCTAssertEqual(encoder.healthHistoryRequest(.heart), [0x05, 0x06]) + XCTAssertEqual(encoder.healthHistoryRequest(.all), [0x05, 0x09]) + XCTAssertEqual(encoder.healthHistoryRequest(.sleep), [0x05, 0x04]) + XCTAssertEqual(encoder.historyBlockAck(status: 0x00), [0x05, 0x80, 0x00]) + XCTAssertEqual(encoder.historyBlockAck(status: 0x04), [0x05, 0x80, 0x04]) + } + + // MARK: Live measurement + + func testLiveMeasurementModesUseDistinctSensors() { + // The 03 2f payload's mode byte selects the sensor/LED; each metric must use its own. + XCTAssertEqual(encoder.heartRateStart(), [0x03, 0x2f, 0x01, 0x00]) // HR (green) + XCTAssertEqual(encoder.bloodPressureStart(), [0x03, 0x2f, 0x01, 0x01]) // BP + XCTAssertEqual(encoder.spo2Start(), [0x03, 0x2f, 0x01, 0x02]) // SpO₂ (red/IR) + XCTAssertEqual(encoder.hrvStart(), [0x03, 0x2f, 0x01, 0x0a]) // HRV + } + + /// The stop **echoes the mode it started** — it is not a mode-agnostic `{00, 00}`. Every SmartHealth + /// measure screen passes its own `getType()` to `appStartMeasurement(enable, type)` in both + /// directions; the capture's `03 2f 00 00` was just the HR screen's stop. Sending mode 0 to stop an + /// SpO₂ sweep was telling the ring to stop *heart rate*. + func testLiveMeasurementStopEchoesItsOwnMode() { + XCTAssertEqual(encoder.heartRateStop(), [0x03, 0x2f, 0x00, 0x00]) + XCTAssertEqual(encoder.bloodPressureStop(), [0x03, 0x2f, 0x00, 0x01]) + XCTAssertEqual(encoder.spo2Stop(), [0x03, 0x2f, 0x00, 0x02]) + XCTAssertEqual(encoder.hrvStop(), [0x03, 0x2f, 0x00, 0x0a]) + } + + // MARK: Find device + + /// The real find-device is AppControl `03 00` (`CMD.KEY_AppControl.FindDevice == 0`) with the exact + /// payload SmartHealth's own button sends (`appFindDevice(1, 5, 2)`). The old `04 0e 00` was not a + /// command at all — group 4 is the *device→app* push channel and key `0x0e` is `MeasurementResult`, + /// so the capture was showing SmartHealth **ACKing a push**, which `YCBTDriver` now does for itself. + func testFindDeviceUsesAppControlNotADevControlAck() { + XCTAssertEqual(encoder.findDevice(), [0x03, 0x00, 0x01, 0x05, 0x02]) + XCTAssertNotEqual(encoder.findDevice()[0], 0x04, "group 4 is device→app; the app never initiates one") + } +} diff --git a/PulseLoopTests/YCBTFrameAssemblerTests.swift b/PulseLoopTests/YCBTFrameAssemblerTests.swift new file mode 100644 index 00000000..defc15c7 --- /dev/null +++ b/PulseLoopTests/YCBTFrameAssemblerTests.swift @@ -0,0 +1,80 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// GATT-fragment reassembly. A logical frame longer than `MTU-3` arrives as several notifications, and +/// several short frames can arrive in one. The old driver required `declaredLength == bytes.count` and +/// dropped every split frame as garbage — which is half of why TK5 history never landed. +@MainActor +final class YCBTFrameAssemblerTests: XCTestCase { + private let stream = CBUUID(string: YCBTUUIDs.stream) + private let command = CBUUID(string: YCBTUUIDs.command) + + /// A valid frame with an `n`-byte payload of `fill`. + private func frame(cmd: UInt8, payloadLength: Int, fill: UInt8 = 0xaa) -> Data { + YCBTFrame.frame([0x05, cmd] + [UInt8](repeating: fill, count: payloadLength)) + } + + func testFrameSplitAcrossThreeNotificationsIsReassembled() { + let assembler = YCBTFrameAssembler() + let whole = frame(cmd: 0x15, payloadLength: 60) // 66 bytes: bigger than a 23-byte default MTU + + XCTAssertTrue(assembler.append(whole.prefix(20), from: stream).isEmpty) + XCTAssertTrue(assembler.append(whole.dropFirst(20).prefix(20), from: stream).isEmpty) + let done = assembler.append(whole.dropFirst(40), from: stream) + + XCTAssertEqual(done, [whole]) + XCTAssertNotNil(YCBTFrame(validating: done[0]), "the reassembled frame must still validate") + } + + func testTwoFramesInOneNotificationBothEmerge() { + let assembler = YCBTFrameAssembler() + let first = frame(cmd: 0x15, payloadLength: 6) + let second = frame(cmd: 0x18, payloadLength: 20) + + XCTAssertEqual(assembler.append(first + second, from: stream), [first, second]) + } + + /// A partial frame followed by a whole one in the same notification: the tail completes the first + /// and the rest emerges intact. + func testFragmentThenWholeFrameInOneNotification() { + let assembler = YCBTFrameAssembler() + let first = frame(cmd: 0x15, payloadLength: 30) + let second = frame(cmd: 0x18, payloadLength: 4) + + XCTAssertTrue(assembler.append(first.prefix(10), from: stream).isEmpty) + XCTAssertEqual(assembler.append(first.dropFirst(10) + second, from: stream), [first, second]) + } + + /// Garbage (a truncated tail from a dropped connection, a stray notification) must not poison the + /// stream: resync byte by byte until a plausible header appears. + func testGarbagePrefixResyncsToTheNextValidFrame() { + let assembler = YCBTFrameAssembler() + let good = frame(cmd: 0x15, payloadLength: 6) + let garbage = Data([0xff, 0x00, 0xff, 0xff, 0x7f]) // implausible group + absurd length + + XCTAssertEqual(assembler.append(garbage + good, from: stream), [good]) + } + + /// Fragments from the command channel and the async stream interleave; concatenating one onto the + /// other would corrupt both. + func testChannelsBufferIndependently() { + let assembler = YCBTFrameAssembler() + let streamFrame = frame(cmd: 0x15, payloadLength: 30) + let commandFrame = YCBTFrame.frame([0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64]) + + XCTAssertTrue(assembler.append(streamFrame.prefix(10), from: stream).isEmpty) + XCTAssertEqual(assembler.append(commandFrame, from: command), [commandFrame]) + XCTAssertEqual(assembler.append(streamFrame.dropFirst(10), from: stream), [streamFrame]) + } + + func testResetDropsPartialFrames() { + let assembler = YCBTFrameAssembler() + let whole = frame(cmd: 0x15, payloadLength: 30) + + XCTAssertTrue(assembler.append(whole.prefix(10), from: stream).isEmpty) + assembler.reset() + // The tail alone is garbage now — it must not be mistaken for the rest of the dropped frame. + XCTAssertTrue(assembler.append(whole.dropFirst(10), from: stream).isEmpty) + } +} diff --git a/PulseLoopTests/YCBTHealthRecordsTests.swift b/PulseLoopTests/YCBTHealthRecordsTests.swift new file mode 100644 index 00000000..2aea5134 --- /dev/null +++ b/PulseLoopTests/YCBTHealthRecordsTests.swift @@ -0,0 +1,374 @@ +import XCTest +@testable import PulseLoop + +/// Record decoders, run over a *reassembled transfer buffer* (never a single frame). Buffers here are +/// the payload bytes of the SmartHealth capture's own history frames, so the offsets are checked +/// against real hardware output; the sleep fixture is one full night. +final class YCBTHealthRecordsTests: XCTestCase { + private func bytes(_ hex: String) -> [UInt8] { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i.. Int? in + if case let .activityUpdate(_, value, _, _) = event { return value } else { return nil } + } + XCTAssertEqual(steps.max(), 3336) + } + + /// Respiratory rate (@10) was decoded but silently dropped before A3. + func testCombinedVitalsDecodesRespiratoryRate() { + let events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + XCTAssertEqual(values(.respiratoryRate, in: events), [14, 13, 13, 12, 13, 12, 13, 12]) + XCTAssertEqual(MeasurementKind.respiratoryRate.unit, "brpm") + } + + /// Every record in the capture carries `tempInt = 0, tempFrac = 15` and `bloodSugar = 0` — the ring's + /// "never measured" fillers (SmartHealth's own chart drops exactly `frac == 15`). Neither may become + /// a 0.15 °C reading or a 0 mg/dL one. + func testCombinedVitalsSkipsUnmeasuredTemperatureAndBloodSugar() { + let events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + XCTAssertEqual(values(.temperature, in: events), []) + XCTAssertEqual(values(.bloodSugar, in: events), []) + } + + /// A record that *does* carry them: temp `36 . 6` and blood sugar raw 55 = 5.5 mmol/L → 99.09 mg/dL. + func testCombinedVitalsDecodesTemperatureAndBloodSugarWhenPresent() { + let events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31721046764f610f3a0324061504370000")) + XCTAssertEqual(values(.temperature, in: events).first ?? 0, 36.6, accuracy: 0.001) + XCTAssertEqual(values(.bloodSugar, in: events).first ?? 0, 99.088, accuracy: 0.001) + XCTAssertEqual(MeasurementKind.bloodSugar.unit, "mg/dL") + } + + /// An unworn record (SpO₂ out of range, HRV 0, BP 0) still carries the day's step count — and + /// nothing else. + func testUnwornCombinedVitalsRecordYieldsStepsOnly() { + let events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31080d4700000000000000000000000000")) + XCTAssertEqual(events.count, 1) + guard case .activityUpdate = events.first else { + return XCTFail("expected a lone activityUpdate, got \(events)") + } + } + + /// Records are sliced from the whole buffer, so a trailing partial record is dropped rather than + /// misaligning the ones before it. + func testPartialTrailingRecordIsDropped() { + let events = YCBTHealthRecords.heartRate(bytes("1cf0de310047" + "1afede3100")) + XCTAssertEqual(events.count, 1) + } + + // MARK: Sleep (variable-length sessions) + + func testSleepDecodesAFullNightMatchingTheApp() { + // One 420-byte session from the capture; stage totals verified against the app's on-screen + // breakdown (deep 93 / light 249 / rem 130 min — the deltas below are per-segment rounding). + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(capturedNight).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(Double(stages.filter { $0 == .deep }.count), 93, accuracy: 3) + XCTAssertEqual(Double(stages.filter { $0 == .light }.count), 249, accuracy: 3) + XCTAssertEqual(Double(stages.filter { $0 == .rem }.count), 130, accuracy: 3) + XCTAssertFalse(stages.contains(.awake)) + } + + /// The buffer can hold several nights back to back — each session's 20-byte header carries its own + /// length at [2..3], so the decoder walks them rather than assuming one. + func testMultipleSessionsInOneBuffer() { + let timelines = YCBTHealthRecords.sleep(capturedNight + capturedNight).filter { event in + if case .sleepTimeline = event { return true } else { return false } + } + XCTAssertEqual(timelines.count, 2) + } + + /// Stage tags are classified by `tag & 0x0F`, and an unrecognised tag is *skipped*, never terminal. + /// The old decoder exact-matched `0xF1…0xF4` and `break`ed on anything else, so one `0xF5` nap + /// segment truncated the rest of the night. + func testNapSegmentDoesNotTruncateTheNight() { + let session = sleepSession(segments: [ + (0xf2, 60 * 60), // light, 1h + (0xf5, 20 * 60), // nap → .unknown, must not end the list + (0xf1, 30 * 60), // deep, 30m — only reachable if the nap didn't break the loop + ]) + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(session).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(stages.filter { $0 == .light }.count, 60) + XCTAssertEqual(stages.filter { $0 == .unknown }.count, 20) + XCTAssertEqual(stages.filter { $0 == .deep }.count, 30, "the segment after the nap must survive") + } + + /// Segment durations are **u24**, not u16 — a 20-hour segment (72 000 s) truncates to 6 464 s when + /// read as two bytes. + func testSegmentDurationIsU24() { + let session = sleepSession(segments: [(0xf2, 72_000)]) + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(session).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(stages.count, 1200) // 72 000 s / 60 + } + + /// Some firmware repeats a segment inside one session; the SDK skips a start time it has already + /// taken (`DataUnpack` case 4 keeps a list of them). Without that guard the repeat is counted twice — + /// and because the timeline is laid out positionally, it also shifts every later stage by its own + /// duration, so the whole night after it lands in the wrong place. + func testRepeatedSegmentIsCountedOnce() { + var session = sleepSession(segments: [(0xf2, 30 * 60), (0xf1, 30 * 60), (0xf3, 30 * 60)]) + // Re-send the deep segment verbatim (same start, same length) as a fourth segment. + let deep = Array(session[(20 + 8)..<(20 + 16)]) + session[2] = UInt8(20 + 4 * 8) // recordLen now claims four segments + session.append(contentsOf: deep) + + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(session).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(stages.filter { $0 == .deep }.count, 30, "the repeat must not double the stage") + XCTAssertEqual(stages.count, 90, "…and must not push the rest of the night 30 min late") + } + + /// A truncated transfer (the header promises more segments than the buffer holds) must not read off + /// the end — the SDK's own loop has no such guard. + func testTruncatedSessionIsClamped() { + var session = sleepSession(segments: [(0xf2, 600), (0xf1, 600)]) + session.removeLast(8) // drop the second segment but leave the header's length claiming it + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(session).first else { + return XCTFail("expected a sleepTimeline") + } + XCTAssertEqual(stages.count, 10) + } + + // MARK: SpO₂ (query 0x1A, 6-byte records) + + func testSpo2RecordsDecode() { + // 97 %, an unmeasured record (0), 95 %. + let events = YCBTHealthRecords.spo2(bytes("1cf0de3100611afede310100260cdf31005f")) + XCTAssertEqual(values(.spo2, in: events), [97, 95]) + XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) + } + + // MARK: Blood pressure (query 0x08, 8-byte records) + + /// `[ts][isInflated@4][sys@5][dia@6][hr@7]`. Two upserting history rows, plus the HR the sweep + /// measured alongside them — and *not* a `.bloodPressureSample` (that would append a duplicate row + /// on every re-sync, since the ring never forgets a record). + func testBloodPressureRecordsDecode() { + let events = YCBTHealthRecords.bloodPressure(bytes("1cf0de3101764f401afede3100000000")) + XCTAssertEqual(values(.bloodPressureSystolic, in: events), [118]) + XCTAssertEqual(values(.bloodPressureDiastolic, in: events), [79]) + XCTAssertEqual(values(.heartRate, in: events), [64]) + XCTAssertFalse(events.contains { if case .bloodPressureSample = $0 { return true } else { return false } }) + XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) + } + + // MARK: Body data (query 0x33, 28-byte records) + + /// hrv `(62, 5)` = 62.5 ms, stress (the SDK's "pressure") `(5, 3)` = **53**, fatigue (its "body") + /// `(4, 2)` = **42**, VO₂max `42`. + /// + /// The two int/frac pairs are read on *different scales* on purpose. HRV is milliseconds, so it is + /// the SDK's string-concatenated composite (`Float.parseFloat("62.5")`). Stress and fatigue are + /// 0–10 scores that SmartHealth renders ×10 on a 1…100 scale — `getCompositePressure()` is + /// `Integer.parseInt("5" + "3")` and the live screen does `(int)(parseFloat("5.3") * 10)` — so 5.3 + /// would be the app's 53, ten times low, inside PulseLoop's own 1…100 stress scale. + func testBodyDataDecodesHrvInMsAndStressFatigueOnTheAppsHundredScale() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord) + XCTAssertEqual(values(.hrv, in: events).first ?? 0, 62.5, accuracy: 0.001) + XCTAssertEqual(values(.stress, in: events).first ?? 0, 53, accuracy: 0.001) + XCTAssertEqual(values(.fatigue, in: events).first ?? 0, 42, accuracy: 0.001) + XCTAssertEqual(values(.vo2max, in: events).first ?? 0, 42, accuracy: 0.001) + XCTAssertEqual(MeasurementKind.vo2max.unit, "mL/kg/min") + XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) + } + + /// The scale, at the seam where it is easy to get wrong: a whole-number score has fraction 0, and + /// `(7, 0)` is 70 — not 7, and not 7.0. `RingEventBridge.stressRange` (1…100) can't catch a 10× + /// error, so this is the only place it is pinned. + func testStressScoreIsDigitConcatenatedNotADecimalComposite() { + XCTAssertEqual(YCBTHealthRecords.score(5, 3), 53, accuracy: 0.001) + XCTAssertEqual(YCBTHealthRecords.score(7, 0), 70, accuracy: 0.001) + XCTAssertEqual(YCBTHealthRecords.score(10, 0), 100, accuracy: 0.001) + XCTAssertEqual(YCBTHealthRecords.composite(5, 3), 5.3, accuracy: 0.001, "hrv/temperature keep the composite") + } + + /// A record too short to hold the fields from @16 on (old firmware is rumoured to send a ~17-byte + /// prefix; a truncated transfer produces the same shape) must not read into the next record's bytes — + /// or off the end of the buffer. The stride slicer drops the partial; the full record before it lands. + func testShortBodyDataRecordIsDroppedNotMisread() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord + Array(capturedBodyRecord.prefix(17))) + XCTAssertEqual(values(.hrv, in: events).count, 1) + XCTAssertEqual(values(.vo2max, in: events), [42]) + } + + // MARK: Sport (query 0x02, 14-byte records) + + /// `[start][end][steps@8][distance@10][calories@12]` → an *additive* bucket (unlike the All record's + /// cumulative step counter). Calories are dropped — `.activityBucket` has no calorie channel. + func testSportRecordsDecodeToActivityBuckets() { + let events = YCBTHealthRecords.sport(bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000")) + XCTAssertEqual(events.count, 1, "the all-zero record is not a bucket") + guard case let .activityBucket(timestamp, steps, distance) = events.first else { + return XCTFail("expected an activityBucket, got \(events)") + } + XCTAssertEqual(timestamp, YCBTBytes.date(836_694_044), "the bucket is stamped with the record's start") + XCTAssertEqual(steps, 640) + XCTAssertEqual(distance, 480) + } + + // MARK: Temperature (query 0x1E, 7-byte records) + + /// `Float.parseFloat(int + "." + frac)` in the SDK, so the fraction's scale follows its digit count: + /// `5` → `36.5`, `25` → `36.25`. The third record is the `int = 0` no-sample filler. + func testTemperatureRecordsDecodeWithStringConcatFraction() { + let events = YCBTHealthRecords.temperature(bytes("1cf0de310024051afede31002419260cdf3100000f")) + let temps = values(.temperature, in: events) + XCTAssertEqual(temps.count, 2) + XCTAssertEqual(temps[0], 36.5, accuracy: 0.001) + XCTAssertEqual(temps[1], 36.25, accuracy: 0.001) + XCTAssertEqual(MeasurementKind.temperature.unit, "°C") + XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) + } + + /// `frac == 15` is the ring's "no sample" marker **whatever the integer is** — SmartHealth's chart + /// drops on the fraction alone. A record left with a stale integer (`36, 15`) would otherwise decode + /// to a 36.15 °C reading that the bridge's 30…45 °C gate happily passes, and the ring replays its + /// whole log on every sync, so it would be re-upserted forever. Both records here are fillers. + func testTemperatureFillerFractionIsDroppedEvenWithANonZeroInteger() { + let events = YCBTHealthRecords.temperature(bytes("1cf0de3100240f" + "1afede3100000f")) + XCTAssertEqual(values(.temperature, in: events), []) + } + + // MARK: Comprehensive (query 0x2F, 44-byte records) + + /// Blood sugar at @5–6 is `int * 10 + frac` **tenths of a mmol/L** (SmartHealth files it in the same + /// column it filters to 11…333, i.e. 1.1…33.3 mmol/L). 5.5 mmol/L × 18.016 = 99.09 mg/dL, the unit + /// PulseLoop stores. The second record is unmeasured. + func testComprehensiveDecodesBloodSugarAsMgdl() { + let events = YCBTHealthRecords.comprehensive(bytes( + "1cf0de3101050500000000000000000000000000000000000000000000000000000000000000000000000000" + + "1afede31010000000000000000000000000000000000000000000000000000000000000000000000000000000000")) + let sugar = values(.bloodSugar, in: events) + XCTAssertEqual(sugar.count, 1) + XCTAssertEqual(sugar[0], 99.088, accuracy: 0.001) + XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) + } + + // MARK: Type table → decoder wiring + + /// `decode(_:type:)` is what the transfer machine actually calls; every catalog type must reach a + /// decoder (a type wired to no decoder would ACK and silently discard a whole transfer). + func testEveryCatalogTypeDecodesThroughTheTypeTable() { + let spo2 = bytes("1cf0de3100611afede310100260cdf31005f") + let blood = bytes("1cf0de3101764f401afede3100000000") + let sport = bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000") + let temperature = bytes("1cf0de310024051afede31002419260cdf3100000f") + + XCTAssertEqual(YCBTHealthRecords.decode(spo2, type: .spo2).count, 2) + XCTAssertEqual(YCBTHealthRecords.decode(blood, type: .blood).count, 3) // sys + dia + hr + XCTAssertEqual(YCBTHealthRecords.decode(sport, type: .sport).count, 1) + XCTAssertEqual(YCBTHealthRecords.decode(temperature, type: .temperature).count, 2) + // hrv + stress + fatigue + vo2max + XCTAssertEqual(YCBTHealthRecords.decode(capturedBodyRecord, type: .bodyData).count, 4) + // 8 records × (steps + systolic + diastolic + spo2 + respiratory rate + hrv); temp and blood + // sugar are the unmeasured fillers in this capture. + XCTAssertEqual(YCBTHealthRecords.decode(capturedAllRecords, type: .all).count, 8 * 6) + XCTAssertFalse(YCBTHealthRecords.decode(capturedNight, type: .sleep).isEmpty) + XCTAssertFalse(YCBTHealthRecords.decode(capturedHeartRecords, type: .heart).isEmpty) + } + + // MARK: Fixtures + + /// Values of one kind, in record order. + private func values(_ kind: MeasurementKind, in events: [RingDecodedEvent]) -> [Double] { + events.compactMap { event in + if case let .historyMeasurement(eventKind, value, _) = event, eventKind == kind { return value } + return nil + } + } + + /// Timestamps of the history measurements, in record order — proves the epoch is read from bytes 0–3. + private func timestamps(in events: [RingDecodedEvent]) -> [Date] { + events.compactMap { event in + if case let .historyMeasurement(_, _, timestamp) = event { return timestamp } + return nil + } + } + + /// One 28-byte body-data record: `[ts][load 3.2][hrv 62.5][stress 5,3][fatigue 4,2][symp 5.5]` + /// `[sdnn 48][vo2max 42][pnn50 12][rmssd 55][lf 1200][hf 900][lfHf 1.3]`. + private lazy var capturedBodyRecord: [UInt8] = bytes("1cf0de3103023e0505030402050530002a0c3700b00484030d000000") + + /// Eight hourly overnight 6-byte heart records from the capture, matched to its wall clock. + private lazy var capturedHeartRecords: [UInt8] = bytes( + "1cf0de3100471afede310042260cdf31003f3b1adf31003e4328df3100425136df31003c6444df3100419852df31003a") + + /// Eight 20-byte "All" records from the capture (23:00–06:00). + private lazy var capturedAllRecords: [UInt8] = bytes( + "1cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf3100003f70" + + "49610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f00" + + "0000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df31" + + "00003a6a465f0c8002000f000000d89d") + + /// Build one sleep session: a 20-byte header (`recordLen` at [2..3]) + 8-byte + /// `[tag][segStart:u32][len:u24]` segments, one hour apart. + private func sleepSession(segments: [(tag: UInt8, seconds: Int)]) -> [UInt8] { + let recordLength = 20 + segments.count * 8 + var out: [UInt8] = [0xaf, 0xfa, UInt8(recordLength & 0xff), UInt8(recordLength >> 8)] + out.append(contentsOf: [UInt8](repeating: 0, count: 16)) // start/end/counts — unused by the decoder + for (index, segment) in segments.enumerated() { + let start = 0x31def01c + index * 3600 + out.append(segment.tag) + out.append(contentsOf: [UInt8(start & 0xff), UInt8((start >> 8) & 0xff), + UInt8((start >> 16) & 0xff), UInt8((start >> 24) & 0xff)]) + out.append(contentsOf: [UInt8(segment.seconds & 0xff), UInt8((segment.seconds >> 8) & 0xff), + UInt8((segment.seconds >> 16) & 0xff)]) + } + return out + } + + private lazy var capturedNight: [UInt8] = bytes( + "affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c9" + + "0400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200" + + "f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f345" + + "18df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf" + + "31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c1" + + "0000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000" + + "f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f" + + "47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df" + + "31050100f15f53df311e0100f27d54df31400400") +} diff --git a/PulseLoopTests/YCBTHistoryTransferTests.swift b/PulseLoopTests/YCBTHistoryTransferTests.swift new file mode 100644 index 00000000..4317d8f2 --- /dev/null +++ b/PulseLoopTests/YCBTHistoryTransferTests.swift @@ -0,0 +1,319 @@ +import XCTest +@testable import PulseLoop + +/// The YCBT history protocol, end to end: query → header → data frames → terminal block → **mandatory +/// ACK** → next type. This is the machinery PulseLoop never had (it paged with `02 26`, an unrelated +/// Get-group opcode, and never ACKed), so these assert the exact outbound bytes, not just behaviour. +@MainActor +final class YCBTHistoryTransferTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + /// Logical (unframed) commands — the writer seam takes these; `RingBLEClient` adds len + CRC. + private let heartQuery = Data([0x05, 0x06]) + private let allQuery = Data([0x05, 0x09]) + private let ackAccepted = Data([0x05, 0x80, 0x00]) + private let ackCrcFailure = Data([0x05, 0x80, 0x04]) + + /// `[recordCount:u16][totalPackets:u32][totalBytes:u32]`. + private func header(records: Int, packets: Int, bytes: Int) -> [UInt8] { + [UInt8(records & 0xff), UInt8(records >> 8), + UInt8(packets & 0xff), UInt8(packets >> 8), 0, 0, + UInt8(bytes & 0xff), UInt8(bytes >> 8), 0, 0] + } + + /// `[totalPackets:u16][totalBytes:u16][crc16:u16]` over the concatenated data payloads. + private func terminal(packets: Int, buffer: [UInt8], crc: UInt16? = nil) -> [UInt8] { + let checksum = crc ?? YCBTFrame.crc16(buffer) + return [UInt8(packets & 0xff), UInt8(packets >> 8), + UInt8(buffer.count & 0xff), UInt8((buffer.count >> 8) & 0xff), + UInt8(checksum & 0xff), UInt8((checksum >> 8) & 0xff)] + } + + /// Two 6-byte HR records (2026-07-06 ~23:00, 71 and 66 bpm) — the same shape the capture carries. + private let heartBuffer: [UInt8] = [ + 0x1c, 0xf0, 0xde, 0x31, 0x00, 0x47, + 0x1a, 0xfe, 0xde, 0x31, 0x00, 0x42, + ] + + private func heartRates(_ events: [RingDecodedEvent]) -> [Double] { + events.compactMap { event in + if case let .historyMeasurement(.heartRate, value, _) = event { return value } else { return nil } + } + } + + /// Wait until the watchdog has written `command` — never a fixed sleep: the *next* type's own + /// watchdog starts running the moment this one is written, so a test that oversleeps is timing the + /// wrong skip. + private func waitForWrite(_ command: Data, by writer: FakeWriter) async throws { + for _ in 0..<500 { + if writer.sent.contains(command) { return } + try await Task.sleep(nanoseconds: 1_000_000) + } + XCTFail("the watchdog never skipped the stalled type") + } + + // MARK: Happy path + + func testFullCycleAcksAndAdvancesToTheNextType() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + + transfer.start(types: [.heart, .all]) + XCTAssertEqual(writer.sent, [heartQuery], "start must write `05 06`, the Health-group heart query") + + let progress = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + guard case let .historySyncProgress(stage) = progress.first else { + return XCTFail("the header must announce progress, got \(progress)") + } + XCTAssertEqual(stage, "Syncing heart rate…") + + XCTAssertTrue(transfer.handle(cmd: 0x15, payload: heartBuffer).isEmpty, "data frames decode nothing on their own") + + writer.sent.removeAll() + let done = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + + XCTAssertEqual(heartRates(done), [71, 66]) + XCTAssertEqual(writer.sent, [ackAccepted, allQuery], + "the terminal block must be ACKed `05 80 00` before the next type is requested") + } + + /// The regression the whole rewrite exists for: the ring cuts the record stream at frame boundaries + /// wherever they fall, so a record straddles two data frames. Decoding per-frame (what the old + /// decoder did) drops it. + func testRecordStraddlingTwoDataFramesSurvives() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart]) + + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 2, bytes: heartBuffer.count)) + // Split mid-record: 9 bytes then 3, so record #2 spans both frames. + _ = transfer.handle(cmd: 0x15, payload: Array(heartBuffer[0..<9])) + _ = transfer.handle(cmd: 0x15, payload: Array(heartBuffer[9...])) + let done = transfer.handle(cmd: 0x80, payload: terminal(packets: 2, buffer: heartBuffer)) + + XCTAssertEqual(heartRates(done), [71, 66], "the straddling record must survive reassembly") + } + + // MARK: Failure paths + + func testCRCMismatchNacksAndRetriesTheTypeOnce() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .all]) + + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: heartBuffer) + + writer.sent.removeAll() + let first = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer, crc: 0xdead)) + XCTAssertTrue(heartRates(first).isEmpty, "a corrupt buffer must not be decoded") + XCTAssertEqual(writer.sent, [ackCrcFailure, heartQuery], "NACK `05 80 04`, then re-request the same type") + + // Second failure on the retry: give up on the type rather than looping the ring forever. + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: heartBuffer) + writer.sent.removeAll() + _ = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer, crc: 0xdead)) + XCTAssertEqual(writer.sent, [ackCrcFailure, allQuery], "after one retry the type is skipped") + } + + /// A header of ≤9 bytes is the SDK's "no stored data" reply. There is no transfer, so there is + /// nothing to ACK — ACKing here would tell the ring we accepted a block it never sent. + func testNoDataHeaderAdvancesWithoutAcking() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .all]) + + writer.sent.removeAll() + _ = transfer.handle(cmd: 0x06, payload: [0x00]) + XCTAssertEqual(writer.sent, [allQuery]) + } + + /// A 1-byte `0xFB…0xFF` payload is a rejection. `0xFC` (unsupported key) is permanent: the type is + /// dropped for the rest of the session, so a later sync doesn't ask again. + func testErrorFrameAdvancesAndUnsupportedTypeIsNotRequestedAgain() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .all]) + + writer.sent.removeAll() + _ = transfer.handle(cmd: 0x06, payload: [0xfc]) + XCTAssertEqual(writer.sent, [allQuery], "an error frame advances the queue and is never ACKed") + + _ = transfer.handle(cmd: 0x09, payload: [0xfc]) + writer.sent.removeAll() + transfer.start(types: [.heart, .all]) + XCTAssertTrue(writer.sent.isEmpty, "types the firmware rejected are not re-requested this session") + } + + // MARK: Queue composition (A3) + + /// The engine asks for the full nine-type catalog, in the SDK's ascending-key order + /// (`02 04 06 08 09 1A 1E 2F 33`). Sport before All is deliberate: sport buckets *assign* a past + /// day's step total while the All record's cumulative counter only ratchets it up, so the counter + /// must land last. Each type here answers "no data", which advances the queue without an ACK — + /// exactly what a ring that doesn't implement a type does. + func testEngineRequestsEveryHistoryTypeInOrder() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer) + engine.runStartup() + + var requested: [UInt8] = [] + for _ in 0..> bit) & 1`. + private func bitmap(length: Int, set bits: [(byte: Int, bit: Int)]) -> [UInt8] { + var payload = [UInt8](repeating: 0, count: length) + for bit in bits where bit.byte < length { + payload[bit.byte] |= UInt8(1 << bit.bit) + } + return payload + } + + /// A full-length bitmap with every bit set — the "this ring has everything" case. + private var allOnes: [UInt8] { [UInt8](repeating: 0xff, count: 27) } + + // MARK: - Bit → capability mapping + + /// Each mapped bit, in isolation, yields exactly its own capability and nothing else. This is the + /// table's regression net: a transposed byte or bit index shows up here as a wrong capability. + func testEachMappedBitYieldsExactlyItsCapability() { + let expected: [(byte: Int, bit: Int, capability: WearableCapability)] = [ + (0, 7, .steps), // ISHASSTEPCOUNT + (0, 6, .sleep), // ISHASSLEEP + (0, 3, .heartRate), // ISHASHEARTRATE + (0, 0, .bloodPressure), // ISHASBLOOD + (1, 3, .spo2), // ISHASBLOODOXYGEN + (1, 1, .hrv), // ISHASHRV + (6, 4, .findDevice), // ISHASFINDDEVICE + (8, 0, .temperature), // ISHASTEMP + (15, 1, .manualHeartRate), // ISHATESTHEART + (15, 2, .manualBloodPressure),// ISHASTESTBLOOD + (15, 3, .manualSpo2), // ISHASTESTSPO2 + (17, 3, .bloodSugar), // ISHASBLOODSUGAR + (22, 6, .stress), // IS_HAS_PRESSURE + (23, 0, .manualHrv), // IS_HAS_HRV_MEASUREMENT + ] + for entry in expected { + let payload = bitmap(length: 27, set: [(entry.byte, entry.bit)]) + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: payload), + [entry.capability], + "byte \(entry.byte) bit \(entry.bit) should map to \(entry.capability) alone" + ) + } + } + + /// Bit order is MSB-first *within* each byte. `ISHASSTEPCOUNT` is byte 0 bit 7 (0x80) and + /// `ISHASBLOOD` is byte 0 bit 0 (0x01) — an LSB-first reader would swap them, which is exactly the + /// mistake that would show a blood-pressure card on a step-only ring. + func testBitOrderingIsMSBFirstWithinAByte() { + var stepsOnly = [UInt8](repeating: 0, count: 27) + stepsOnly[0] = 0x80 + XCTAssertEqual(YCBTSupportFunction.capabilities(from: stepsOnly), [.steps]) + + var bloodOnly = [UInt8](repeating: 0, count: 27) + bloodOnly[0] = 0x01 + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bloodOnly), [.bloodPressure]) + } + + /// An all-ones bitmap yields precisely the mapped set — no more. Proves the unmapped/reserved bits + /// (ECG, dials, per-sport, notification apps…) are ignored rather than silently folded into + /// something. If a future bit is added to the table, this is the test that must be updated. + func testUnmappedBitsAreIgnored() { + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: allOnes), + [ + .steps, .sleep, .heartRate, .bloodPressure, .spo2, .hrv, .findDevice, .temperature, + .bloodSugar, .stress, + .manualHeartRate, .manualBloodPressure, .manualSpo2, .manualHrv, + ] + ) + } + + func testEmptyBitmapClaimsNothing() { + XCTAssertEqual(YCBTSupportFunction.capabilities(from: [UInt8](repeating: 0, count: 27)), []) + } + + /// Several bits packed into one byte decode together (the realistic shape of a reply). + func testMultipleBitsWithinOneByteDecodeTogether() { + var payload = [UInt8](repeating: 0, count: 14) + payload[0] = 0b1100_1001 // stepCount(7) + sleep(6) + heartRate(3) + blood(0) + payload[1] = 0b0000_1010 // bloodOxygen(3) + hrv(1) + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: payload), + [.steps, .sleep, .heartRate, .bloodPressure, .spo2, .hrv] + ) + } + + /// `rawBits` is the diagnostic view — the bitmap carries far more bits than we map, and an + /// unrecognised ring is triaged from the raw array. MSB-first, so 0b1000_0001 reads ends-in. + func testRawBitsAreMSBFirst() { + XCTAssertEqual( + YCBTSupportFunction.rawBits(from: [0b1000_0001]), + [true, false, false, false, false, false, false, true] + ) + } + + // MARK: - Length guards + + /// A payload shorter than the SDK's own `>= 14` gate is parsed as nothing at all — not as a partial + /// claim. A truncated reply must read as "no opinion" (baseline stands), never as a denial. + func testPayloadBelowTheSDKsMinimumClaimsNothing() { + for length in 0..<14 { + let payload = [UInt8](repeating: 0xff, count: length) + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: payload), + [], + "a \(length)-byte bitmap is below the SDK's 14-byte gate and must claim nothing" + ) + } + } + + /// No byte access may run off the end: every truncation of an all-ones bitmap must parse without + /// trapping, and must only ever claim a subset of the full-length result. + func testEveryTruncationIsSafeAndMonotonic() { + let full = YCBTSupportFunction.capabilities(from: allOnes) + for length in 0...allOnes.count { + let claimed = YCBTSupportFunction.capabilities(from: Array(allOnes.prefix(length))) + XCTAssertTrue(claimed.isSubset(of: full), "a \(length)-byte bitmap claimed more than the full one") + } + } + + /// The gates reproduce the SDK's *block* structure, not a per-byte bounds check. + /// `saveDeviceSupportFunctionData` reads bytes 14–17 only behind `if (bArr.length >= 18)`, so a + /// 16-byte bitmap has a physical byte 15 that the vendor SDK — and therefore we — must not read. + func testByte15IsNotReadBelowTheSDKs18ByteGate() { + let manualBits = [(byte: 15, bit: 1), (byte: 15, bit: 2), (byte: 15, bit: 3)] + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 16, set: manualBits)), []) + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 17, set: manualBits)), []) + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: bitmap(length: 18, set: manualBits)), + [.manualHeartRate, .manualBloodPressure, .manualSpo2] + ) + } + + /// The same block rule at the far end of the array: stress needs 23 bytes, manual-HRV needs 24. + func testTrailingBlocksNeedTheirOwnLengths() { + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 22, set: [(22, 6)])), []) + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(22, 6)])), [.stress]) + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(23, 0)])), []) + XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 24, set: [(23, 0)])), [.manualHrv]) + } + + /// A 14-byte bitmap (the shortest the SDK accepts) still yields the whole first block. + func testShortestAcceptedBitmapYieldsTheFirstBlock() { + let payload = bitmap(length: 14, set: [(0, 3), (1, 3), (8, 0)]) + XCTAssertEqual(YCBTSupportFunction.capabilities(from: payload), [.heartRate, .spo2, .temperature]) + } + + // MARK: - Refinement formula: baseline ∪ (gated ∩ derived) + + /// A coordinator that gates `temperature` + `stress` on the bitmap, over a fixed baseline. + @MainActor + private final class GatedCoordinator: WearableCoordinator { + nonisolated deinit {} + static let deviceType: RingDeviceType = .tk5 + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { false } + let capabilities: Set = [.heartRate, .steps, .battery] + let bitmapGatedCapabilities: Set = [.temperature, .stress] + let iconSystemName = "circle" + func makeDriver(writer: RingCommandWriter) -> WearableDriver { YCBTDriver(writer: writer) } + } + + /// A gated capability the ring claims is added. + @MainActor + func testBitmapAddsAPreApprovedCapabilityTheRingClaims() { + let refined = GatedCoordinator().refinedCapabilities(bitmapDerived: [.temperature]) + XCTAssertEqual(refined, [.heartRate, .steps, .battery, .temperature]) + } + + /// A gated capability the ring does *not* claim stays off — the whole point of gating. + @MainActor + func testBitmapWithholdsAPreApprovedCapabilityTheRingDoesNotClaim() { + let refined = GatedCoordinator().refinedCapabilities(bitmapDerived: [.temperature]) + XCTAssertFalse(refined.contains(.stress)) + } + + /// **The bitmap can never remove a baseline capability.** Firmwares under-report in the field (an + /// old one simply sends a shorter array), and a metric card vanishing mid-session is a worse failure + /// than one extra card. Here the ring claims nothing at all and the baseline still stands. + @MainActor + func testBitmapCannotRemoveABaselineCapability() { + let coordinator = GatedCoordinator() + XCTAssertEqual(coordinator.refinedCapabilities(bitmapDerived: []), coordinator.capabilities) + } + + /// **The bitmap can never add a capability the coordinator didn't pre-approve.** A bit we mapped + /// wrongly — or a metric PulseLoop has no decoder for — must not be able to conjure a UI surface. + @MainActor + func testBitmapCannotAddACapabilityTheCoordinatorDidNotPreApprove() { + let refined = GatedCoordinator().refinedCapabilities(bitmapDerived: [.bloodPressure, .bloodSugar, .remSleep]) + XCTAssertEqual(refined, [.heartRate, .steps, .battery]) + } + + /// A family that gates nothing is bit-for-bit unaffected by any bitmap — the zero-regression + /// property that keeps jring, QRing-Colmi and the TK5 out of this feature's blast radius. + @MainActor + func testFamilyThatGatesNothingIsUnaffectedByAnyBitmap() { + for coordinator in [TK5Coordinator(), JringCoordinator(), ColmiCoordinator()] as [any WearableCoordinator] { + XCTAssertTrue(coordinator.bitmapGatedCapabilities.isEmpty) + let refined = coordinator.refinedCapabilities(bitmapDerived: YCBTSupportFunction.capabilities(from: allOnes)) + XCTAssertEqual(refined, coordinator.capabilities, "\(type(of: coordinator)) must ignore the bitmap") + XCTAssertEqual(coordinator.refinedCapabilities(bitmapDerived: []), coordinator.capabilities) + } + } + + /// Refinement is idempotent: re-applying the same bitmap (the ring re-sends it on every handshake, + /// and a reconnect runs a fresh one) converges rather than accumulating. + @MainActor + func testRefinementIsIdempotent() { + let coordinator = GatedCoordinator() + let once = coordinator.refinedCapabilities(bitmapDerived: [.temperature, .stress]) + let twice = coordinator.refinedCapabilities(bitmapDerived: [.temperature, .stress]) + XCTAssertEqual(once, twice) + } + + // MARK: - Decoder wiring + + /// The `02 01` reply now emits `.supportFunctions` rather than a bare ack. + func testDecoderEmitsSupportFunctionsEvent() throws { + let payload = bitmap(length: 27, set: [(0, 3), (1, 3), (22, 6)]) + let frame = try XCTUnwrap(YCBTFrame(validating: YCBTFrame.frame([0x02, 0x01] + payload))) + let events = YCBTDecoder().decode(frame) + guard case let .supportFunctions(claimed) = events.first else { + return XCTFail("expected .supportFunctions, got \(events)") + } + XCTAssertEqual(claimed, [.heartRate, .spo2, .stress]) + } + + /// The `02 1b` reply reaches the debug feed with its value (`InnerUtils.isJieLiChipScheme`: 3/4/5). + func testDecoderEmitsChipSchemeEvent() throws { + let frame = try XCTUnwrap(YCBTFrame(validating: YCBTFrame.frame([0x02, 0x1b, 0x03]))) + guard case let .chipScheme(value) = YCBTDecoder().decode(frame).first else { + return XCTFail("expected .chipScheme") + } + XCTAssertEqual(value, 3) + XCTAssertTrue(YCBTChipScheme.isJieLi(value)) + XCTAssertFalse(YCBTChipScheme.isJieLi(0)) + } + + /// An error status in the chipScheme byte (the `0xFB…0xFF` band) folds to 0 = unknown, not a + /// scheme id of 255 — matching `unpackGetChipScheme`'s `>= 240` check. + func testChipSchemeErrorStatusFoldsToUnknown() { + XCTAssertEqual(YCBTChipScheme.value(from: [0xff]), 0) + XCTAssertEqual(YCBTChipScheme.value(from: []), 0) + XCTAssertEqual(YCBTChipScheme.value(from: [0x04]), 4) + } + + /// Neither event produces a `PulseEvent`: capabilities reach persistence via the re-published + /// `.deviceIdentified`, and chipScheme is diagnostic only. If either started fanning out here it + /// would be written to the metric store as a reading. + func testNeitherEventProducesAPulseEvent() { + XCTAssertTrue(RingEventBridge.events(for: .supportFunctions([.heartRate])).isEmpty) + XCTAssertTrue(RingEventBridge.events(for: .chipScheme(value: 3)).isEmpty) + } +} diff --git a/README.md b/README.md index 7de21373..a336ccad 100644 --- a/README.md +++ b/README.md @@ -139,13 +139,20 @@ declares exactly what it can do and the app shows only those features. | Ring | BLE Family | Advertised name | Price | | --- | --- | --- | --- | | jring (generic smart ring) | `56ff` | `SMART_RING` | $7–12 | -| Colmi / Yawell ring family | `6e40fff0` / `de5bf728` | `R02_…`, `R0x…`, `COLMI R1x…`, `H59_…` | $15–30 | -| TK5 ring — 🧪 **limited support** | `be940` (SmartHealth) | `TK5 …` (e.g. `TK5 24AA`) | ❓ | - -> 🧪 **TK5 support is experimental.** Its driver was reverse-engineered from a single packet -> capture: some readings are unverified and may be missing or wrong, skin temperature and stress -> aren't decoded, and the ring's encrypted login isn't implemented. The app labels it "Limited -> support" when you pair it. See the [TK5 page](https://saksham2001.github.io/PulseLoopiOS/hardware/tk5/). +| Colmi / Yawell ring family — **QRing app** | `6e40fff0` / `de5bf728` | `R02_…`, `R0x…`, `COLMI R1x…`, `H59_…` | $15–30 | +| Colmi / Yawell ring family — **SmartHealth app** — 🧪 **limited support** | `be940` (Yucheng YCBT) | same names as above | $15–30 | +| TK5 ring — 🧪 **limited support** | `be940` (Yucheng YCBT) | `TK5 …` (e.g. `TK5 24AA`) | ❓ | + +> 🧪 **The two YCBT rings (TK5, SmartHealth-app Colmi) are experimental.** Their driver is rebuilt from +> the decompiled SmartHealth vendor SDK, but a handful of value scales still need one confirmed reading +> on real hardware, and the SmartHealth-Colmi variant has never been connected at all. The app labels +> both "Limited support" when you pair them. +> +> ⚠️ **A Colmi ring ships with *either* the QRing or the SmartHealth app**, and its Bluetooth name +> doesn't say which — so PulseLoop asks you when you pair. Pick the wrong one and it just won't +> connect; the app then offers a one-tap "try the other app" retry. See the +> [Colmi page](https://saksham2001.github.io/PulseLoopiOS/hardware/colmi/#smarthealth-app-colmi-rings) +> and the [TK5 page](https://saksham2001.github.io/PulseLoopiOS/hardware/tk5/). > 📚 **Full hardware specs, per-model capability matrix, and buying guidance: > [Supported hardware docs](https://saksham2001.github.io/PulseLoopiOS/hardware/).** diff --git a/docs/YCBT-Protocol.md b/docs/YCBT-Protocol.md new file mode 100644 index 00000000..e9b3312c --- /dev/null +++ b/docs/YCBT-Protocol.md @@ -0,0 +1,594 @@ +--- +title: YCBT protocol (TK5 · SmartHealth-Colmi) +description: >- + Byte-level reference for the Yucheng YCBT ring protocol — framing, CRC, the + health-history transfer, every record layout, and the commands PulseLoop sends. + Shared by the TK5 and the SmartHealth-app Colmi rings. +--- + +# YCBT protocol (TK5 · SmartHealth-Colmi) + +Byte-level wire reference for the **Yucheng YCBT** protocol — the language spoken by every ring whose +vendor app is **SmartHealth** (`com.zhuoting.healthyucheng`): the [TK5](hardware/tk5.md), and the +[Colmi units that ship with SmartHealth](hardware/colmi.md#smarthealth-app-colmi-rings) rather than +QRing. + +This document is **device-neutral wherever the protocol is** — which is nearly everywhere. The frame +format, the history transfer, the record layouts and the command catalog are the same bytes on every +YCBT device; §0 is the whole of what a *family* gets to differ on. + +## 0. The two families that speak it + +PulseLoop drives two YCBT families, and the byte stream between them is **identical**. What differs is +only how a ring announces itself and which sensors it happens to carry: + +| | TK5 | SmartHealth-Colmi | +|---|---|---| +| **Advertised name** | `TK5 <4 hex>` — unambiguous, so it auto-detects | a Colmi-line name (`R09_ABCD`, `COLMI R10_…`) — **collides with QRing-Colmi**, so the app *asks* (see below) | +| **Manufacturer data** | prefix `10786501` (captured) | expected to contain the `1078` product code; ⚠️ **no capture yet** | +| **SupportFunction bitmap** (`02 01`, §2) | parsed and logged, but **not** used to gate: one SKU, on the bench | **gates the per-SKU sensors** — temperature, blood pressure, stress, blood sugar and on-demand BP are claimed only if this unit's bitmap sets their bit | +| **chipScheme** (`02 1b`) | JieLi (3–5), inferred from the `AE00` service | ❓ unknown; only selects the OTA stack, which PulseLoop does not implement (§9) | +| **Support level** | 🧪 Limited | 🧪 Limited — never connected | + +Nothing else. There is no per-family opcode, no per-family record layout, and no per-family framing. + +**In the source**, that maps 1:1: the whole protocol lives in device-neutral +`PulseLoop/RingProtocol/YCBT*.swift` (`YCBTProtocol` · `YCBTEncoder` · `YCBTDecoder` · `YCBTDriver` · +`YCBTHistoryTransfer` · `YCBTHealthRecords` · `YCBTSyncEngine`), and each family contributes exactly +one small **coordinator** — `TK5Coordinator.swift` and `ColmiSmartHealthCoordinator.swift` — carrying +its advertisement matcher and its capability set. A third YCBT family would be one more coordinator +and no protocol code at all. + +**Why the Colmi one has to ask the user.** The Colmi line is sold with *either* firmware, and the +local name is set by the OEM, not by the app — so a QRing-Colmi and a SmartHealth-Colmi can advertise +the identical `R09_ABCD`. No advertisement-only rule can separate them, so the pairing screen asks +which app came with the ring and the answer — not the scan — picks the driver. See +[Colmi / Yawell](hardware/colmi.md#smarthealth-app-colmi-rings). + +## Provenance + +| Source | What it settles | +|---|---| +| **Decompiled vendor SDK** — `com.yucheng.ycbtsdk` (v4.0.10), shipped inside the SmartHealth Android app | Opcodes (`CMD.java`), 16-bit dataTypes (`Constants.java`), record parsing (`DataUnpack.java`), framing / send queue / history assembly (`YCBTClientImpl.java`). **Ground truth.** | +| **SmartHealth app layer** — `com.zhuoting.healthyucheng` | Which commands the app actually sends and in what order, the measurement-mode table (one per measure screen), and how a raw field is *displayed* (which is how the temperature and blood-sugar scales were pinned down) | +| **Original Android btsnoop HCI capture** (one TK5 session) | Advertisement identity, GATT topology, and a sanity check that the SDK's bytes are what the ring really sees | + +The driver was originally reconstructed from that **single capture** alone. It is now built from the +SDK, with the capture used only for corroboration. Where a capture-derived assumption and the Java +disagreed, **the Java won** — every such case is called out below. + +Anything still marked ⚠️ **UNVERIFIED** could not be substantiated from either source and is waiting +on an on-device checkpoint. + +--- + +## 1. Transport + +### 1.1 GATT topology *(captured on the TK5; a SmartHealth-Colmi is expected to expose the same characteristics — ⚠️ unconfirmed)* + +| Role | UUID | +|---|---| +| Service | `be940000-7333-be46-b7ae-689e71722bd5` | +| Command | `be940001-…` — **write and indicate** (the app writes here *and* gets command replies here) | +| Stream | `be940003-…` — indicate: live vitals and all history data frames | + +Both characteristics carry the same frame format; the decoder dispatches on the frame's group byte, +not on which characteristic it arrived on. + +The ring also exposes the standard **`180D`/`2A37` Heart Rate** service. PulseLoop deliberately does +**not** subscribe to it: the TK5 emits a cached resting HR (~87 bpm) on it even when off the finger, +which would mask real readings. The vendor app ignores it too — live HR comes solely from the +proprietary `06 01` stream, which reflects actual finger contact. + +### 1.2 MTU and fragmentation + +The SDK requests an MTU of 500 and chunks at **`MTU − 3`** in both directions. A logical frame longer +than one ATT packet is therefore split across several notifications, and several short frames can +arrive in one notification. + +**Reassembly is driven by the declared length field, never by notification boundaries** +(`YCBTFrameAssembler`). Treating one notification as one frame drops every history data frame — this +was one half of why TK5 history never landed. + +### 1.3 Frame format + +``` +[TYPE:1] [CMD:1] [LEN:2 LE] [PAYLOAD:N] [CRC16:2 LE] +``` + +* **`LEN` is the *total* frame length**, including the 4-byte header and the 2-byte CRC — so + `LEN = N + 6`, not the payload length. +* **CRC = CRC-16/CCITT-FALSE**: poly `0x1021`, init `0xFFFF`, **no** input/output reflection, **no** + final XOR, computed over every byte before it, appended **little-endian**. + Check value: `"123456789"` → `0x29B1`. +* A command's 16-bit `dataType` in the SDK is exactly `(TYPE << 8) | CMD` — e.g. `0x0580` is the + Health terminal block. + +Worked example — request heart-rate history (group `05`, key `06`, empty payload): + +``` +05 06 06 00 83 20 +│ │ └──┴── LEN = 6 (0x0006 LE): 4 header + 0 payload + 2 CRC +│ └─────── CMD = 0x06 (Health query key: heart rate) +└────────── TYPE = 0x05 (Health group) + CRC-16/CCITT-FALSE over `05 06 06 00` = 0x2083 → little-endian `83 20` +``` + +The ring **does not validate the CRC of inbound command frames** (the SDK doesn't either), but it +*does* validate the inner CRC of a bulk history block — that one is load-bearing (§4). + +### 1.4 Timestamps + +`u32 LE`, **seconds since 2000-01-01**, in the ring's **local wall clock**. The ring has no timezone +concept: its RTC is set from local Y/M/D h:m:s fields and ticks in local time. + +Decoding must therefore un-apply the device's UTC offset to recover the true instant, or a later +`Calendar.current` read re-applies the same offset and doubles it. This is exact for same-session +syncs, and can be an hour off across a DST transition that happens between recording and syncing. +(`YCBTBytes.date` / `.ringSeconds`, in `YCBTProtocol.swift`.) + +### 1.5 Groups + +The `TYPE` byte is the command group. Direction is a property of the group, and getting it wrong is +how the original driver ended up sending device→app opcodes: + +| TYPE | Group | Direction | Purpose | +|---:|---|---|---| +| `0x01` | Setting | app → ring | clock, user profile, units, language, all-day monitors | +| `0x02` | Get | app → ring | device info, support bitmap, name, user config, chip scheme | +| `0x03` | AppControl | app → ring | live-measurement start/stop, live-status stream, find device | +| `0x04` | DevControl | **ring → app** | pushes: measurement progress/result, SOS, find-phone, sedentary | +| `0x05` | Health | both | history: queries, data frames, terminal block, ACK | +| `0x06` | Real | **ring → app** | realtime stream: HR, SpO₂, steps, vitals, battery, wear state | + +Groups 4 and 6 are **device-originated**. The only `04 xx` frame an app may write is the mandatory +push ACK (§6); it never writes a `06 xx` at all. + +--- + +## 2. Connect handshake (what PulseLoop sends) + +In the SmartHealth app's own order (`HomeFragment.getCompile` → `syncSettingData`), rebuilt from the +SDK's definitions rather than replayed from the capture — every payload is derived from the user's +real settings (`YCBTEncoder.startupSequence`). + +| # | Frame | Meaning | +|---:|---|---| +| 1 | `01 00` `[year:u16][mon][day][hh][mm][ss][weekday]` | Set clock. **Weekday is Mon = 0 … Sun = 6.** | +| 2 | `02 00` `47 43` | GetDeviceInfo → battery + firmware come back in the reply | +| 3 | `02 01` `47 46` | GetSupportFunction → capability bitmap. The reply is a bit-per-feature map (`ISHASTEMP`, `ISHASBLOOD` = blood *pressure*, `IS_HAS_PRESSURE` = *stress*, …) parsed by `YCBTSupportFunction`. It can only **add** capabilities a family has pre-approved, never remove one — which is how the SmartHealth-Colmi's per-SKU sensors are claimed (§0) while the TK5 keeps its static set | +| 4 | `02 1b` | GetChipScheme (JieLi vs Nordic vs Realtek — selects the OTA stack; see §8) | +| 5 | `02 03` `47 50` | GetDeviceName | +| 6 | `02 07` `43 46` | GetUserConfig | +| 7 | `01 12` `[languageCode]` | Language (0 = English) | +| 8 | `01 04` `[dist][weight][temp][timeFmt][sugar][uric]` | Units — 0 = metric each; `timeFmt` is **1 for 12-hour** | +| 9 | five `01 xx` `{enable, intervalMin}` | The all-day monitors (§3) | +| 10 | `01 03` `[heightCm][weightKg][sex][age]` | User profile — the ring feeds this into its step/calorie/BP algorithms | +| 11 | `03 09` `01 00 02` | Enable the ring's continuous `06 00` live-status push | + +The two-byte tags on the Get frames (`47 43`, `47 46`, …) are cosmetic — the firmware ignores a Get's +payload — but PulseLoop keeps the app's exact bytes so a byte-diff against a capture stays clean. + +The history queries (§4) follow, driven by the transfer state machine, not by this list. + +--- + +## 3. Setting group (`0x01`) — the all-day monitors + +**These five commands — not any Health-group opcode — are what make the ring record anything between +syncs.** Each takes `{enable, intervalMinutes}`. + +| Frame | Monitor | +|---|---| +| `01 0c` `{en, interval}` | Heart rate | +| `01 1c` `{en, interval}` | Blood pressure | +| `01 20` `{en, interval}` | Temperature | +| `01 26` `{en, interval}` | Blood oxygen | +| `01 45` `{en, interval, 0, 0, 0}` | HRV — ⚠️ the trailing three bytes are **UNVERIFIED**; the SDK names only the first two arguments, so they are zero-filled | + +**Interval is clamped to ≥ 30 minutes** — the ring's sampler rejects anything faster, and SmartHealth +clamps identically (`if (isRing() && interval < 30) interval = 30`). Vendor default is 60. + +Other Setting keys used: `01 00` set time, `01 03` user info, `01 04` units, `01 12` language. + +PulseLoop has no all-day *blood-pressure* toggle of its own (no other ring family has one), and the +ring derives BP from the same PPG sweep as heart rate, so `01 1c` rides the HR enable. + +--- + +## 4. Health group (`0x05`) — the history transfer + +The whole point of the protocol, and the part the original driver got wrong end to end. One transfer +per record type, strictly sequential: **the ring will not release the next type until the previous +one is acknowledged.** + +### 4.1 The sequence + +``` +app → 05 empty payload — "give me everything you have of this type" +ring → 05 [recordCount:u16][totalPackets:u32][totalBytes:u32] (header, payload ≥ 10 B) +ring → 05 ×N — payloads CONCATENATE into one buffer +ring → 05 80 [totalPackets:u16][totalBytes:u16][crc16:u16] (terminal, payload = 6 B) +app → 05 80 {00} MANDATORY ACK — 00 = CRC matched, 04 = mismatch +``` + +Four rules that are easy to get wrong: + +1. **The query key and the data key are different.** Heart rate is *queried* with `0x06` and its data + frames come back on `0x15`. The full table is in §4.3. +2. **The data payloads concatenate.** Records are packed back-to-back into one logical stream and + then chopped wherever a frame boundary happens to fall, so a record routinely **straddles two + frames**. Decoding per-frame silently drops every straddling record and misaligns everything + after it. `DataUnpack.unpackHealthData` is likewise handed one buffer, not one frame. +3. **The terminal CRC is over the reassembled buffer**, not over any frame — same CRC-16/CCITT-FALSE. +4. **The ACK is mandatory and comes first.** `YCBTClientImpl.packetHealthHandle` writes the ACK + *before* it parses a single record, so a slow decode can never stall the ring. PulseLoop does the + same. Without the ACK, the ring simply never answers the next query. + +A header payload of **≤ 9 bytes means "nothing stored for this type"** — there is no transfer, so +there is nothing to ACK; move on to the next type. + +### 4.2 Byte-level example — heart-rate history + +``` +app → 05 06 06 00 83 20 query heart rate +ring → 05 06 10 00 2c 00 06 00 00 00 08 01 00 00 de 7b + │ │ └── totalBytes = 0x108 = 264 (= 44 × 6-byte records) + │ └─────────────── totalPackets = 6 + └────────────────────── recordCount = 0x2c = 44 +ring → 05 15 …data… ×6, payloads concatenate → 264-byte buffer +ring → 05 80 0c 00 06 00 08 01 34 12 7c 49 terminal: 6 packets, 264 bytes, crc16 = 0x1234 +app → 05 80 07 00 00 f3 6a ACK, CRC matched (mismatch → …04 → 77 2a) +``` + +Then the next query goes out. On a CRC mismatch PulseLoop ACKs `{04}` and re-requests the type +**once**; a second failure drops the type rather than looping the ring forever. + +### 4.3 Record types + +Query key → ack key → record stride. All nine are requested on connect; a ring that doesn't +implement one answers with a no-data header or a `0xFC` error, both of which skip cleanly — so +querying the full catalog costs nothing. + +| Type | Query | Ack | Stride | PulseLoop decodes | +|---|---:|---:|---:|---| +| Sport (activity buckets) | `0x02` | `0x11` | 14 | steps, distance | +| Sleep | `0x04` | `0x13` | *variable* | deep / light / REM / awake timeline | +| Heart rate | `0x06` | `0x15` | 6 | HR | +| Blood pressure | `0x08` | `0x17` | 8 | systolic, diastolic, HR | +| **All** (combined vitals) | `0x09` | `0x18` | 20 | steps, BP, SpO₂, resp. rate, HRV, temp, blood sugar | +| SpO₂ | `0x1A` | `0x22` | 6 | SpO₂ | +| Temperature | `0x1E` | `0x26` | 7 | temperature | +| Comprehensive (metabolic) | `0x2F` | `0x30` | 44 | blood sugar | +| Body data | `0x33` | `0x34` | 28 | HRV, stress, fatigue, VO₂max | + +Queried in ascending key order. **Sport before All matters**: sport records are additive buckets that +*assign* a past day's step total, while the All record's step field is a cumulative counter that only +ratchets it up — asking in this order lets the counter have the last word. + +Types the SDK can request that PulseLoop deliberately does **not** (no PulseLoop metric maps onto +them): sport-mode workouts `0x2D`, fall `0x29`, health-monitoring `0x2B`, sedentary `0x37`, ambient +light `0x20`, temp+humidity `0x1C`, location `0x35`, power on/off `0x76`. + +### 4.4 Record layouts + +Offsets are within one record. Every record starts with a `u32 LE` 2000-epoch local timestamp. + +**Sport — `0x02`, 14 bytes** (`DataUnpack` case 2) + +| @ | Field | +|---:|---| +| 0 | `start:u32` | +| 4 | `end:u32` | +| 8 | `steps:u16` | +| 10 | `distanceMeters:u16` | +| 12 | `calories:u16` | + +Interval buckets (each covers start→end), not a running total. ⚠️ **UNVERIFIED**: whether they +partition the whole day or only cover workouts, and whether distance is really metres. + +**Sleep — `0x04`, variable** (`DataUnpack` case 4) + +Back-to-back **sessions**, each a 20-byte header followed by 8-byte stage segments: + +``` +header: [flags:2] [recordLen:u16 @2] [start:u32 @4] [end:u32 @8] [counts/totals @12…19] + recordLen = the whole session's byte count, INCLUDING this header +segment: [tag:1] [segStart:u32 LE @1] [durationSeconds:u24 LE @5] +``` + +* The duration is **u24**, not u16 — a u16 read truncates any segment longer than 18h12m. +* Stage is **`tag & 0x0F`**: 1 = deep, 2 = light, 3 = REM, 4 = awake, 5 = nap. The high nibble is a + flag mask, so exact-matching the tag byte (`0xF1…0xF4`) is wrong. +* An **unknown tag must be skipped, never terminal**. Breaking out of the segment loop on one lets a + single nap segment (`0xF5`) truncate the rest of the night. +* The "`af fa` magic" in the older notes was never a magic number: bytes [0] and [1] are unused flag + bytes and `recordLen` is at [2..3]. +* The SDK's segment loop has **no bounds check** against the buffer length (only against `recordLen`) + and would run off the end of a truncated transfer. PulseLoop clamps to the bytes it actually holds. + +**Heart rate — `0x06`, 6 bytes** (case 6) · `[ts:u32][mode@4][hr@5]` — `hr == 0` is an unworn sample. + +**Blood pressure — `0x08`, 8 bytes** (case 8) · `[ts:u32][isInflated@4][systolic@5][diastolic@6][hr@7]` + +**All / combined vitals — `0x09`, 20 bytes** (case 9) + +| @ | Field | | @ | Field | +|---:|---|---|---:|---| +| 0 | `ts:u32` | | 11 | `hrv` | +| 4 | `steps:u16` — **cumulative daily counter** | | 12 | `cvrr` (not decoded) | +| 6 | `heartRate` | | 13–14 | `tempInt`, `tempFrac` | +| 7 | `systolic` | | 15–16 | `bodyFatInt`, `bodyFatFrac` (no metric) | +| 8 | `diastolic` | | 17 | `bloodSugar` | +| 9 | `spo2` | | | | +| 10 | `respiratoryRate` | | | | + +HR at @6 is not emitted — the heart-rate history carries the same samples at the same epochs. + +**SpO₂ — `0x1A`, 6 bytes** (case 26) · `[ts:u32][type@4][value@5]` — `type` = auto-sampled vs. manual +spot reading; PulseLoop stores both the same way. + +**Temperature — `0x1E`, 7 bytes** (case 30) · `[ts:u32][type@4][int@5][frac@6]`. Note the SDK's own +bounds check demands only 5 bytes while the loop advances 7 — it would read out of bounds on a short +tail; PulseLoop's stride-7 slicer drops it. + +**Comprehensive — `0x2F`, 44 bytes** (case 47) · `[ts:u32][bloodSugarModel@4][int@5][frac@6]`, then +uric acid (@7–9), ketones (@10–12) and four lipid fractions. Only blood sugar has a PulseLoop metric. + +**Body data — `0x33`, 28 bytes** (case 51) + +| @ | Field | | @ | Field | +|---:|---|---|---:|---| +| 0 | `ts:u32` | | 14 | `sdnn:u16` | +| 4–5 | load index (int/frac) | | 16 | `vo2max` | +| 6–7 | **HRV** (int/frac) | | 17 | `pnn50` | +| 8–9 | **stress** — the SDK's `pressure` | | 18 | `rmssd:u16` | +| 10–11 | **fatigue** — the SDK's `body` | | 20 / 22 | `lf:u16` / `hf:u16` | +| 12–13 | sympathetic tone | | 24 | `lfHf` | + +This record is the proof that the ring **stores** stress and fatigue rather than the app deriving +them from HRV (`BodyData.getPressureValue()` / `getBodyStateValue()` are what the SmartHealth UI +labels "stress" and "fatigue"). + +### 4.5 Three scaling rules that are not what they look like + +**Int/fraction pairs are STRING-CONCATENATED, not `int + frac/10` or `/100`.** The SDK does +`Float.parseFloat(int + "." + frac)` (`DataUnpack` case 30, `BodyData.calculateCompositeValue`, and +SmartHealth's temperature screen). So the fraction's scale is implied by its *digit count*: +`frac = 5` → `.5`, `frac = 50` → `.5`, `frac = 25` → `.25`. Reproducing that exactly is the only way +to land on the number the vendor app shows for the same bytes. Applies to **temperature and HRV**. + +**Stress and fatigue are the exception: the app shows them ×10, on a 1…100 scale.** Both UI surfaces +do it — the history list reads `BodyData.getCompositePressure()` = `Integer.parseInt(int + "" + frac)` +and the live screen reads `(int)(Float.parseFloat(int + "." + frac) * 10)` +(`PressureMeasureActivity:66`) — and both then filter to `TransUtils.PRESSURE_VISIBLE_MIN/MAX` = 1…100. +The ring therefore scores 0–10 with one decimal: bytes `(5, 3)` are the **53** SmartHealth puts on +screen, not 5.3. HRV in the same record is *not* one of these — it is milliseconds (the All record +carries the same quantity as one whole byte, and the app's own HRV range is 1…180), so `(45, 6)` is +45.6 ms, not 456. ⚠️ **Fatigue is inferred**: SmartHealth never renders it, so its scale is taken from +stress, whose byte pair is adjacent and identically shaped. + +A temperature record with `int == 0` **or `frac == 15`** is the ring's **"never measured" filler**. +The captured All records carry `tempInt = 0, tempFrac = 15`, but SmartHealth's chart drops on the +fraction *independently of the integer* (`TemperatureActivity`: `int <= 42 && int >= 33 && frac != 15`) +— so a record left with a stale integer (`36, 15`) is a filler too, and would otherwise decode to a +36.15 °C reading that no plausibility range can catch. + +**Blood sugar is in TENTHS of mmol/L**, not whole mmol/L. SmartHealth stores the comprehensive +record's value as `integer * 10 + fraction` and the All record's *single byte* into the **same** DB +column, then filters that column to 11…333 — whose float twins are 1.1 and 33.3 mmol/L. So a raw 55 +is 5.5 mmol/L ≈ 99 mg/dL. PulseLoop persists mg/dL, so it converts `raw / 10 × 18.016`. +⚠️ **UNVERIFIED**: no captured record carried a non-zero value; the scale is inferred from the app's +DB column and chart filter, not from observed bytes. + +--- + +## 5. Realtime group (`0x06`) — ring → app stream + +Arrives unprompted on the stream characteristic. Never ACKed. + +| Frame | Payload | PulseLoop | +|---|---|---| +| `06 00` | `[steps:u16][distance:u16][calories:u16]` — cumulative day totals | steps verified against the capture; ⚠️ distance/calories offsets **UNVERIFIED** (capture-inferred) | +| `06 01` | `[bpm]` | live HR (verified) | +| `06 02` | `[spo2]` | live SpO₂, from the mode-`0x02` red/IR sweep | +| `06 03` | `[SBP@0][DBP@1][hr@2][hrv@3][spo2@4][tempInt@5][tempFrac@6]` | **one fixed layout** — the live feed for *both* the BP and the HRV sweep | +| `06 13` | `[ts:u32][worn]` | wear state — ⚠️ **UNVERIFIED polarity** (nonzero taken as worn); debug feed only | +| `06 15` | `[chargingStatus][percent]` | battery push — keeps battery fresh without polling `02 00` | + +`06 03` was previously decoded with a "BP-vs-HRV shape heuristic". There are no two shapes: the BP +screen and the HRV screen in SmartHealth both subscribe to this one dataType and each reads its own +fixed offsets. In BP mode the ring fills @0/@1 and zeroes @3; in HRV mode the reverse. Emitting each +field iff it carries a value decodes both cases *and* recovers the HR that the BP sweep also +measures, which the heuristic was throwing away. + +### 5.1 Starting a live measurement — `03 2f {enable, mode}` + +The **mode byte selects the sensor/LED**. One mode runs at a time. + +| Mode | Metric | Streams back on | +|---:|---|---| +| `0x00` | Heart rate (green LED) | `06 01` | +| `0x01` | Blood pressure | `06 03` | +| `0x02` | SpO₂ (red/IR LED) | `06 02` | +| `0x03` | Respiratory rate | — | +| `0x04` | Temperature | — | +| `0x05` | Blood sugar | — | +| `0x06` / `0x07` / `0x09` | Uric acid / ketone / blood fat | — | +| `0x0A` | HRV | `06 03` | +| `0x0C` | Stress | — | + +**The stop echoes its own mode — it is not `{0x00, 0x00}`.** Every SmartHealth measure screen passes +its own type in *both* directions (`BaseMeasureActivity.playStopMeasure` → `appStartMeasurement(type, …)`). +The capture's `03 2f 00 00` was simply the HR screen's stop with mode 0. Stopping an SpO₂ sweep with +mode 0 tells the ring to stop *heart rate* — which is exactly the bug this replaced. + +``` +03 2f 08 00 01 00 4f 1b start HR +03 2f 08 00 00 00 7e 28 stop HR +03 2f 08 00 01 02 0d 3b start SpO₂ (stop = …00 02) +``` + +### 5.2 Other AppControl commands PulseLoop sends + +``` +03 09 09 00 01 00 02 a0 de enable the continuous 06 00 live-status push (…00 00 02 disables) +03 00 09 00 01 05 02 b7 69 find device — make the ring buzz +``` + +⚠️ **UNVERIFIED**: the find-device arguments. `appFindDevice(i2, i3, i4)` sends its three arguments +verbatim and the SDK never names them; `{1, 5, 2}` is what SmartHealth's own "find ring" button +passes. First suspect if the ring doesn't buzz. + +--- + +## 6. DevControl group (`0x04`) — the push ACK contract + +Group 4 is **ring → app**. The ring **retransmits a push until the app acknowledges it**, so: + +> **Every non-error `04 ` frame must be answered with `04 {00}` — before it is decoded.** + +`YCBTClientImpl.packetDevControlHandle` sends `sendData2Device(dataType, {0})` *before* it parses the +payload, and PulseLoop does the same: a slow decode must never be able to stall the ring. + +``` +ring → 04 13 … measurement status push +app → 04 13 07 00 00 e1 9d ACK +``` + +| Key | Push | PulseLoop | +|---:|---|---| +| `0x00` | Find phone | ACK + log (no product surface) | +| `0x05` / `0x17` | SOS / SOS with GPS | ACK + log | +| `0x0E` | **MeasurementResult** — `[measureType][result]`; 1 = success, 2 = failed, else cancelled | ACK + log. **Carries no measured value** — SmartHealth reacts to a success by re-reading *history*, which is where the reading actually lands | +| `0x13` | **MeasurementStatus** (1043) — `[type][state]` then that type's value; `type` is the same mode byte from `03 2f` | ACK + decode the reading. ⚠️ the `state` byte has no enum anywhere in the SDK, so it is ignored | +| `0x16` | Sedentary reminder | ACK + log | + +Two deliberate divergences from the SDK: + +* **Error frames are never ACKed.** A 1-byte `0xFB…0xFF` payload is a rejection, not a push (§7). The + SDK returns early for groups 4 and 6 on one, before the push handler runs. ACKing it would answer a + rejection as though it were a push. +* **PulseLoop ACKs *every* non-error key**, where the SDK only ACKs the keys in its own table. An + unrecognised push still needs its retransmissions stopped — that is the entire point of the ACK — + and an ACK for a key the ring never pushed is inert. + +The historical `04 0e 00` "bond nudge" in the capture was never a command: it was SmartHealth +*auto-ACKing a MeasurementResult push*. Replaying it on connect did nothing. + +--- + +## 7. Error frames + +Any group, any command: a **1-byte response payload in `0xFB…0xFF`** is a status, not data +(`YCBTClientImpl.isError`). It must be checked *before* any offset is read, because a 1-byte error +payload is otherwise indistinguishable from a short header. + +| Byte | Meaning | Handling | +|---:|---|---| +| `0xFB` | Unsupported command (group not implemented) | history: skip the type **and stop asking for it this session** | +| `0xFC` | Unsupported key (cmd not implemented on this firmware) | same — this is how an absent sensor announces itself | +| `0xFD` | Length error | history: skip the type, advance | +| `0xFE` | Data error | skip, advance | +| `0xFF` | CRC error | skip, advance | + +--- + +## 8. Never send these + +| Bytes | What they actually are | +|---|---| +| **`05 40` … `05 4E`** | The Health **DELETE** opcodes. PulseLoop used to send `05 40/42/43/44/4E` believing they enabled monitoring. **They erase the ring's stored history.** The real enables are the five `01 xx` monitors (§3). | +| **`02 24` / `02 26` / `02 28`** | `GetCardInfo` / `GetSleepStatus` / `GetMeasurementFunction` — not history queries. The real history path is §4. | +| **Anything on the `AE00` service** | JieLi RCSP (§9). Not part of the health protocol. | +| Any `04 xx` other than the push ACK | Group 4 is device→app. The only legitimate app write is `04 {00}`. | +| Any `06 xx` | Group 6 is device→app, full stop. | + +--- + +## 9. The AE00 service — JieLi RCSP, deliberately not implemented + +The ring exposes a second service, `0000ae00` (write `ae01`, notify `ae02`), on which the capture +showed an encrypted `FE DC BA …` / `02 "pass"` handshake. It looked like a login gating the health +data. **It is not.** + +It is **JieLi RCSP** — the chipset vendor's own challenge-response auth, a subsystem entirely +separate from the Yucheng health protocol (`com/jieli/jl_rcsp/impl/RcspAuth.java`, +`YCBT…/p060jl/WatchManager.java`): + +* `FE DC BA` is the JieLi RCSP frame magic; `02 "pass"` is `RcspAuth.getAuthOkData()`. +* It is **gated on the chip scheme** (`02 1b` → 3/4/5 = JieLi) and it authorizes the **JieLi feature + set only: OTA / firmware update, watch-face (dial) upload, and log extraction.** +* The YC health commands on `be940001` are **plaintext, CRC-framed and carry no auth of any kind** — + they are an independent code path in the SDK. Bind/unbind at the YC layer + (`AppControl.BindDevice = 6`) are ordinary plaintext commands, not cryptographic. +* The key and algorithm are **native**: `getEncryptedAuthData()` is a `native` method inside + `libjl_rcsp.so`, with no Java implementation. It is not recoverable from the decompile. + +**PulseLoop implements none of it, and never will unless OTA is in scope.** Since PulseLoop does not +do firmware updates or watch faces, there is nothing behind that handshake it wants. + +The one residual risk, stated honestly: the SDK proves the two paths are *independent*, but it cannot +prove a given **firmware** doesn't refuse YC commands until RCSP auth completes. The capture showed +plaintext health traffic with no AE00 exchange, so the evidence points the right way — but a ring +that NAKs every `05 xx` until `02 "pass"` would be a documented hard stop. This is the top item on +the on-device checkpoint. + +--- + +## 10. Where PulseLoop deliberately diverges from SmartHealth + +| | SmartHealth | PulseLoop | +|---|---|---| +| **After a sync** | Deletes the records off the ring (`05 40…4E`) so the next sync is small | **Never deletes.** The ring replays its entire log on every sync. | +| **Deduplication** | Implicit — the ring forgets what was uploaded | **App-side upsert on `(kind, timestamp)`.** A history sample re-persists onto the same row; a re-sync is idempotent. | +| **Activity totals** | — | Sport buckets upsert by start epoch (day = sum of distinct buckets); the All record's cumulative counter is a per-day `max` ratchet. Both are idempotent under replay. | + +Not deleting is the safer trade: a delete that races a failed decode destroys data that exists +nowhere else, and it makes the vendor app and PulseLoop mutually destructive on the same ring. The +cost is a longer sync as the ring's log fills. + +**History horizon.** `RingEventBridge` drops any history sample, sleep session or activity timestamp +outside a **~8-day window** (`now − 8 days … now + 1 hour`). A ring's log can hold records stamped +under a *previous* clock — which decode hours or days out of place — and because history rows upsert, +one misdecoded record doesn't merely flicker: it re-persists on every future sync. Every decoded +metric is additionally **range-gated per kind** in the bridge (the record decoders only drop the +ring's "no sample" fillers; the plausible ranges live in exactly one place), so a misdecode is +dropped rather than stored as garbage. + +--- + +## 11. Open questions for the on-device checkpoint + +Everything marked ⚠️ above, in priority order: + +1. **AE00 gating** — do `05 xx` queries answer before any RCSP auth? (§9) +2. **Blood-sugar scale** — cross-check one non-zero reading against SmartHealth. (§4.5) +3. **Temperature scale** — confirm a real reading lands at ~36.x °C under the string-concat rule. +4. **Stress / fatigue magnitude** — read one `05 33` record and compare against SmartHealth's own + stress number. Stress is decoded on the app's 1…100 scale (`(5,3)` → 53); **fatigue's scale is + inferred from stress**, since the app never renders fatigue. HRV from the same record must stay in + milliseconds (`(45,6)` → 45.6). (§4.5) +5. **Find-device arguments** `{1, 5, 2}` — does the ring buzz? (§5.2) +6. **Per-mode stop** — does an SpO₂/HRV sweep actually end on `03 2f {00, mode}`? (LED still on ⇒ no.) +7. **HRV monitor tail bytes** — the three zero-filled bytes of `01 45`. (§3) +8. **Sport records** — whole-day buckets or workouts only? Distance in metres? +9. **Wear-state polarity** (`06 13`), the `sex` byte polarity in `01 03`, the `06 00` + distance/calories offsets, and the `04 13` `state` byte. + +Until these are settled the TK5's `supportLevel` stays **`.limited`** — the driver is now built from +the vendor SDK rather than one capture, but "reads correctly from the SDK" is not the same claim as +"verified against the hardware", and the badge should mean the latter. + +**The SmartHealth-Colmi carries all nine plus three of its own**, because no unit has ever been +connected: whether its advertisement really carries the `1078` marker (and whether it withholds the +QRing service UUIDs), what its `02 01` bitmap actually claims, and — the one that could be a hard stop +— whether *its* firmware answers `05 xx` before any AE00/RCSP auth. Its matching constants are +therefore a *hint* with a user-declared override behind them, not a decision: see +[Colmi / Yawell → SmartHealth-app Colmi rings](hardware/colmi.md#smarthealth-app-colmi-rings). + +--- + +See the [TK5 hardware page](hardware/tk5.md) and the +[SmartHealth-Colmi section](hardware/colmi.md#smarthealth-app-colmi-rings) for the devices themselves, +or the [hardware overview](hardware/index.md) for the cross-ring comparison. diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 4563c604..42c5c789 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -1,19 +1,28 @@ --- -title: Colmi / Yawell (QRing) +title: Colmi / Yawell description: >- - The $15–30 QRing ring family (R02/R0x/R1x/H59) — Nordic-UART protocol, skin - temperature, REM sleep, and continuous background sync. + The $15–30 Colmi/Yawell ring family (R02/R0x/R1x/H59) — sold with either the + QRing app (Nordic-UART protocol) or the SmartHealth app (Yucheng YCBT). --- -# Colmi / Yawell (QRing) +# Colmi / Yawell -**PulseLoop support: ✅ Supported** (R11 tested; rest implemented, needs testing 🧪) +**PulseLoop support: ✅ Supported on QRing** (R11 tested; rest implemented, needs testing 🧪) · +**🧪 Limited on SmartHealth** ([see below](#smarthealth-app-colmi-rings)) The QRing family — manufactured by Yawell and most commonly sold under the **Colmi** brand. These $15–30 rings speak a Nordic-UART–based protocol and bring sensors the cheaper [56ff / Jring](jring.md) lacks: skin temperature, REM sleep, HRV, stress, and continuous background sync. +!!! warning "The same ring is sold with two different firmwares" + A Colmi ring ships with **either the QRing app or the SmartHealth app**, and the two speak + *completely different BLE protocols*. Everything on this page down to + [Hackability](#hackability) describes the **QRing** firmware. If your ring came with + **SmartHealth**, jump to [SmartHealth-app Colmi rings](#smarthealth-app-colmi-rings) — it is a + different driver, a different capability set, and PulseLoop asks you which one you have when you + pair. + ## At a glance | | Detail | @@ -41,7 +50,7 @@ HRV, stress, and continuous background sync. - **Colmi** (Shenzhen Colmi Technology Co., Ltd.) is the most popular licensed brand selling Yawell's QRing rings - Website: [yawellfit.com](https://www.yawellfit.com/), [colmi.com](https://www.colmi.com/) -## Protocol +## Protocol (QRing firmware) | Property | Value | |---|---| @@ -50,6 +59,9 @@ HRV, stress, and continuous background sync. | **Frame size** | 16 bytes (checksum) | | **Encryption** | None | +A SmartHealth-flavoured unit speaks none of this — see +[SmartHealth-app Colmi rings](#smarthealth-app-colmi-rings). + ## Models — QRing platform | Model | CPU | Bluetooth | Battery | Waterproof | Display | Sensors | Notes | @@ -108,7 +120,7 @@ The VC30F is the PPG bio-sensor used in R10, R11, and R12: - Wear detection (wake on motion) - Raw acceleration data for sleep and activity algorithms -## Capabilities per model +## Capabilities per model (QRing firmware) | Capability | R10 | R12 | R11 | Other QRing¹ | |---|---|---|---|---| @@ -140,6 +152,127 @@ The VC30F is the PPG bio-sensor used in R10, R11, and R12: - Stress scoring - Continuous background sync (autonomous notifications while worn) +--- + +## SmartHealth-app Colmi rings + +**PulseLoop support: 🧪 Limited — implemented, never connected to a physical ring** + +Some Colmi rings ship with **SmartHealth** (`com.zhuoting.healthyucheng`) instead of QRing. Same +brand, same product numbers, often the same box art — but the firmware inside speaks the **Yucheng +YCBT** protocol on a `be940…` service, which has *nothing* in common at the wire level with QRing's +Nordic-UART frames. It is the identical protocol the [TK5](tk5.md) speaks, byte for byte, so PulseLoop +drives these rings with the same shared stack and only a per-family capability set on top. + +Byte-level spec: **[YCBT protocol](../YCBT-Protocol.md)** — and [§0](../YCBT-Protocol.md#0-the-two-families-that-speak-it) +in particular, which is the complete list of what differs between a TK5 and a SmartHealth-Colmi. + +### Which rings + +| Ring | Ships with SmartHealth? | +|---|---| +| **Colmi R09, R10** | ✅ Confirmed by the project owner (these are the units this support was written for) | +| Any other Colmi/Yawell model (R02, R03, R06, R07, R08, R11, R12, H59, Yawell R05/R10/R11) | ❔ Possible. The app a ring ships with is a seller/OEM choice, not a hardware one — the *same* model number is sold both ways. If yours came with SmartHealth, PulseLoop will try to drive it as a YCBT ring; it should work, and a report either way is genuinely useful | + +### How to tell which app your ring uses + +There is no way to tell from the ring itself, and **PulseLoop cannot reliably detect it either** — the +Bluetooth local name (`R09_ABCD`, `COLMI R10_1234`) is set by the OEM, not by the app, so a QRing ring +and a SmartHealth ring can advertise the *identical* name. So: check the box, the manual, the QR code +on the leaflet, or the listing that sold it to you. Whichever app it told you to install is the answer. + +If you genuinely don't know, just try one — a wrong pick is a 20-second dead end with a one-tap fix, +not a broken ring ([below](#if-you-pick-the-wrong-app)). + +### The pairing app-type picker + +Because the answer can't be detected, PulseLoop **asks**. Under every Colmi card in *Add your ring* +there is a segmented picker — *"Which app came with your ring?"* — with **QRing** and **SmartHealth**. + +- **QRing is the default**, because it is the mature, hardware-proven driver. +- The scan may *hint*: a ring whose advertisement looks like a SmartHealth unit defaults the picker to + SmartHealth. The hint is only a default — it is [provisional](#whats-still-provisional) and it may + never fire. +- **Your pick is authoritative.** It, not the scan, chooses the driver; and it changes what the card + shows you before you connect (capability chips and the *Limited support* badge both follow the + picker, because the two firmwares expose different metric sets). +- jring and TK5 cards show no picker: those rings ship with exactly one app, so they stay fully + auto-detected. + +### If you pick the wrong app + +Nothing breaks, and nothing wrong gets saved. The driver for the app you picked goes looking for +service UUIDs the ring doesn't have: the Bluetooth link opens, GATT discovery comes up empty, and the +connection never completes. + +PulseLoop times a user-initiated connect out after **20 seconds** and says so in the app's own terms — +*"This ring didn't answer as a SmartHealth ring. If it came with the QRing app, switch the app type +and try again."* — with a one-tap **"Try as QRing"** button that flips the picker and re-dials the same +ring. (And symmetrically in the other direction.) Only the pairing attempt times out; a background +reconnect to a ring you have already paired keeps waiting, as it should. + +### Capabilities + +The baseline is **what every YCBT ring does regardless of its sensors** — it is a protocol floor, not a +SKU description. Everything sensor-dependent is *gated on the ring's own capability bitmap*: the +handshake asks the ring what it has (`02 01` → `YCBTSupportFunction`), and PulseLoop claims those +extras only if the ring itself claims them. The bitmap can only **add** capabilities from a +pre-approved list — a garbled or truncated reply can never take one away. + +| Capability | QRing-Colmi | **SmartHealth-Colmi** | TK5 | +|---|:---:|:---:|:---:| +| Heart rate — history / live / spot | ✅ | 🧪 baseline | 🧪 | +| SpO₂ — history | ✅ | 🧪 baseline | 🧪 | +| SpO₂ — **spot** | ❌ (all-day only) | 🧪 baseline | 🧪 | +| Steps / distance / calories | ✅ | 🧪 baseline | 🧪 | +| Sleep, incl. REM | ✅ | 🧪 baseline | 🧪 | +| HRV — history | ✅ | 🧪 baseline | 🧪 | +| HRV — **spot** | ❌ | 🧪 baseline | 🧪 | +| Battery level | ✅ | 🧪 baseline | 🧪 | +| Find device | ✅ | 🧪 baseline | 🧪 | +| Measurement intervals | ✅ | 🧪 baseline | 🧪 | +| Skin temperature | ✅ | ❔ **bitmap** | 🧪 | +| Stress | ✅ | ❔ **bitmap** | 🧪 | +| Blood pressure — history | ❌ | ❔ **bitmap** | 🧪 | +| Blood pressure — spot | ❌ | ❔ **bitmap** | 🧪 | +| Blood sugar | ❌ | ❔ **bitmap** | 🧪 | +| Fatigue | ✅ | ❌ | 🧪 | +| Power off / factory reset | ✅ | ❌ | ❌ | +| Continuous background sync | ✅ | ❌ | ❌ | + +- **🧪 baseline** — claimed for every SmartHealth-Colmi, but no unit has confirmed any of it yet. +- **❔ bitmap** — claimed *only* if this particular ring's SupportFunction bitmap sets the bit. Two + Colmi rings on the identical protocol genuinely differ on whether they carry a temperature or + blood-pressure sensor, so this is a per-unit answer, not a per-family one. +- **Fatigue is deliberately not claimed**, unlike on the TK5. It rides the body-data record and *no + bit names it*, so it can be neither gated nor honestly promised on hardware nobody has connected — + and an unsupported claim here is user-visible, as a Vitals gauge stuck at "No fatigue score yet". The + first real sync decides; adding it back is a one-line change. +- **Power off / factory reset** are QRing-protocol commands with no YCBT equivalent in PulseLoop, so + the SmartHealth firmware simply doesn't offer them. +- **No background sync while disconnected** — like the TK5, the ring keeps logging on its own (that's + what the all-day monitors are for), but PulseLoop only reads it while connected: on connect, every + 30 minutes thereafter, and after a workout. + +### 🧪 What's still provisional + +**No SmartHealth-Colmi has ever been connected to PulseLoop.** The protocol is not the risk — it is +byte-identical to the TK5's and is exercised by the same unit tests — but everything *specific to this +family* is a prediction until a real ring answers: + +| Provisional | What it is | If it's wrong | +|---|---|---| +| **The advertisement match** | `ColmiSmartHealthCoordinator.Advertisement` — a Colmi-line local name **and** the `1078` product code somewhere in the manufacturer data **and** *no* QRing service UUID (`6e40fff0` / `de5bf728`). Taken from the vendor SDK's own scan filter (`BleHelper.filterDevice` accepts any advertisement whose hex *contains* `1078`), never from a capture of one of these rings | **Nothing stops working.** This is only the *hint* that pre-selects the picker. The user's pick chooses the driver, so a hint that never fires costs one toggle flip, not a connection. Both constants live in one place, to be refined the moment a capture exists | +| **The capability bitmap** | Which bits an R09/R10 actually sets — i.e. whether it really has temperature, BP, stress, blood sugar | A gated capability silently stays off (safe), or the ring claims one whose data never arrives (the card stays empty) | +| **`AE00` / JieLi RCSP gating** | The TK5 answers health commands in plaintext with no auth handshake, and the SDK proves the two code paths are independent — but it cannot prove a given *firmware* doesn't refuse `05 xx` until RCSP auth completes. See [TK5 → the AE00 service](tk5.md#the-ae00-service) | The one scenario that would be a **hard stop** for this family: the RCSP key is native and not recoverable. It is the first thing the first real sync checks | +| **GATT topology** | That a SmartHealth-Colmi exposes the same `be940000/01/03` service and characteristics as the TK5 | The driver finds nothing to subscribe and the connect times out — same failure, and the same recovery, as picking the wrong app | + +Support stays **Limited** until a physical R09/R10 pairs, syncs, and its bitmap is read. If you own +one, [Contributing](../project/contributing.md) explains how to send a report — a `nRF Connect` scan of +the advertisement alone is already useful. + +--- + ## Hackability ### 🏆 Full-Stack Hackable: Colmi R02 / R03 / R06 @@ -169,5 +302,6 @@ The manufacturer publishes firmware update images with no authenticity checks --- See the [hardware overview](index.md) for the full cross-manufacturer comparison -tables, or the [Jring / 56ff](jring.md) page for the cheaper option (the only one -with BP/blood sugar). +tables, the [Jring / 56ff](jring.md) page for the cheaper option, or — if your Colmi came with the +SmartHealth app — the [TK5](tk5.md) page and the [YCBT protocol](../YCBT-Protocol.md) reference, whose +driver it shares. diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 0c0f703a..7067cad6 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -36,12 +36,13 @@ section breaks the hardware down by manufacturer. [:octicons-arrow-right-24: Jring / 56ff](jring.md) -- :material-ring: __Colmi / Yawell (QRing)__ +- :material-ring: __Colmi / Yawell__ --- - The $15–30 QRing family — R02/R0x/R1x/H59. Nordic-UART protocol, skin - temperature, REM sleep, and continuous background sync. + The $15–30 R02/R0x/R1x/H59 family. Sold with **two different firmwares**: + QRing (Nordic-UART, ✅ supported) or SmartHealth (Yucheng **YCBT**, 🧪 limited). + PulseLoop asks which app your ring came with. [:octicons-arrow-right-24: Colmi / Yawell](colmi.md) @@ -49,8 +50,9 @@ section breaks the hardware down by manufacturer. --- - 🧪 Experimental. Custom `be940` protocol (SmartHealth app), reverse-engineered - from a single capture — some metrics unverified, no encrypted login yet. + 🧪 Limited. Yucheng **YCBT** protocol (SmartHealth app), rebuilt from the + vendor SDK — the broadest metric set here, awaiting on-device confirmation. + Shares its driver with the SmartHealth-app Colmi rings. [:octicons-arrow-right-24: TK5 / SmartHealth](tk5.md) @@ -80,17 +82,25 @@ section breaks the hardware down by manufacturer. - ✅ — yes / supported - ❌ — no / not supported - 🧪 — implemented, needs testing / experimental + - ❔ — depends on the individual ring (claimed only if its capability bitmap says so) - ❓ — unknown +!!! note "A Colmi ring's protocol depends on the app it shipped with" + The hardware below is the same either way — the *protocol* is not. A Colmi/Yawell ring sold with + the **SmartHealth** app speaks Yucheng **YCBT** (`be940`, the TK5's protocol), not QRing, and gets + a different driver and a different capability set. PulseLoop asks you which app your ring came + with when you pair it. See + [SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings). + | | 56ff / Jring | Colmi R02/R03/etc | Colmi R10 | Colmi R12 | Colmi R11 | TK5 | |---|---:|---:|---:|---:|---:|---:| -| **SoC** | Renesas DA14531 | Realtek RTL8762 | RTL8762 ESF | Realtek RTL8762 | Realtek AB2026 | ❓ | +| **SoC** | Renesas DA14531 | Realtek RTL8762 | RTL8762 ESF | Realtek RTL8762 | Realtek AB2026 | JieLi (part ❓)⁴ | | **Architecture** | ARM Cortex-M0 | ARM | ARM | ARM | ARM | ❓ | | **Bluetooth** | BLE 5.x | BLE 5.0 | BLE 5.0 | BLE 5.0 | BLE 5.0 | BLE | | **PPG sensor** | Unknown (HR/SpO₂) | Unknown | Vcare VC30F | Vcare VC30F | Vcare VC30F | ❓ | | **PPG LEDs** | Unknown | Unknown | Red + green (dual) | Red + green (dual) | Red + green (dual) | Green + red/IR | | **Accelerometer** | Yes | Unknown | STK8321 | ST LIS2DOC | STK8321 | Yes (❓ part) | -| **Skin temperature** | ❌ | ❓ | ✅ | ✅ | ✅ | ❌ | +| **Skin temperature** | ❌ | ❓ | ✅ | ✅ | ✅ | ✅ | | **Battery** | Unknown | Varies | 17 mAh | 15–18 mAh | 15–18 mAh¹ | ❓ | | **Battery life** | Unknown | Varies | ~4–7 days | ~4–7 days | ~4–7 days | ❓ | | **Charging case** | ❌ | ❌ | ✅ (200 mAh) | ❌ | ✅ (200 mAh) | ❓ | @@ -98,44 +108,54 @@ section breaks the hardware down by manufacturer. | **Waterproof** | Varies by seller | IP68 / 3ATM | 5ATM | IP68 + 1ATM | IP68 + 5ATM | ❓ | | **Weight** | Unknown | Unknown | Unknown | ~4 g | Unknown | ❓ | | **Price** | $7–12 | $15–25 | $15–25 | ~$30 | ~$15–25 | ❓ | -| **Protocol** | Custom 56ff | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing² | Custom be940 | +| **Protocol** | Custom 56ff | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing² | Yucheng YCBT (`be940`) | | **Frame size** | Fixed 20 bytes | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | Variable (CRC16) | | **Encryption** | None | None | None | None | None | None³ | -| **FW OTA** | ✅ Renesas SUOTA | ✅ BLE OTA (no sign) | ❓ | ❓ | ❓ | ❓ | +| **FW OTA** | ✅ Renesas SUOTA | ✅ BLE OTA (no sign) | ❓ | ❓ | ❓ | ❌³ | | **Custom firmware** | ✅ (SR08 ref) | ✅ (RF03 ref) | ❓ | ❓ | ❓ | ❓ | | **PulseLoop support** | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | ¹ 15 mAh for sizes 8–9, 18 mAh for sizes 10–13. ² Works with the QRing app; also has a companion "Da Rings" app. Matched by Colmi driver. -³ TK5 data channels are cleartext. It also exposes a separate encrypted `AE00` login, which PulseLoop does not implement. +³ The TK5's health protocol is cleartext with no auth. Its separate `AE00` service is **JieLi RCSP** — the chipset vendor's auth, which gates firmware updates and watch faces only. PulseLoop implements neither, so it doesn't implement the handshake. See [TK5](tk5.md#the-ae00-service). +⁴ JieLi chipset inferred from the `AE00` RCSP service; the exact part is unknown. ## Supported Rings — Capabilities -| Capability | 56ff / Jring | Colmi R02/etc | Colmi R10 | Colmi R12 | Colmi R11 | TK5 | -|---|---:|---:|---:|---:|---:|---:| -| Heart rate — spot | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Heart rate — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Heart rate — live | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| SpO₂ — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| SpO₂ — spot | ✅ | ❌¹ | ❌¹ | ❌¹ | ❌¹ | 🧪 | -| Steps / distance / calories | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Sleep (light/deep/awake) | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| REM sleep | ❌ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Blood pressure | ✅² | ❌ | ❌ | ❌ | ❌ | 🧪 | -| Blood sugar | ✅³ | ❌ | ❌ | ❌ | ❌ | ❌ | -| HRV | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Stress | ✅ | ✅ | ✅ | ✅ | ✅ | ❌⁴ | -| Fatigue | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Skin temperature | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Battery level | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Find device | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | -| Continuous background sync | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | -| FW update via app | ✅ | ✅ | ❓ | ❓ | ❓ | ❓ | +The Colmi columns are the **QRing** firmware. The same rings sold with the SmartHealth app get their +own column — same hardware, different protocol, different driver. + +| Capability | 56ff / Jring | Colmi R02/etc | Colmi R10 | Colmi R12 | Colmi R11 | Colmi (SmartHealth)⁶ | TK5 | +|---|---:|---:|---:|---:|---:|---:|---:| +| Heart rate — spot | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Heart rate — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Heart rate — live | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| SpO₂ — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| SpO₂ — spot | ✅ | ❌¹ | ❌¹ | ❌¹ | ❌¹ | 🧪 | 🧪 | +| Steps / distance / calories | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Sleep (light/deep/awake) | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| REM sleep | ❌ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Blood pressure | ✅² | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪 | +| Blood sugar | ✅³ | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪⁴ | +| HRV | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Stress | ✅ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪 | +| Fatigue | ✅ | ✅ | ✅ | ✅ | ✅ | ❌⁸ | 🧪 | +| Skin temperature | ❌ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪⁴ | +| Battery level | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Find device | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | +| Continuous background sync | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| FW update via app | ✅ | ✅ | ❓ | ❓ | ❓ | ❌⁵ | ❌⁵ | ¹ Colmi family has no on-demand SpO₂ reading; SpO₂ is all-day background only. ² Direct PPG sensor reading, no user profile required. ³ Profile-derived estimate from sex/age/height/weight, not a real glucometer reading. -⁴ The TK5 doesn't store stress — SmartHealth derives it from HRV app-side — so PulseLoop doesn't claim it. +⁴ Decoded from the vendor SDK, but the *value scale* is inferred rather than observed — one confirmed reading on hardware settles it. See [TK5: needs on-device confirmation](tk5.md#needs-on-device-confirmation). +⁵ PulseLoop implements no OTA for either YCBT ring. On the TK5 that path is JieLi RCSP auth on `AE00` (out of scope); on a SmartHealth-Colmi the chip scheme — and therefore which DFU stack it would even need — is unknown. +⁶ A Colmi/Yawell ring that shipped with the **SmartHealth** app rather than QRing — R09/R10 confirmed, other models possible. It speaks the TK5's YCBT protocol, so it runs the same driver. 🧪 throughout: **no unit has been connected yet**. See [SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings). +⁷ Sensor-dependent, so it is claimed **per ring** rather than per family: the handshake reads the ring's own capability bitmap and PulseLoop enables the metric only if the ring claims it. +⁸ Not claimed: no capability bit names fatigue, so it can be neither gated nor honestly promised on hardware nobody has connected. The first real sync decides. + +The TK5 stores stress and fatigue on the ring itself (the body-data record), which an earlier version of this page denied — it also reads respiratory rate and VO₂max, which no other supported ring exposes. Its whole column is 🧪 until the [on-device checkpoint](tk5.md#needs-on-device-confirmation) clears. ## Not Supported by PulseLoop @@ -158,7 +178,9 @@ Multiple hardware platforms span from $7 commodity rings to $350 premium devices |---|---|---|---|---|---| | **[56ff / Jring](jring.md)** | Renesas DA14531 | Custom 56ff (SXR KeepFit SDK) | Jring / KeepFit | $7–12 | ✅ App + FW | | **[Colmi / Yawell (QRing)](colmi.md)** | Realtek RTL8762 family | Nordic-UART (QRing) | QRing | $15–30 | ✅ App (R02 FW too) | +| **[Colmi / Yawell (SmartHealth)](colmi.md#smarthealth-app-colmi-rings)** | Realtek RTL8762 family | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | 🧪 App (limited — same protocol as the TK5) | | **[Colmi R11](colmi.md#colmi-r11-qring-compatible-with-fidget-shell)** | Realtek AB2026 | Nordic-UART QRing | QRing / Da Rings | ~$15–25 | ✅ App (untested) | +| **[TK5](tk5.md)** | JieLi (part ❓) | Yucheng YCBT (`be940`) | SmartHealth | ❓ | 🧪 App (limited) | | **[SIMSONLAB](simsonlab.md)** | Phyplus PHY6222 | Unknown | SIMSONLAB app | ~$10–20 | ❌ | ### Premium Rings diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 825651a6..1e6c7e1b 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -1,91 +1,179 @@ --- title: TK5 / SmartHealth description: >- - The TK5 ring (SmartHealth app) — experimental support on a custom be940 - protocol, reverse-engineered from a single capture. Several fields unverified. + The TK5 ring (SmartHealth app) — the Yucheng YCBT protocol, rebuilt from the + decompiled vendor SDK. Broad metric support; awaiting on-device confirmation. --- # TK5 / SmartHealth -**PulseLoop support: 🧪 Experimental — limited support, reverse-engineered from a single capture** +**PulseLoop support: 🧪 Limited — built from the vendor SDK, not yet confirmed on hardware** -The TK5 pairs with the **SmartHealth** app (`com.zhuoting.healthyucheng`) and speaks its own -`be940` BLE protocol — nothing in common at the wire level with the [56ff / Jring](jring.md) or -[Colmi / Yawell](colmi.md) families. PulseLoop's driver was reconstructed from **one** Android -btsnoop HCI capture, which is why it is the only ring here that isn't marked as supported. +The TK5 pairs with the **SmartHealth** app (`com.zhuoting.healthyucheng`) and speaks the **Yucheng +YCBT** protocol on a `be940` service — nothing in common at the wire level with the +[56ff / Jring](jring.md) or [Colmi / Yawell QRing](colmi.md) families. -!!! warning "Experimental — limited support" - This driver came from a single packet capture. Some byte offsets are **inferred rather than - confirmed**, skin temperature and stress aren't decoded at all, and the ring's encrypted `AE00` - login isn't implemented. Every decoded metric is range-gated before it's stored, so a misdecode - is dropped rather than saved as garbage — but expect gaps, and treat TK5 readings as approximate. +PulseLoop's driver is reconstructed from the **decompiled Yucheng YCBT SDK** (`com.yucheng.ycbtsdk`, +v4.0.10) that ships inside the SmartHealth Android app, corroborated by an Android btsnoop capture of +one TK5 session. The byte-level reference is [YCBT protocol](../YCBT-Protocol.md). - If you own one, a second capture (especially one containing temperature) would settle most of - the open questions. See [Contributing](../project/contributing.md). +!!! warning "Limited support — pending an on-device checkpoint" + Every layout below is read from the vendor SDK, so this is no longer guesswork from a single + capture. But *"decoded correctly from the SDK"* is not the same claim as *"verified against the + hardware"*, and the badge should mean the latter. A handful of scales and payloads (blood sugar, + temperature, find-device) still need one confirmed reading from a real ring — see + **[Needs on-device confirmation](#needs-on-device-confirmation)**. + + Every decoded metric is range-gated before it's stored, so a misdecode is dropped rather than + saved as garbage. Treat TK5 readings as approximate until the checkpoint clears. + +## Not the only ring that speaks it + +The TK5 is one of **two** ring families PulseLoop drives over YCBT. The other is +**[Colmi rings that ship with SmartHealth](colmi.md#smarthealth-app-colmi-rings)** instead of QRing +(R09 / R10 confirmed) — byte-identical protocol, so they run the *same* driver, encoder, decoder, +history transfer and sync engine (the device-neutral `YCBT*` core in `PulseLoop/RingProtocol/`). Each +family adds only a small coordinator with its advertisement matcher and its capability set, and they +differ in exactly three ways: + +| | TK5 | SmartHealth-Colmi | +|---|---|---| +| **Advertisement** | `TK5 <4 hex>` + manufacturer prefix `10786501` — unambiguous, so it auto-detects | a Colmi-line name a *QRing* Colmi can advertise too, so PulseLoop asks the user which app their ring came with | +| **SupportFunction bitmap** (`02 01`) | parsed and logged, but nothing is gated on it — one SKU, and it's the ring on the bench | **gates the per-SKU sensors**: temperature, BP, stress, blood sugar | +| **chipScheme** (`02 1b`) | JieLi | ❓ unknown — it selects the OTA stack only, which PulseLoop doesn't implement | + +The practical consequence for the TK5: a fix to any `YCBT*` file fixes both rings, and a regression in +one breaks both. See [YCBT protocol §0](../YCBT-Protocol.md#0-the-two-families-that-speak-it). ## At a glance | | Detail | |---|---| -| **SoC** | ❓ Unknown | +| **SoC** | JieLi (chip scheme 3/4/5 — inferred from the `AE00` RCSP service; exact part ❓) | | **Bluetooth** | BLE (version ❓) | | **PPG sensor** | ❓ Unknown — green LED for HR, red/IR for SpO₂ | | **Accelerometer** | Yes (steps decoded; part ❓) | | **Battery / life** | ❓ Unknown | | **Waterproof** | ❓ Unknown | | **Price** | ❓ Unknown | -| **Protocol** | Custom `be940`, variable-length frames, CRC16/CCITT-FALSE, cleartext | +| **Protocol** | Yucheng **YCBT** — variable-length frames, CRC16/CCITT-FALSE, cleartext | | **App** | SmartHealth (`com.zhuoting.healthyucheng`) | | **Advertised name** | `TK5 <4 hex>` — e.g. `TK5 24AA` | | **Custom firmware** | ❓ Unknown | ## Protocol +Full byte-level spec: **[YCBT protocol](../YCBT-Protocol.md)**. + | Property | Value | |---|---| | **Service** | `be940000-7333-be46-b7ae-689e71722bd5` | | **Command char** | `be940001` — write **and** indicate (the app writes here and gets replies here) | -| **Stream char** | `be940003` — indicate: live HR / SpO₂ / steps and downloaded history records | +| **Stream char** | `be940003` — indicate: live vitals and all history data frames | | **Frame** | `[type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE]`, `len` = total frame length | | **CRC** | CRC16/CCITT-FALSE (poly `0x1021`, init `0xFFFF`, no reflection), little-endian | | **Epoch** | Seconds since 2000-01-01, in the ring's **local wall-clock** (no timezone concept) | -| **Encryption** | Data channels are cleartext; a separate `AE00` login exists but is **not implemented** | +| **Fragmentation** | Frames split across notifications at `MTU−3`; reassembled by the declared length | +| **History** | `05 ` query → header → concatenated data frames → `05 80` terminal → **mandatory `05 80 {00}` ACK** | +| **Encryption** | None on the health protocol. The `AE00` service is JieLi RCSP — see [below](#the-ae00-service) | ## Capabilities -Everything PulseLoop reads from the TK5 is 🧪 — implemented from one capture, needs testing on -more hardware. - -| Capability | Status | Notes | -|---|:---:|---| -| Heart rate — spot | 🧪 | proprietary `06 01` stream; the standard `180D` char is ignored (see below) | -| Heart rate — live | 🧪 | | -| Heart rate — history | 🧪 | hourly, from `05 15` records | -| SpO₂ — spot | 🧪 | red/IR LED, `03 2f` mode `02` | -| SpO₂ — history | 🧪 | hourly, from `05 18` records | -| Steps / distance / calories | 🧪 | live push while connected; distance/calories offsets unverified | -| Sleep (light/deep/awake) | 🧪 | `05 13` timeline, matched to the app's own breakdown | -| REM sleep | 🧪 | | -| HRV | 🧪 | spot (`03 2f` mode `0a`) and hourly history | -| Blood pressure | 🧪 | periodic + live; no cuff calibration, so treat as a trend, not a number | -| Battery level | 🧪 | in-band; byte offset unverified, clamped to 0–100 | -| Find device | 🧪 | best-effort — no confirmed vibrate command in the capture | -| Stress | ❌¹ | | -| Skin temperature | ❌ | monitoring is enabled on connect, but no capture contains the data | -| Blood sugar | ❌ | | -| Continuous background sync | ❌ | live stream only while connected | -| FW update via app | ❓ | | - -¹ The ring doesn't store stress — SmartHealth derives it from HRV app-side — so PulseLoop doesn't -claim it rather than show an empty card. +Everything here is decoded from the vendor SDK and covered by unit tests against fixture bytes. The +right-hand column is the honest one: what a physical ring still has to confirm. + +| Capability | Status | Needs on-device confirmation | Notes | +|---|:---:|---|---| +| Heart rate — spot | 🧪 | — | `03 2f` mode `00` → `06 01` stream; the standard `180D` char is deliberately ignored (see below) | +| Heart rate — live | 🧪 | — | verified against the capture (climbed 82→86) | +| Heart rate — history | 🧪 | — | `05 06` query, 6-byte records | +| SpO₂ — spot | 🧪 | — | red/IR LED, `03 2f` mode `02` | +| SpO₂ — history | 🧪 | — | dedicated `05 1A` log **and** the All record | +| Steps / distance / calories | 🧪 | distance & calories byte offsets in the `06 00` live push | live push + `05 02` history buckets + the All record's cumulative counter | +| Sleep (light / deep / awake) | 🧪 | — | `05 04` timeline; stage = `tag & 0x0F` | +| REM sleep | 🧪 | — | stage tag `3` | +| HRV | 🧪 | the three tail bytes of the `01 45` monitor enable | spot (`03 2f` mode `0a`) + the All and body-data records | +| Blood pressure — spot | 🧪 | per-mode stop (`03 2f {00, 01}`) | `03 2f` mode `01`; no cuff calibration, so treat as a trend, not a number | +| Blood pressure — history | 🧪 | — | dedicated `05 08` log + the All record | +| Skin temperature | 🧪 | one real reading (the string-concat scale) | dedicated `05 1E` log + the All record; monitor enabled by `01 20` | +| Stress | 🧪 | — | **ring-stored**, from the body-data record (`05 33`) — the SDK's `pressure` field | +| Fatigue | 🧪 | — | body-data record — the SDK's `body` field | +| Blood sugar | 🧪 | **one real reading — the scale is inferred, not observed** | All record @17 and the comprehensive record (`05 2F`); tenths of mmol/L → mg/dL | +| Respiratory rate | 🧪 | — | All record @10 | +| VO₂max | 🧪 | raw byte taken unscaled | body-data record @16 | +| Battery level | 🧪 | — | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | +| Find device | 🧪 | **the command's three payload bytes** | `03 00 {01, 05, 02}` — the app's literal arguments; the SDK never names them | +| Measurement intervals | 🧪 | — | five `01 xx {enable, interval}` monitors; floored at the firmware's 30-min minimum | +| Periodic re-sync while connected | ✅ | — | every 30 min (SmartHealth's own cadence), plus a post-workout vitals pass | +| Continuous background sync | ❌ | — | the ring is only read while connected | +| FW update via app | ❌ | — | out of scope — would require the JieLi RCSP auth (see below) | + +## Needs on-device confirmation + +The open items, in priority order. Each is range-gated in code, so a wrong guess degrades to "no +reading", never to a wrong reading: + +1. **AE00 gating** — do the `05 xx` history queries answer before any RCSP auth? (Evidence says yes; + see [below](#the-ae00-service).) +2. **Blood-sugar scale** — the tenths-of-mmol/L reading is inferred from SmartHealth's database + column and chart filter, not from an observed non-zero record. Cross-check one reading. +3. **Temperature scale** — the int/fraction pair is *string-concatenated* (`5` → `.5`, `25` → `.25`), + not divided. Confirm a real reading lands at ~36.x °C. +4. **Find-device payload** — `{1, 5, 2}` replays the app's own button; the SDK never names the + arguments. First suspect if the ring doesn't buzz. +5. **Per-mode stop** — an SpO₂/HRV sweep is stopped with its *own* mode byte. If the LED stays on + afterwards, this is the line to look at. +6. **HRV monitor tail bytes** — `01 45 {enable, interval, 0, 0, 0}`; only the first two arguments are + named in the SDK, so the rest are zero-filled rather than guessed. + +## The AE00 service + +The ring exposes a second service, `AE00`, carrying an encrypted `FE DC BA …` / `02 "pass"` +handshake. It looks like a login gating the health data. **It is not.** + +It is **JieLi RCSP** — the chipset vendor's challenge-response auth, an entirely separate subsystem +from the Yucheng health protocol. It authorizes the **JieLi feature set only: OTA / firmware update, +watch-face upload, and log extraction.** The YC health commands on `be940001` are plaintext, +CRC-framed and carry no auth of any kind; they are an independent code path in the SDK. The AES key +lives in a native library (`libjl_rcsp.so`) and is not recoverable from the decompile. + +**PulseLoop deliberately implements none of it** — it does no firmware updates and no watch faces, so +there is nothing behind that handshake it wants. The one residual risk: the SDK proves the two paths +are independent, but cannot prove a given *firmware* doesn't refuse YC commands until RCSP auth +completes. The capture showed plaintext health traffic with no AE00 exchange, so the evidence points +the right way — but if a unit NAKs every `05 xx` until `02 "pass"`, that is a hard stop, and it is +item 1 on the checkpoint. + +## How PulseLoop diverges from SmartHealth + +**We never delete records off the ring.** SmartHealth issues the Health-Delete opcodes (`05 40…4E`) +after a sync, so its next sync is small. PulseLoop doesn't: a delete that races a failed decode +destroys data that exists nowhere else, and it makes the vendor app and PulseLoop mutually +destructive on the same ring. + +The consequence is that **the ring replays its entire log on every sync**, so deduplication is +app-side: every history sample upserts on `(kind, timestamp)`, activity buckets upsert by start +epoch, and the cumulative step counter is a per-day `max` ratchet. All three are idempotent under +replay, so a double-sync produces no duplicates. The cost is a longer sync as the ring's log fills. + +(An earlier version of the driver sent `05 40/42/43/44/4E` *believing they enabled monitoring*. They +are the delete opcodes. The real enables are the five `01 xx {enable, interval}` monitor commands.) ## Known limitations -- **Single-capture provenance.** Offsets tagged `UNVERIFIED` in the driver source are inferred from - one session. They're range-gated, but they haven't been confirmed against a second device. -- **No encrypted login.** The ring exposes a separate `AE00` service with a `fedcba`/`"pass"` - handshake that PulseLoop doesn't implement. Live data and history flowed in plaintext without it - in the capture; if a unit turns out to gate them behind that login, this is why nothing arrives. +- **Not yet confirmed on hardware.** The layouts come from the vendor SDK rather than guesswork, but + the items in [Needs on-device confirmation](#needs-on-device-confirmation) are still open, which is + why support stays "Limited". See [Contributing](../project/contributing.md) if you own one. +- **~8-day history horizon.** `RingEventBridge` drops any history sample, sleep session or activity + timestamp outside `now − 8 days … now + 1 hour`. A ring's log can hold records stamped under a + *previous* clock, which decode hours or days out of place — and because history rows upsert, one + misdecoded record wouldn't merely flicker, it would re-persist on every future sync. Records older + than the window are therefore not imported. +- **No background sync while disconnected.** The ring keeps logging on its own schedule (that's what + the `01 xx` monitors are for), but PulseLoop only reads it while connected: on connect, every 30 + minutes thereafter, and after a workout. +- **No firmware updates or watch faces.** Both live behind the JieLi RCSP auth (see above) and are + out of scope. - **Standard heart-rate service is deliberately ignored.** The TK5 exposes `180D`/`2A37`, but it emits a cached resting HR (~87 bpm) even off-finger, which would mask real readings. Live HR comes solely from the proprietary `06 01` stream, as in the official app. @@ -95,4 +183,6 @@ claim it rather than show an empty card. --- -See the [hardware overview](index.md) for the cross-manufacturer comparison. +See the [YCBT protocol reference](../YCBT-Protocol.md) for the byte-level spec, the +[SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings) that share this driver, or the +[hardware overview](index.md) for the cross-manufacturer comparison. diff --git a/mkdocs.yml b/mkdocs.yml index 8423c19e..ffaa183f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,8 +109,9 @@ nav: - Hardware: - Overview: hardware/index.md - Jring / 56ff: hardware/jring.md - - Colmi / Yawell: hardware/colmi.md + - Colmi / Yawell (QRing + SmartHealth): hardware/colmi.md - TK5 / SmartHealth: hardware/tk5.md + - YCBT protocol (TK5 · SmartHealth-Colmi): YCBT-Protocol.md - SIMSONLAB: hardware/simsonlab.md - Premium Rings: hardware/premium.md - Platforms: From 462183e530bbd10ae35aff9c61517db39fb0f3b3 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 15:15:07 -0400 Subject: [PATCH 2/6] Recognize SmartHealth-app rings by their space-separated name convention and add the Colmi R99 --- .../ColmiSmartHealthCoordinator.swift | 116 +++++++---- PulseLoop/Wearables/WearableModel.swift | 56 ++++-- PulseLoopTests/PairingMatchingTests.swift | 181 +++++++++++++----- 3 files changed, 258 insertions(+), 95 deletions(-) diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 2b2b6d06..dd608fcc 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -1,7 +1,7 @@ import Foundation @preconcurrency import CoreBluetooth -/// Coordinator for Colmi rings that ship with the **SmartHealth** app (the R09/R10 the owner has). +/// Coordinator for Colmi rings that ship with the **SmartHealth** app (the owner's `R99 54DC`). /// /// Same product line as `ColmiCoordinator`, a completely different firmware: these speak YCBT — the /// byte-identical protocol the TK5 speaks — so the entire stack they build (`YCBTDriver` → encoder, @@ -10,66 +10,112 @@ import Foundation /// **QRing** keep the GadgetBridge-derived `ColmiDriver`. /// /// The two are told apart at *pairing*, not on the wire — see `RingAppVariant`. +/// +/// ## What the owner's ring taught us (the `R99 54DC` nRF Connect capture) +/// +/// The first real SmartHealth-Colmi we have looked at. Its GATT is the TK5's, exactly: the YCBT +/// service `BE940000-…` with `BE940001/2/3`, plus JieLi RCSP (`AE00`, unimplemented on purpose), +/// Nordic UART, `FEE7` and standard Heart Rate. **Neither QRing service** (`6e40fff0…`, `de5bf728…`) +/// is present — so it is definitively not a ring `ColmiDriver` could talk to — and, like the TK5, it +/// does **not** advertise `be940000`, which is why recognition here has to be name-first. +/// +/// It also settled the question the file was built around. Both YCBT rings we have now seen name +/// themselves `<4 hex>` — `TK5 24AA`, `R99 54DC` — while every QRing-Colmi in the +/// catalog uses an underscore (`R02_A1B2`, `COLMI R10_9C3F`, `R11C_BEEF`). Space-versus-underscore is +/// a signal from real hardware in both families; the `1078` manufacturer marker is not (see below). @MainActor final class ColmiSmartHealthCoordinator: WearableCoordinator { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) static let deviceType: RingDeviceType = .colmiSmartHealth - // MARK: - Advertisement constants — PROVISIONAL + // MARK: - Advertisement constants - /// ⚠️ **None of this has been checked against a real SmartHealth-Colmi advertisement.** We have no - /// capture yet (plan B0: the owner takes one with nRF Connect). These constants are a *hint* that - /// sets the default position of the pairing screen's app-type picker, and nothing more. + /// **Confirmed** by the `R99 54DC` capture: the GATT layout (identical to the TK5's), the absence of + /// the QRing services, and the ` <4 hex>` naming convention. /// - /// The design deliberately does not depend on them being right. The user's explicit pick is what + /// **Still unknown**: the raw advertisement payload. The capture logged GATT discovery only — no + /// manufacturer-data hex, no advertised service-UUID list — so `manufacturerHexMarker` remains a + /// prediction carried over from the TK5, and that is exactly why it no longer decides anything on + /// its own. What would confirm it: an nRF Connect *scanner* capture (the raw scan record, not the + /// connected device's service list) of any SmartHealth-Colmi. + /// + /// The design still does not depend on any of this being right. The user's explicit pick is what /// selects the driver (`RingBLEClient.coordinatorType(preferredFamily:autoMatched:)`), so a /// heuristic that never fires costs a toggle the user has to flip — not a working connection — and - /// one that fires wrongly costs the same. That asymmetry is why this is a hint and not a decision: - /// a QRing-Colmi and a SmartHealth-Colmi can advertise the *identical* local name, so no - /// advertisement-only rule can ever be trusted to separate them. - /// - /// Refine exactly these two constants (and nothing else) once the capture exists. + /// one that fires wrongly costs the same. Matching is a hint that sets the picker's default. enum Advertisement { - /// The SmartHealth product code, matched as a **prefix of the manufacturer data** — i.e. in the - /// company-ID slot, which is where the only capture we have in this family puts it (the TK5's - /// `10786501…`; its trailing bytes are that model's own, so we match less than `TK5Coordinator` - /// does but at the same anchor). + /// The SmartHealth naming convention: model, one space, four hex digits. Both confirmed rings in + /// this family advertise this way (`TK5 24AA`, `R99 54DC`) and no QRing-Colmi does — every + /// Colmi-line pattern in the catalog is underscore-separated. That makes the *name*, not the + /// manufacturer data, the primary signal. /// - /// `BleHelper.filterDevice` merely *contains*-tests `1078` (and its siblings 1178/1278/1378/C5FE) - /// against the whole raw scan record — but it has no choice: it never parses out the AD - /// structures. We do (CoreBluetooth hands us the manufacturer data already isolated), and an - /// unanchored substring test over hex is not even byte-aligned: manufacturer bytes `a1 07 8f` - /// stringify to `"a1078f"` and would match. Colmi's manufacturer payload commonly embeds the MAC, - /// so an unlucky QRing-Colmi would be tagged as this family, defaulting the picker to the wrong - /// app for a ring we already support. Anchoring costs nothing and cannot do that. + /// Anchored end to end so the hex suffix is the whole tail: `Beats FADE` would satisfy it, but + /// only a name no catalog card claims ever reaches this test (see `matches`), and the four-hex + /// tail plus a bare model token is a narrow enough shape that the cost of a false positive — one + /// stray row in the pairing list, tagged with a family the user can override — stays trivial. + static let namePattern = "^[A-Za-z0-9]+( [A-Za-z0-9]+)* [0-9A-Fa-f]{4}$" + + /// The Yucheng SDK's company ID (0x7810, little-endian ⇒ `1078`), matched as a **prefix of the + /// manufacturer data** — the company-ID slot, which is where the one capture in this family that + /// does include a scan record (the TK5's `10786501…`) puts it. + /// + /// **Demoted to corroborating evidence.** It used to be a hard requirement, and the R99 is why it + /// can't be: we have no advertisement for it at all, so requiring the marker would reject the one + /// ring in this family we actually own. Its absence therefore no longer vetoes a name match. It + /// still earns its keep in the other direction — a ring *nothing* names, carrying the Yucheng + /// company ID, is a YCBT ring, and this family (whose sensors are bitmap-gated rather than + /// assumed) is the conservative home for one. /// - /// If the B0 capture shows a SmartHealth-Colmi carrying the code somewhere *other* than the first - /// two bytes, the fix is here and is one line: match on a byte-aligned index instead of a prefix. + /// It is deliberately **not** allowed to override a name. A QRing-Colmi may well carry the same + /// company ID — every ring in this SDK family does, the TK5 included — so letting `1078` promote + /// an underscore-named Colmi would mis-tag a ring we already support fully, and default its + /// picker (and first connect) to a protocol its firmware does not speak. The name is the + /// evidence; the marker only fills the silence. + /// + /// Matched as a prefix, never as a substring: an unanchored test over hex is not even + /// byte-aligned (manufacturer bytes `a1 07 8f` stringify to `"a1078f"`), and Colmi's manufacturer + /// payload commonly embeds the MAC. static let manufacturerHexMarker = "1078" /// The QRing-flavoured Colmi rings advertise one of these. Presence is a positive disqualifier: - /// this ring answers to the *other* driver, so the conjunction below rejects it outright. + /// this ring answers to the *other* driver, so `matches` rejects it outright. The R99 advertises + /// neither — its GATT has no QRing service at all. static let qringServiceUUIDs: [CBUUID] = [ CBUUID(string: ColmiUUIDs.serviceV1), CBUUID(string: ColmiUUIDs.serviceV2), ] + + /// Does this local name follow the SmartHealth convention? + static func isSmartHealthName(_ name: String?) -> Bool { + guard let name, let regex = try? NSRegularExpression(pattern: namePattern) else { return false } + let range = NSRange(name.startIndex.. Bool { - guard let model = WearableModel.model(advertisedName: name), - model.variant(for: deviceType) != nil else { return false } + guard !advertisement.serviceUUIDs.contains(where: { Advertisement.qringServiceUUIDs.contains($0) }) + else { return false } + if let model = WearableModel.model(advertisedName: name) { + return model.families.contains(deviceType) && Advertisement.isSmartHealthName(name) + } guard let manufacturer = advertisement.manufacturerData, manufacturer.hexString.hasPrefix(Advertisement.manufacturerHexMarker) else { return false } - return !advertisement.serviceUUIDs.contains { Advertisement.qringServiceUUIDs.contains($0) } + return !TK5Coordinator.matches(name: name, advertisement: advertisement) } // MARK: - Capabilities diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 54c7d0ef..9204917c 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -138,6 +138,30 @@ extension WearableModel { ) static let colmiR12 = colmiFamily("colmi-r12", "Colmi R12", brand: "Colmi", pattern: "^COLMI R12_.*") + /// The R99 — the one SmartHealth-Colmi we have actually looked at (the owner's, captured with nRF + /// Connect as `R99 54DC`). It stays under the **Colmi** brand tab because that is where its owner + /// will look for it: it is a Colmi-line ring, it wears the Colmi ring art, and a brand tab of one + /// would only hide it. What it does *not* share with the other Colmi cards is its default family — + /// the capture shows the TK5's YCBT GATT and no QRing service at all, so `.colmiSmartHealth` is not + /// a guess here, it is the observed firmware. + /// + /// It keeps the app-variant picker anyway. The card would otherwise promise recovery it can't give: + /// `RingConnectFailure.message` tells a user whose connect stalls to "switch the app type and try + /// again", and `PairingView.variantRetry` needs an `otherVariant` to offer. One confirmed unit is + /// not a guarantee that no R99 ever shipped with QRing — the whole reason `RingAppVariant` exists — + /// so the picker stays and simply defaults the other way (the default is `family`, not the first + /// option; see `variant(picked:rowFamily:hinted:)`). + /// + /// No `imageName`: there is no R99 imageset, and a non-nil name that isn't registered renders an + /// *empty* platter rather than falling back. nil is the supported path — `RingArtView.fallbackImage` + /// is a generic Colmi ring, which is exactly what this is. + static let colmiR99 = WearableModel( + id: "colmi-r99", displayName: "Colmi R99", brand: "Colmi", family: .colmiSmartHealth, + tint: PulseColors.hrv, blurb: smartHealthBlurb, + advertisedNamePatterns: ["^R99 [0-9A-Fa-f]{4}$"], + appVariants: colmiAppVariants + ) + // TK5 — the YCBT protocol (be940 service, SmartHealth app), shared with the SmartHealth-flavoured // Colmi rings. Advertises as "TK5 <4 hex>", which is unambiguous, so it needs no app-variant picker. // Blurb mirrors `TK5Coordinator.capabilities`; the driver is `.limited` (see `supportLevel`). @@ -153,23 +177,22 @@ extension WearableModel { static let yawellR11 = colmiFamily("yawell-r11", "Yawell R11", brand: "Yawell", pattern: "^R11_[0-9A-F]{4}$") static let h59 = colmiFamily("h59", "H59 Ring", brand: "H59", pattern: "^H59_.*") - /// Every Colmi-line ring is sold with one of two apps, and its advertised name doesn't say which — - /// so every Colmi card offers both. QRing is listed first: it is the mature, hardware-proven driver - /// and therefore the default when nothing in the scan hints otherwise. - /// /// The blurbs differ because the firmwares do. The SmartHealth one lists only what *every* YCBT ring /// certainly does (`ColmiSmartHealthCoordinator.capabilities`); its per-SKU sensors — temperature, /// blood pressure, stress — are claimed at connect time from the ring's own capability bitmap, so /// promising them on the card would be a promise we can't keep for every unit. + private static let qringBlurb = "HR · SpO₂ · HRV · Stress · Temp · Sleep" + private static let smartHealthBlurb = "HR · SpO₂ · HRV · Sleep · Steps" + + /// Every Colmi-line ring is sold with one of two apps, so every Colmi card offers both — including + /// the R99, whose firmware we *have* confirmed (a confirmed unit is not a confirmed SKU). + /// + /// The order here is the picker's segment order, nothing more: which option a card *starts* on is + /// its own `family` (`variant(picked:rowFamily:hinted:)`), so one shared list serves both the + /// QRing-by-default cards and the SmartHealth-by-default R99. private static let colmiAppVariants: [AppVariantOption] = [ - AppVariantOption( - variant: .qring, family: .colmiR02, - blurb: "HR · SpO₂ · HRV · Stress · Temp · Sleep" - ), - AppVariantOption( - variant: .smartHealth, family: .colmiSmartHealth, - blurb: "HR · SpO₂ · HRV · Sleep · Steps" - ), + AppVariantOption(variant: .qring, family: .colmiR02, blurb: qringBlurb), + AppVariantOption(variant: .smartHealth, family: .colmiSmartHealth, blurb: smartHealthBlurb), ] private static func colmiFamily( @@ -182,7 +205,7 @@ extension WearableModel { // Asset-catalog image name matches the model id (see PulseLoop/Assets.xcassets). WearableModel( id: id, displayName: name, brand: brand, family: .colmiR02, - tint: PulseColors.hrv, blurb: "HR · SpO₂ · HRV · Stress · Temp · Sleep", + tint: PulseColors.hrv, blurb: qringBlurb, advertisedNamePatterns: [pattern], imageName: imageName ?? id, appVariants: colmiAppVariants ) @@ -227,6 +250,10 @@ extension WearableModel { /// though the scan had already identified both correctly. A row that the scan claimed knows better /// than a hint borrowed from its neighbour; only a deliberate pick beats it. /// + /// The last fallback is the card's **own default family**, not the first option in the picker: the + /// R99 lists the same two apps as every other Colmi card but starts on SmartHealth, because that is + /// the firmware its capture shows. + /// /// nil for a single-firmware card (no picker, no override — auto-detection exactly as before). func variant( picked: RingAppVariant?, @@ -234,7 +261,7 @@ extension WearableModel { hinted: RingAppVariant? ) -> RingAppVariant? { guard !appVariants.isEmpty else { return nil } - return picked ?? rowFamily.flatMap(variant(for:)) ?? hinted ?? appVariants.first?.variant + return picked ?? rowFamily.flatMap(variant(for:)) ?? hinted ?? variant(for: family) } /// The family to *force* on a connect to one discovered row, or nil to leave the scan's auto-match @@ -269,6 +296,7 @@ extension WearableModel { static let catalog: [WearableModel] = [ jring, colmiR02, colmiR03, colmiR06, colmiR07, colmiR08, colmiR09, colmiR10, colmiR11, colmiR12, + colmiR99, yawellR05, yawellR10, yawellR11, h59, tk5, ] diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index c1371341..78aabf20 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -7,10 +7,13 @@ import UIKit /// share `ColmiDriver`), without the jring coordinator wrongly claiming them. /// /// Since Phase B it must also do something strictly harder: separate the *two* Colmi families, which -/// speak entirely different protocols and can advertise the identical local name. That is why the -/// advertisement is only a hint here and the user's app-type pick is the authority — the cross-claim -/// matrix below pins the hint's behavior, and `testPreferredFamilyOverridesAutoMatch` pins the override -/// that makes a wrong hint harmless. +/// speak entirely different protocols. That is why the advertisement is only a hint here and the user's +/// app-type pick is the authority — the cross-claim matrix below pins the hint's behavior, and +/// `testPreferredFamilyOverridesAutoMatch` pins the override that makes a wrong hint harmless. +/// +/// The hint got a lot better once real hardware turned up: the owner's `R99 54DC` and the TK5 both name +/// themselves ` <4 hex>`, while every QRing-Colmi uses an underscore. The tests below hold that +/// split in place from both sides — the R99 must land on the YCBT stack, and no QRing-Colmi may. @MainActor final class PairingMatchingTests: XCTestCase { private let noAdv = AdvertisementInfo(serviceUUIDs: [], manufacturerData: nil) @@ -26,8 +29,9 @@ final class PairingMatchingTests: XCTestCase { return Data(out) } - /// A SmartHealth-family advertisement: the `1078` product code in manufacturer data, no QRing - /// service. PROVISIONAL — no real SmartHealth-Colmi capture exists yet (plan B0). + /// A SmartHealth-family advertisement: the `1078` Yucheng company ID in manufacturer data, no QRing + /// service. Still PROVISIONAL — the `R99 54DC` capture logged GATT only, never a scan record — which + /// is why the marker now only corroborates and the *name* decides. private var smartHealthAdv: AdvertisementInfo { AdvertisementInfo(serviceUUIDs: [], manufacturerData: bytes("10780a01aabbccdd")) } @@ -59,11 +63,24 @@ final class PairingMatchingTests: XCTestCase { } func testNonColmiNamesDoNotMatch() { - for name in ["SMART_RING", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random"] { + // `R99 54DC` is a Colmi *ring*, but not a QRing one: its card's family is `.colmiSmartHealth`, so + // the GadgetBridge-derived coordinator must leave it alone. + for name in ["SMART_RING", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random", "R99 54DC"] { XCTAssertFalse(colmiMatches(name), "did not expect Colmi match for \(name)") } } + /// The owner's ring, claimed by exactly one coordinator — and not by the three it could plausibly be + /// confused with. + func testOnlyTheSmartHealthCoordinatorClaimsTheR99() { + for advertisement in [noAdv, smartHealthAdv] { + XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "R99 54DC", advertisement: advertisement)) + XCTAssertFalse(ColmiCoordinator.matches(name: "R99 54DC", advertisement: advertisement)) + XCTAssertFalse(TK5Coordinator.matches(name: "R99 54DC", advertisement: advertisement)) + XCTAssertFalse(JringCoordinator.matches(name: "R99 54DC", advertisement: advertisement)) + } + } + func testColmiMatchesByServiceUUID() { let adv = AdvertisementInfo(serviceUUIDs: [CBUUID(string: ColmiUUIDs.serviceV1)], manufacturerData: nil) XCTAssertTrue(ColmiCoordinator.matches(name: "Unlabeled", advertisement: adv)) @@ -100,13 +117,22 @@ final class PairingMatchingTests: XCTestCase { } /// Registry order + each coordinator's matcher, end to end: every ring lands on exactly one driver. + /// + /// The R99 rows are the point of the whole exercise: the owner's ring (`R99 54DC`) has to land on + /// `.colmiSmartHealth` from its *name alone*, because its advertisement is the one thing the nRF + /// capture didn't record. func testRegistryCrossClaimMatrix() { let cases: [(String, String?, AdvertisementInfo, RingDeviceType?)] = [ ("jring", "SMART_RING", noAdv, .jring), ("QRing-Colmi advertising its service", "R09_00AA", qringAdv, .colmiR02), ("QRing-Colmi with a bare advertisement", "R09_00AA", noAdv, .colmiR02), - ("SmartHealth-Colmi", "R09_00AA", smartHealthAdv, .colmiSmartHealth), - ("SmartHealth-Colmi (COLMI-prefixed name)", "COLMI R10_xyz", smartHealthAdv, .colmiSmartHealth), + // Underscore = QRing, and the marker no longer overrides that (it may well be on a QRing ring + // too — every ring in this SDK family carries the Yucheng company ID). + ("QRing-Colmi carrying the Yucheng company ID", "R09_00AA", smartHealthAdv, .colmiR02), + ("QRing-Colmi (COLMI-prefixed name)", "COLMI R10_xyz", smartHealthAdv, .colmiR02), + ("R99, name only — the capture we actually have", "R99 54DC", noAdv, .colmiSmartHealth), + ("R99 with the Yucheng company ID", "R99 54DC", smartHealthAdv, .colmiSmartHealth), + ("R99 lowercase hex suffix", "R99 54dc", noAdv, .colmiSmartHealth), ("TK5", "TK5 24AA", tk5Adv, .tk5), ("TK5, name only", "TK5 24AA", noAdv, .tk5), ("unknown peripheral", "Galaxy Watch", noAdv, nil), @@ -120,48 +146,68 @@ final class PairingMatchingTests: XCTestCase { } } - /// The TK5's own manufacturer data (`10786501…`) *contains* the `1078` SmartHealth marker — every - /// ring in this SDK family does. Only the name half of the conjunction keeps the TK5 out of the - /// Colmi family, so assert it directly: TK5 auto-detection is exactly what it was. + /// The TK5's own manufacturer data (`10786501…`) starts with the `1078` marker — every ring in this + /// SDK family does, so the marker cannot separate them and the *name* is what does. `TK5 24AA` is a + /// name the catalog resolves to a `.tk5`-only card, so this coordinator (registered *ahead* of + /// `TK5Coordinator`) stands aside even though the name is space-separated like its own family's. func testSmartHealthColmiDoesNotClaimTheTK5() { - XCTAssertTrue(tk5Adv.manufacturerData!.hexString.contains( + XCTAssertTrue(tk5Adv.manufacturerData!.hexString.hasPrefix( ColmiSmartHealthCoordinator.Advertisement.manufacturerHexMarker )) + // The TK5's name is in the SmartHealth *shape* — so only the catalog lookup keeps it out. + XCTAssertTrue(ColmiSmartHealthCoordinator.Advertisement.isSmartHealthName("TK5 24AA")) XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) XCTAssertTrue(TK5Coordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + // …and an *unnamed* ring carrying the TK5's fuller prefix stays the TK5's, too: the marker + // fallback below must not steal it from a coordinator that comes after us in the registry. + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: tk5Adv)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "Unlabeled", advertisement: tk5Adv), .tk5) } /// The marker lives in the manufacturer data's **company-ID slot**, so it is matched as a prefix. An /// unanchored substring test over the hex string is not even byte-aligned — mfr bytes `a1 07 8f` /// stringify to `"a1078f"` — and Colmi's manufacturer payload commonly embeds the MAC, so an unlucky - /// QRing-Colmi would be tagged as the SmartHealth family, defaulting the picker (and the first - /// connect) to a protocol its firmware doesn't speak. + /// ring would be tagged as the SmartHealth family, defaulting the picker (and the first connect) to a + /// protocol its firmware doesn't speak. func testSmartHealthMarkerIsAnchoredToTheCompanyIDSlot() { let straddling = AdvertisementInfo(serviceUUIDs: [], manufacturerData: bytes("a1078fcc")) XCTAssertTrue(straddling.manufacturerData!.hexString.contains("1078")) // the trap it must not fall into - XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: straddling)) - XCTAssertEqual(RingBLEClient.matchDeviceType(name: "R09_00AA", advertisement: straddling), .colmiR02) - } - - /// The conjunction, term by term. Each half alone is not enough. - func testSmartHealthColmiRequiresAllThreeSignals() { - // Colmi name + marker + no QRing service → claimed. - XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: smartHealthAdv)) - // Colmi name, but no manufacturer data at all → not claimed (falls through to QRing-Colmi). - XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: noAdv)) - // Colmi name + marker, but the ring advertises a QRing service → disqualified. + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: straddling)) + XCTAssertNil(RingBLEClient.matchDeviceType(name: "Unlabeled", advertisement: straddling)) + } + + /// The rule, term by term: the name decides, the QRing service vetoes, and the marker only speaks + /// when nothing else can. + func testSmartHealthColmiMatchesOnTheNamingConvention() { + // ` <4 hex>` on a card that can be this family → claimed, with or without any manufacturer + // data. This is the whole fix: the R99's advertisement is exactly what we do *not* have. + XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "R99 54DC", advertisement: noAdv)) + XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "R99 54DC", advertisement: smartHealthAdv)) + // The underscore convention is the QRing line — the marker's presence must not promote it. + for name in ["R02_ABCD", "COLMI R10_1234", "R11C_ABCD", "H59_1234", "R05_1A2B", "R11_BEEF"] { + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: name, advertisement: smartHealthAdv), name) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: name, advertisement: noAdv), .colmiR02, name) + } + // A jring is neither. + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "SMART_RING", advertisement: smartHealthAdv)) + // A QRing service disqualifies outright, however the ring names itself — the R99 included. for uuid in ColmiSmartHealthCoordinator.Advertisement.qringServiceUUIDs { - let conflicted = AdvertisementInfo( - serviceUUIDs: [uuid], - manufacturerData: smartHealthAdv.manufacturerData - ) - XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: conflicted)) - XCTAssertEqual( - RingBLEClient.matchDeviceType(name: "R09_00AA", advertisement: conflicted), .colmiR02 - ) + for advertisement in [ + AdvertisementInfo(serviceUUIDs: [uuid], manufacturerData: nil), + AdvertisementInfo(serviceUUIDs: [uuid], manufacturerData: smartHealthAdv.manufacturerData), + ] { + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R99 54DC", advertisement: advertisement)) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "R09_00AA", advertisement: advertisement)) + // It answers to the *other* driver, and `ColmiCoordinator` claims it by service UUID. + XCTAssertEqual( + RingBLEClient.matchDeviceType(name: "R99 54DC", advertisement: advertisement), .colmiR02 + ) + } } - // Marker, but not a Colmi-line name → not claimed. - XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: smartHealthAdv)) + // Nothing names it, but it carries the Yucheng company ID: a YCBT ring, and this family — whose + // sensors are bitmap-gated rather than assumed — is the conservative home for one. + XCTAssertTrue(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: smartHealthAdv)) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: "Unlabeled", advertisement: noAdv)) } // MARK: - The user's pick is authoritative (B4) @@ -227,6 +273,30 @@ final class PairingMatchingTests: XCTestCase { } } + /// The R99 is the one card whose *default* is SmartHealth — its capture says so. It still offers both + /// apps: a confirmed unit is not a confirmed SKU, and without the picker the connect-failure copy + /// ("switch the app type and try again") would point at a control that doesn't exist. + func testTheR99CardDefaultsToSmartHealthAndKeepsTheEscapeHatch() { + let card = WearableModel.colmiR99 + XCTAssertEqual(card.family, .colmiSmartHealth) + XCTAssertEqual(card.brand, WearableModel.colmiR09.brand) // lives on the Colmi tab + XCTAssertEqual(card.families, [.colmiSmartHealth, .colmiR02]) + XCTAssertEqual(card.appVariants.map(\.variant), [.qring, .smartHealth]) + // Untouched picker, unrecognized row, no hint → the card's own default, not the first segment. + XCTAssertEqual(card.variant(picked: nil, rowFamily: nil, hinted: nil), .smartHealth) + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: nil, hinted: nil), .colmiSmartHealth) + // The scan tags the row itself, which is what a tap actually connects as. + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: .colmiSmartHealth, hinted: nil), .colmiSmartHealth) + // …and a QRing hint borrowed from a neighbouring Colmi must not drag it onto the wrong driver. + XCTAssertEqual(card.preferredFamily(picked: nil, rowFamily: .colmiSmartHealth, hinted: .qring), .colmiSmartHealth) + // The user still outranks everything, in both directions. + XCTAssertEqual(card.preferredFamily(picked: .qring, rowFamily: .colmiSmartHealth, hinted: nil), .colmiR02) + XCTAssertEqual(card.otherVariant(than: .smartHealth), .qring) + XCTAssertEqual(card.supportLevel, .limited) + // No R99 art exists; nil is the only value `RingArtView` has a fallback for. + XCTAssertNil(card.imageName) + } + func testColmiCardsOfferBothAppsAndTK5OffersNone() { for model in WearableModel.catalog where model.family == .colmiR02 { XCTAssertEqual(model.appVariants.map(\.variant), [.qring, .smartHealth], model.displayName) @@ -260,6 +330,7 @@ final class PairingMatchingTests: XCTestCase { "COLMI R10_xyz": "colmi-r10", "R11C_BEEF": "colmi-r11", "COLMI R12_x": "colmi-r12", + "R99 54DC": "colmi-r99", "R05_1A2B": "yawell-r05", "R10_DEAD": "yawell-r10", "R11_BEEF": "yawell-r11", @@ -303,9 +374,20 @@ final class PairingMatchingTests: XCTestCase { WearableModel.resolve(advertisedName: nil, selectedModelID: "colmi-r10", family: .colmiSmartHealth)?.id, "colmi-r10" ) + // The R99 connects as `.colmiSmartHealth` by default, and is still a "Colmi R99" if the user + // overrides the picker to QRing. + XCTAssertEqual( + WearableModel.resolve(advertisedName: "R99 54DC", selectedModelID: nil, family: .colmiSmartHealth)?.id, + "colmi-r99" + ) + XCTAssertEqual( + WearableModel.resolve(advertisedName: "R99 54DC", selectedModelID: nil, family: .colmiR02)?.id, + "colmi-r99" + ) // A card that can't be this family still doesn't resolve to it. XCTAssertNil(WearableModel.resolve(advertisedName: "TK5 24AA", selectedModelID: nil, family: .colmiSmartHealth)) XCTAssertNil(WearableModel.resolve(advertisedName: "R09_00AA", selectedModelID: nil, family: .tk5)) + XCTAssertNil(WearableModel.resolve(advertisedName: "R99 54DC", selectedModelID: nil, family: .tk5)) } func testUnknownLegacyColmiHasNoExactModel() { @@ -370,18 +452,21 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(RingDeviceType.colmiSmartHealth.supportLevel, .limited) } - /// Both YCBT families are unproven, and only unproven families get a badge. A Colmi card therefore - /// carries one *only* while its picker is on SmartHealth — the same physical ring, a different - /// driver's maturity. + /// Both YCBT families are unproven, and only unproven families get a badge. The two cards that + /// *default* to one — the TK5 and the R99 — therefore wear it as they sit; a two-app Colmi card wears + /// it only while its picker is on SmartHealth (the same physical ring, a different driver's maturity). func testLimitedSupportFamiliesCarryTheBadge() { - XCTAssertEqual(WearableModel.tk5.supportLevel, .limited) - XCTAssertEqual(WearableModel.tk5.supportLevel.badgeLabel, "Limited support") - - for model in WearableModel.catalog where model.family != .tk5 { - XCTAssertEqual(model.supportLevel, .full, model.displayName) - XCTAssertNil(model.supportLevel.badgeLabel, model.displayName) + let limitedByDefault: Set = [WearableModel.tk5.id, WearableModel.colmiR99.id] + for model in WearableModel.catalog { + let expected: WearableSupportLevel = limitedByDefault.contains(model.id) ? .limited : .full + XCTAssertEqual(model.supportLevel, expected, model.displayName) + XCTAssertEqual( + model.supportLevel.badgeLabel, + expected == .limited ? "Limited support" : nil, + model.displayName + ) } - for model in WearableModel.catalog where model.family == .colmiR02 { + for model in WearableModel.catalog where !model.appVariants.isEmpty { XCTAssertEqual(model.supportLevel(for: .qring), .full, model.displayName) XCTAssertEqual(model.supportLevel(for: .smartHealth), .limited, model.displayName) } @@ -436,5 +521,9 @@ final class PairingMatchingTests: XCTestCase { ) } XCTAssertEqual(WearableModel.tk5.imageName, "tk5") + // The R99 ships no art of its own, so it takes the generic-ring path — which only a *nil* + // `imageName` gets: an unregistered name renders an empty platter. + XCTAssertNil(WearableModel.colmiR99.imageName) + XCTAssertNotNil(UIImage(named: RingArtView.fallbackImage, in: appBundle, compatibleWith: nil)) } } From c8969a4275e11d9d25fd718a652d71cfabe77827 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 16:22:47 -0400 Subject: [PATCH 3/6] Gate HRV behind the ring's capability bitmap, widen the SpO2 window, and fail fast when a measurement is refused --- PulseLoop/Events/PulseEventBus.swift | 43 ++++- .../ColmiSmartHealthCoordinator.swift | 48 ++++-- PulseLoop/RingProtocol/RingEventBridge.swift | 4 + PulseLoop/RingProtocol/RingProtocol.swift | 19 ++- PulseLoop/RingProtocol/YCBTDecoder.swift | 34 +++- PulseLoop/RingProtocol/YCBTDriver.swift | 62 ++++++- PulseLoop/RingProtocol/YCBTProtocol.swift | 12 ++ PulseLoop/Services/RingSyncCoordinator.swift | 128 ++++++++++++-- PulseLoopTests/PairingMatchingTests.swift | 61 +++++++ PulseLoopTests/RingIdentityMemoryTests.swift | 114 +++++++++++++ PulseLoopTests/SpotMeasurementGateTests.swift | 131 ++++++++++++++ PulseLoopTests/YCBTDecoderTests.swift | 160 ++++++++++++++++++ docs/YCBT-Protocol.md | 20 +++ 13 files changed, 810 insertions(+), 26 deletions(-) create mode 100644 PulseLoopTests/SpotMeasurementGateTests.swift diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index ce7ffc3a..6f5d3339 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -214,13 +214,24 @@ final class EventPersistenceSubscriber { let device = MetricsService.fetchDevices(context).first ?? Device() device.deviceType = deviceType device.wearableModelID = wearableModelID - if let advertisedName { device.advertisedName = advertisedName } + if let advertisedName { + device.advertisedName = advertisedName + } device.capabilities = capabilities + adoptDeviceName(advertised: advertisedName, deviceType: deviceType, on: device) context.insert(device) case .deviceForgotten: guard let device = MetricsService.fetchDevices(context).first else { break } device.wearableModelID = nil device.advertisedName = nil + // The name goes with them. This single row is *reused* by the next ring paired + // (`fetchDevices(context).first ?? Device()`), so a name adopted from the ring being + // forgotten would otherwise outlive it — and, being neither empty nor a placeholder, + // `adoptDeviceName` would read it as a name the user chose and defend it against the new + // ring's own advertisement forever. Forget an R99, pair a TK5, and the coach's `device_name` + // and the diagnostics export's `wearableName` would both still say "R99 54DC": worse than the + // old placeholder, because it reads as authoritative. + device.name = "" context.insert(device) case let .batteryLevel(percent): let device = MetricsService.fetchDevices(context).first ?? Device() @@ -396,6 +407,36 @@ final class EventPersistenceSubscriber { lastBatteryLogAt = now } + /// Names nobody chose: the `Device()` initializer's default (`"SMART_RING"`, which is also the jring's + /// display name) and every other family's fallback. A `Device.name` still holding one of these has + /// never been told what the ring is actually called. + private static let placeholderDeviceNames: Set = Set(RingDeviceType.allCases.map(\.displayName)) + + /// Give the device the best name available for the ring **now on the other end of the link** — its + /// advertised name, or its family's placeholder when the advertisement carried none — without ever + /// overwriting a name a human chose. + /// + /// `Device.name` is what the human-facing surfaces read (the coach's device context, the diagnostics + /// export's `wearableName`), and nothing ever wrote it: a paired Colmi R99 exported as `SMART_RING`, + /// the jring's default, which is a misleading thing to hand someone debugging a Colmi. `advertisedName` + /// alone was set and nothing read it. + /// + /// The placeholder check is the whole of the "don't clobber" rule. No screen renames a device today, + /// so in practice this only ever fills a blank — but when one does, the user's name must survive the + /// next connect, which re-publishes `.deviceIdentified` on every handshake. + /// + /// Falling back to `deviceType.displayName` is what keeps the row's name in the *current* ring's family + /// when a connect brings no advertisement to adopt (state restoration, a cached peripheral): the row is + /// shared across pairings, so without it a fresh TK5 could keep answering to the name of the Colmi that + /// used to be in this slot. `.deviceForgotten` clears the name to `""` precisely so this path can run. + private func adoptDeviceName(advertised: String?, deviceType: RingDeviceType, on device: Device) { + let current = device.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard current.isEmpty || Self.placeholderDeviceNames.contains(current) else { return } + let adopted = advertised ?? deviceType.displayName + guard device.name != adopted else { return } + device.name = adopted + } + /// 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 } diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index dd608fcc..6a063bea 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -125,27 +125,35 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { /// a temperature or blood-pressure sensor at all — so anything sensor-dependent is deferred to /// `bitmapGatedCapabilities` and only claimed if the ring itself claims it. /// + /// **A baseline entry is an unconditional promise**: the refinement is additive-only, so the bitmap + /// can never take one back. That is why HRV is no longer here — see `bitmapGatedCapabilities`. + /// /// Two entries look like they belong in the gated set and deliberately don't, because they are /// *protocol* facts, not sensor facts — identical for every YCBT ring, and the TK5 (the one unit of /// this protocol we have on the bench) declares both as baseline: /// /// - `.measurementInterval` is the five `01 xx {enable, interval}` monitor writes. It is a settings - /// screen, not a sensor; a ring that doesn't implement one of the five NAKs that one write. + /// screen, not a sensor; a ring that doesn't implement one of the five NAKs that one write. The + /// R99 proved this benign: it NAKed `01 45` (HRV) and `01 1c` (all-day BP) with `0xFC` and honoured + /// the other three. /// - `.spo2History` is the all-day `05 1A` log. A ring without it answers the query with a no-data /// header or `0xFC`, which `YCBTHistoryTransfer` skips permanently. /// /// Neither is named by any bit in `YCBTSupportFunction`, so gating them would not defer the decision /// — it would make them permanently unreachable (see `bitmapGatedCapabilities`). /// - /// **`.fatigue` is deliberately absent**, unlike on the TK5. It rides the body-data record (`05 33`) - /// and no bit names it, so we can neither gate it nor honestly promise it on hardware nobody has - /// connected yet — and unlike the two above, an unsupported claim here *is* user-visible: `.fatigue` - /// renders its own Vitals gauge, which would sit permanently at "No fatigue score yet". B6 (the - /// first real sync) is what decides; adding a capability then is a one-line change, and a card that - /// appears is a better surprise than one that never fills. + /// **`.findDevice` stays here on no evidence either way.** The R99's bitmap does *not* claim it + /// (byte 6 bit 4 is clear) and nobody has pressed Find Ring on one, so we have neither a working + /// buzz nor a refusal. Gating it would *remove* a button that may well work — the opposite trade from + /// HRV, where four independent denials said the button can never work. Left as a baseline promise, + /// recorded here as untested; the first person to press it on an R99 settles it. + /// + /// **`.fatigue` is deliberately absent**, unlike on the TK5. It rides the body-data record (`05 33`), + /// which the R99 answered with `0xFC` (unsupported key) — so on this ring it is not merely unnamed by + /// any bit, it is confirmed absent. Its Vitals gauge would sit permanently at "No fatigue score yet". let capabilities: Set = [ - .heartRate, .spo2, .spo2History, .steps, .sleep, .remSleep, .battery, .hrv, - .manualHeartRate, .manualSpo2, .manualHrv, + .heartRate, .spo2, .spo2History, .steps, .sleep, .remSleep, .battery, + .manualHeartRate, .manualSpo2, .realtimeHeartRate, .realtimeSteps, .findDevice, .measurementInterval, ] @@ -158,10 +166,28 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { /// bit can ever satisfy is not a deferred decision but a dead promise, permanently unreachable while /// reading as "supported if the ring says so". `PairingMatchingTests` asserts that invariant. /// - /// These are exactly the rows the gap analysis marks `❔` for this family: present in the protocol, - /// unknown per unit. + /// ## HRV moved here, and the R99 is why + /// + /// `.hrv`/`.manualHrv` used to be baseline — an unconditional promise this ring cannot keep. The + /// owner's `R99 54DC` (firmware 2.32) denies HRV **four independent ways** in one session: + /// + /// - its `02 01` bitmap leaves `ISHASHRV` (byte 1 bit 1) clear; + /// - `01 45` (the all-day HRV monitor) → `0xFC` unsupported key; + /// - `05 33` (the body-data history that carries HRV) → `0xFC`; + /// - `03 2f` with mode `0x0a` → status `0x01`, an outright refusal to start. + /// + /// The user pressed "Measure HRV", the ring never answered, and the app spun the full 45 s window + /// before failing. Gating both on the bitmap turns a broken button into an absent one — and costs a + /// ring that *does* claim HRV (the bits exist: byte 1 bit 1 and byte 23 bit 0) nothing at all. + /// `TK5Coordinator` is untouched: its HRV works, and it gates nothing. + /// + /// The same session settled the rest of this list on the same ring: `.bloodPressure` + + /// `.manualBloodPressure` were claimed and a spot BP measurement returned 100/68 — the gate working + /// as designed — while `.temperature`, `.stress` and `.bloodSugar` were not claimed and correctly + /// never appeared. let bitmapGatedCapabilities: Set = [ .temperature, .bloodPressure, .stress, .bloodSugar, .manualBloodPressure, + .hrv, .manualHrv, ] let iconSystemName = "circle.circle.fill" diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index ed008420..3d914220 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -123,6 +123,10 @@ enum RingEventBridge { // 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. + // + // `.measurementRejected` belongs to that first group on purpose: it is the ring declining a + // command, not a reading, so there is nothing to persist. `RingSyncCoordinator` reads it off + // the raw-packet feed instead — the one consumer that has any business acting on it. return extraMetricEvents(for: decoded) } } diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index 99602558..c1b8f8b7 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -141,6 +141,15 @@ enum RingDecodedEvent: Sendable { /// no `PulseEvent`, because nothing in the app gates on wear state yet. It is decoded so the packet /// feed can show *why* a measurement returned nothing (the ring was off). case wearingStatus(worn: Bool, timestamp: Date) + /// The ring **refused** to start the spot measurement we asked for (YCBT `03 2f` answered with a + /// non-zero status). `mode` is the measurement mode we started — the reply itself carries only a + /// status byte, so the mode comes from the start `YCBTDriver` remembers sending. + /// + /// Produces no `PulseEvent`: it is a verdict on a command, not data. `RingSyncCoordinator` reads it + /// off the raw-packet feed and aborts the matching in-flight measurement, which is the whole point — + /// the owner's R99 refuses HRV (mode `0x0a` → status `0x01`), and without this the app polls a ring + /// that already said no for the full 45-second window before reporting a generic failure. + case measurementRejected(mode: UInt8) case timeSyncAck(timestamp: Date) case commandAck(commandId: UInt8) case unknown(commandId: UInt8, raw: Data) @@ -172,6 +181,7 @@ enum RingDecodedEvent: Sendable { case .supportFunctions: return "support_functions" case .chipScheme: return "chip_scheme" case .wearingStatus: return "wearing_status" + case .measurementRejected: return "measurement_rejected" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" case .unknown: return "unknown" @@ -183,8 +193,11 @@ enum RingDecodedEvent: Sendable { case .unknown: return .unknown case .commandAck, .heartRateComplete, .spo2Complete, .spo2Progress, .bind, .firmware, - .bandFunction, // bit ordering unverified against hardware - .wearingStatus: // layout is SDK-verified; the status byte's *polarity* is not + .bandFunction, // bit ordering unverified against hardware + .wearingStatus, // layout is SDK-verified; the status byte's *polarity* is not + .measurementRejected: // 0x00 = accepted is hardware-confirmed; that *every* non-zero code + // means "refused" is the SDK's generic `code` contract, unnamed by + // any enum in it (we have seen exactly one: 0x01, refusing HRV) return .partial default: return .known @@ -225,6 +238,8 @@ enum RingDecodedEvent: Sendable { return #"{"chip_scheme":\#(value)}"# case let .wearingStatus(worn, _): return #"{"worn":\#(worn)}"# + case let .measurementRejected(mode): + return #"{"rejected_mode":\#(mode)}"# case let .historySyncProgress(stage): return #"{"stage":"\#(stage)"}"# case let .battery(percent): diff --git a/PulseLoop/RingProtocol/YCBTDecoder.swift b/PulseLoop/RingProtocol/YCBTDecoder.swift index 580eee5e..749ef5b6 100644 --- a/PulseLoop/RingProtocol/YCBTDecoder.swift +++ b/PulseLoop/RingProtocol/YCBTDecoder.swift @@ -18,7 +18,12 @@ struct YCBTDecoder { /// Decode one validated frame into the events it carries. Dispatch is on the **group** first, exactly /// as `YCBTClientImpl.bleDataResponse` does — the key byte only means anything within its group /// (`0x00` is GetDeviceInfo in group 2, the live-status stream in group 6, and find-phone in group 4). - func decode(_ frame: YCBTFrame, now: Date = Date()) -> [RingDecodedEvent] { + /// + /// - Parameter startedMode: the mode of the `03 2f` **start** still awaiting its reply, or nil if the + /// last live-measurement command we sent was a stop (or none was). The ring's reply carries a status + /// but not a mode, so the decoder cannot know on its own *which* measurement a refusal refers to — + /// `YCBTDriver`, the one thing that sees both directions, supplies it. + func decode(_ frame: YCBTFrame, now: Date = Date(), startedMode: UInt8? = nil) -> [RingDecodedEvent] { switch frame.type { case YCBTGroup.real: return decodeRealStream(frame, now: now) @@ -30,6 +35,9 @@ struct YCBTDecoder { case YCBTGroup.get: return decodeGetReply(frame) + case YCBTGroup.appControl where frame.cmd == YCBTCommand.liveMeasurement: + return decodeMeasurementStartReply(frame.payload, startedMode: startedMode) + case YCBTGroup.setting where frame.cmd == YCBTSettingKey.setTime: return [.timeSyncAck(timestamp: now)] @@ -38,6 +46,30 @@ struct YCBTDecoder { } } + // MARK: - AppControl replies (group 0x03) + + /// The ring's answer to `03 2f {enable, mode}` — one status byte (§5.1 / `YCBTMeasurementMode.isAccepted`). + /// + /// `0x00` is "started", and is the only reply the app has ever acted on. Anything else is the firmware + /// declining: the R99 answers `0x01` to mode `0x0a` (HRV), a sensor it does not have. Surfaced as + /// `.measurementRejected` so the in-flight spot measurement can fail immediately instead of polling a + /// stream the ring already told us it will never send. + /// + /// Two things keep a *stray* refusal from cancelling the wrong measurement, and both are deliberate: + /// + /// 1. `startedMode` is nil unless a start is actually outstanding — a rejected **stop** is just an ack + /// (nothing is in flight to cancel), and a duplicate/late reply finds the mode already cleared. + /// 2. The mode travels with the event, so `RingSyncCoordinator` can check it against the measurement + /// it is actually running before failing anything. + private func decodeMeasurementStartReply(_ p: [UInt8], startedMode: UInt8?) -> [RingDecodedEvent] { + let ack: [RingDecodedEvent] = [.commandAck(commandId: YCBTCommand.liveMeasurement)] + // A status is exactly one byte (the SDK's own `isError` shape); anything else is not a verdict. + guard p.count == 1, let status = p.first, !YCBTMeasurementMode.isAccepted(status: status), + let mode = startedMode else { return ack } + ycbtLog.info("03 2f mode \(mode, privacy: .public) refused → status \(status, privacy: .public)") + return [.measurementRejected(mode: mode)] + } + // MARK: - Async live stream (be940003, group 0x06) private func decodeRealStream(_ frame: YCBTFrame, now: Date) -> [RingDecodedEvent] { diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift index 06867307..fc5be615 100644 --- a/PulseLoop/RingProtocol/YCBTDriver.swift +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -22,6 +22,36 @@ final class YCBTDriver: WearableDriver { /// engine sees decoded events), and it is handed to the engine so `runStartup` can seed the queue. private let transfer: YCBTHistoryTransfer + /// The `03 2f` commands still owed a reply, oldest first — the mode for a **start**, `nil` for a + /// **stop** (a rejected stop cancels nothing, so it names no measurement). + /// + /// The ring answers a live-measurement command with a bare status byte and **no mode**, so a refusal + /// is anonymous on the wire. The driver is the one place that sees both directions — `frame(_:)` for + /// every outbound command, `ingest(_:from:)` for every inbound frame — so it is the only place that + /// can pair the two up. + /// + /// It has to be a **queue**, not one slot, because more than one `03 2f` is routinely outstanding: + /// + /// • Framing happens when a command is *enqueued* (`RingBLEClient.enqueueWrite` calls `frame(_:)` + /// on the way into the serialized write queue), not when it reaches the wire — so a command is + /// recorded here long before the ring has answered the one ahead of it. + /// • Every spot measurement ends with `engine.stopX()` immediately followed by + /// `restartWorkoutHeartRateIfActive()` (`RingSyncCoordinator`), which during a workout enqueues a + /// stop and a start back-to-back. Both are still owed replies. + /// + /// With a single slot the start overwrote the stop, the stop's `0x00` reply was then read as *the + /// start's* verdict and cleared the slot, and the start's real refusal decoded anonymously and was + /// swallowed — the ring said no and the user still watched the full window run out, which is the exact + /// failure this path exists to kill. (A *NAKed* stop was worse: it decoded as a refusal of the start + /// behind it and aborted a measurement the ring was actually running.) One serialized write queue and + /// one ring means replies come back in the order the commands went out, so FIFO pairing is exact. + private var pendingMeasurementReplies: [UInt8?] = [] + + /// A ring that stops answering `03 2f` must not grow the queue without bound. Real pipeline depth is + /// two (the stop+restart pair above), so reaching this means replies are no longer coming; the oldest + /// entries are then the stale ones, and dropping them keeps the newest command pairable. + private static let maxPendingMeasurementReplies = 8 + init(writer: RingCommandWriter) { self.writer = writer self.transfer = YCBTHistoryTransfer(writer: writer) @@ -45,8 +75,28 @@ final class YCBTDriver: WearableDriver { // MARK: Framing func frame(_ command: Data) -> Data { + // Every outbound command passes through here exactly once, which is what makes this the seam that + // can watch for live-measurement commands (see `pendingMeasurementReplies`). + let logical = [UInt8](command) + noteLiveMeasurementCommand(logical) // Logical command is `[type, cmd, payload…]`; insert the total-length field and append CRC16. - YCBTFrame.frame([UInt8](command)) + return YCBTFrame.frame(logical) + } + + /// Queue one entry per outbound `03 2f {enable, mode}`: the mode for a start, `nil` for a stop. Any + /// other command is none of our business. + /// + /// A stop is queued too, and that is the point. The ring replies to a stop as well, and its reply is + /// byte-for-byte indistinguishable from a start's — so a stop we didn't queue would have its reply + /// consumed by the next start in line, and that start's own verdict lost. + private func noteLiveMeasurementCommand(_ logical: [UInt8]) { + guard logical.count >= 4, + logical[0] == YCBTGroup.appControl, + logical[1] == YCBTCommand.liveMeasurement else { return } + pendingMeasurementReplies.append(logical[2] == 1 ? logical[3] : nil) + if pendingMeasurementReplies.count > Self.maxPendingMeasurementReplies { + pendingMeasurementReplies.removeFirst() + } } // MARK: Lifecycle @@ -57,6 +107,9 @@ final class YCBTDriver: WearableDriver { func connectionDidStart() { assembler.reset() transfer.cancel() + // Commands the old link never got a reply for are owed nothing by the new one; leaving them + // queued would pair the fresh connection's first `03 2f` reply with a dead command's mode. + pendingMeasurementReplies.removeAll() } /// Cancelling on the next *connect* is too late for the transfer: its stall watchdog is a timer, and @@ -67,6 +120,7 @@ final class YCBTDriver: WearableDriver { func connectionDidEnd() { assembler.reset() transfer.cancel() + pendingMeasurementReplies.removeAll() } // MARK: Inbound decode @@ -87,6 +141,12 @@ final class YCBTDriver: WearableDriver { case YCBTGroup.devControl: acknowledgePush(frame) events.append(contentsOf: decoder.decode(frame)) + case YCBTGroup.appControl where frame.cmd == YCBTCommand.liveMeasurement: + // The verdict on the *oldest* `03 2f` still owed one. Retire it as we hand the decoder the + // mode that command carried (nil for a stop): this reply is the only one that command + // gets, so a duplicate or late frame finds the queue empty and refers to nothing. + let startedMode = pendingMeasurementReplies.isEmpty ? nil : pendingMeasurementReplies.removeFirst() + events.append(contentsOf: decoder.decode(frame, startedMode: startedMode)) default: events.append(contentsOf: decoder.decode(frame)) } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index 01b4a351..e1abe1ae 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -239,6 +239,18 @@ enum YCBTMeasurementMode { static let bloodFat: UInt8 = 0x09 static let hrv: UInt8 = 0x0a static let stress: UInt8 = 0x0c + + /// The ring's **verdict** on a `03 2f` start, and the reason `.measurementRejected` exists. + /// + /// The reply is a single status byte — `0x00` = accepted, non-zero = "I will not run that" — and it + /// **does not echo the mode**, so the SDK just hands its caller `bArr[last]` as an opaque `code` + /// (`YCBTClientImpl.packetAppControlHandle`, which falls through to `onDataResponse(code, …)`). + /// Whoever decodes it therefore has to remember which mode it asked for; `YCBTDriver` does. + /// + /// Hardware: the owner's R99 answered `0x00` for HR / SpO₂ / BP and **`0x01` for HRV (mode 0x0a)** — + /// a ring saying, in one byte, that a sensor its bitmap already disclaimed is not there. Treating + /// that as an ordinary ack is what made the app poll for 45 s before giving a generic failure. + static func isAccepted(status: UInt8) -> Bool { status == 0x00 } } /// Group 4 (**DevControl**) — the ring→app push channel: measurement progress/results, SOS, find-phone, diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 5c4536c8..44a1bed8 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -15,6 +15,76 @@ protocol RingSyncGating: AnyObject { func awaitSyncCompletion(timeout: TimeInterval) async -> Bool } +/// Which spot measurements are in flight, and which of them the ring has **refused**. +/// +/// A ring can decline a measurement rather than merely fail to produce one: the owner's Colmi R99 +/// answers the HRV start (`03 2f` mode `0x0a`) with status `0x01`, because it has no HRV sensor. That +/// refusal used to decode to a generic ack, so the app polled a ring that had already said no for the +/// full 45-second window before reporting a generic failure. `RingSyncCoordinator` now aborts on it. +/// +/// The rule this type exists to hold — **a refusal may only ever abort the measurement it names** — is +/// the part that must not be got wrong, so it lives in a value type that can be tested without a live +/// BLE link rather than in loose flags on the coordinator: +/// +/// - A measurement not in flight has no window left to cut short, so a rejection that arrives late (after +/// the window closed, or from a stop) finds nothing to cancel. +/// - A rejection naming a different mode is dropped: it cannot fail an unrelated measurement. +/// - A **workout HR stream never begins one** — it is not a spot measurement, has no window to cut +/// short, and must keep streaming through anything that happens to a spot reading. +/// +/// **Several measurements can be in flight at once**, and that is not hypothetical: while a workout is +/// recording, `WorkoutSensorPollingService` fires `measureHR()`/`measureSpO2()` on its own timer, and +/// nothing serializes that against the Measurement modal, the BP calibration screen, or the coach's action +/// tools — each `measure*` only guards its *own* re-entry. So ownership is explicit: `begin` hands back a +/// `Token`, and only that token can read its own verdict or retire it. One shared slot failed all three +/// ways — the second `begin` overwrote the first's mode (so the first's refusal no longer matched it and +/// it spun its whole window), the first `end` disarmed the second, and, worst, the abort closures read one +/// shared `isRejected` without asking *whose* refusal it was, so a refused BP start also aborted a workout +/// HR poll the ring had never said a word about. +/// +/// The mode byte is YCBT's (`YCBTMeasurementMode`). It is the only protocol here that answers a start +/// with a verdict, so on jring and QRing-Colmi this is inert bookkeeping nothing ever reads. +struct SpotMeasurementGate { + /// A handle to one in-flight spot measurement. Identity is `id`, **not** the mode, so two flows that + /// somehow ran the same mode at once still could not end or abort each other. + struct Token: Hashable { + fileprivate let id = UUID() + let mode: UInt8 + } + + /// The measurements currently mid-poll, and whether the ring has refused each. + private var inFlight: [Token: Bool] = [:] + + /// Arm the gate for one measurement and hand back its handle. + mutating func begin(mode: UInt8) -> Token { + let token = Token(mode: mode) + inFlight[token] = false + return token + } + + /// Disarm `token` — and only `token`. Called on every exit path (success, timeout, rejection); the + /// measurement that finishes first must not disarm one still running. + mutating func end(_ token: Token) { + inFlight.removeValue(forKey: token) + } + + /// Has the ring refused **this** measurement? What each `pollForValue` abort closure asks, so a + /// refusal can only ever end the measurement it actually named. + func isRejected(_ token: Token) -> Bool { + inFlight[token] ?? false + } + + /// The ring refused `mode`. Honoured only by the in-flight measurement(s) actually running it. + mutating func noteRejected(mode: UInt8) { + for token in inFlight.keys where token.mode == mode { + inFlight[token] = true + } + } + + /// The modes currently mid-poll. Read by tests; the coordinator drives everything through tokens. + var modesInFlight: Set { Set(inFlight.keys.map(\.mode)) } +} + /// High-level orchestration of ring command flows. Subscribes to `PulseEventBus` to track the /// latest measurement values and completion signals, and exposes app-facing actions /// (`syncNow`, `measureHR`, `measureSpO2`, `querySleep`, `setGoal`). It only *orchestrates* @@ -98,20 +168,42 @@ final class RingSyncCoordinator { /// reading from a stale `latestHRValue` left on screen from a previous measurement. private var measurementReceivedReading = false + /// The spot measurements in flight, and which of them the ring has refused — see + /// `SpotMeasurementGate`, which owns the rule that a refusal can only ever abort the measurement it + /// actually names, and holds several concurrent measurements apart by token. + private var spot = SpotMeasurementGate() + /// Ring connection state, surfaced for the workout polling layer + UI. var connectionState: RingConnectionState { client.state } var isConnected: Bool { client.state == .connected } - /// Max time to wait for an on-demand reading before giving up. A Colmi manual HR reading can need - /// 15–30s of on-finger warm-up, so we poll up to this window and succeed the moment a value lands. + // MARK: Spot-measurement windows + // + // How long to keep polling for an on-demand reading before calling it a failure. These are not + // arbitrary: the numbers below are set against what the owner's Colmi R99 actually took, measured + // from the `03 2f` start frame to the reply that carried the value — + // + // HR ~19 s (window 30) · SpO₂ **38 s** (window was 40) · BP ~12 s (window 45) + // + // — plus enough headroom that a slow session is not a failed one. A window is a ceiling, never a + // wait: every measurement returns the instant its value lands (and now, on a ring that refuses the + // measurement outright, the instant it says so — see `SpotMeasurementGate`). + + /// A Colmi manual HR reading can need 15–30s of on-finger warm-up (observed: ~19 s). private let hrMeasureSeconds: UInt64 = 30 /// After the first valid bpm, keep reading this long so the reported value is settled, not a jumpy /// first sample. private let hrSettleSeconds: Int = 4 - private let spo2MeasureSeconds: UInt64 = 40 - /// HRV needs a stretch of beats to stabilize, so give it a longer on-finger window. + /// **Raised from 40 s.** The R99's successful SpO₂ sweep took 38 s — inside a 40 s window by two + /// seconds — and an earlier attempt in the same session ran past 41 s with no result. At 40 s the + /// outcome was a coin toss: the user watched the ring's red LED work and got an error anyway. 60 s + /// puts a comfortable margin around the one real timing we have; the cost of the extra 20 s is paid + /// only by a measurement that was going to fail regardless. + private let spo2MeasureSeconds: UInt64 = 60 + /// HRV needs a stretch of beats to stabilize, so give it a longer on-finger window. (A ring without + /// an HRV sensor no longer waits it out — it now refuses the `03 2f` start and we fail immediately.) private let hrvMeasureSeconds: UInt64 = 45 - /// BP rides the same PPG warm-up as SpO2 and the ring's estimator settles slowly. + /// BP rides the same PPG warm-up as SpO2 and the ring's estimator settles slowly (observed: ~12 s). private let bpMeasureSeconds: UInt64 = 45 /// The combined sweep computes every metric, so give it the longest window (observed: the ring /// streams empty packets for ~20s before the populated burst). @@ -322,6 +414,7 @@ final class RingSyncCoordinator { measurementReceivedReading = false // Spot reading: the engine picks the right command (jring live stream / Colmi manual 0x69 // continuous stream). Always stop the stream when we're done so the ring doesn't keep measuring. + let token = spot.begin(mode: YCBTMeasurementMode.heartRate) engine?.measureHeartRateSpot() // Phase 1 — warm up: the manual stream emits bpm 0 for ~25s before a real reading. Poll the full // window for the first reading *of this measurement* (not a stale prior value). Warm-up zeros are @@ -329,7 +422,7 @@ final class RingSyncCoordinator { _ = await pollForValue( window: hrMeasureSeconds, value: { self.measurementReceivedReading ? self.latestHRValue : nil }, - abort: { self.hrNoReadingReported } + abort: { self.hrNoReadingReported || self.spot.isRejected(token) } ) // Phase 2 — settle: keep reading briefly so the reported value is stable, not a first jump. var result = measurementReceivedReading ? latestHRValue : nil @@ -339,6 +432,7 @@ final class RingSyncCoordinator { if let v = latestHRValue { result = v } // latest stable sample } } + spot.end(token) engine?.stopHeartRate() // A spot read's stop also tears down the realtime stream (Colmi stops both 0x69 and 0x1e); // if a workout stream is supposed to be running, bring it straight back. @@ -365,12 +459,14 @@ final class RingSyncCoordinator { guard client.state == .connected else { spo2State = .failed; return nil } spo2State = .measuring latestSpO2Value = nil + let token = spot.begin(mode: YCBTMeasurementMode.spo2) engine?.startSpO2() let result = await pollForValue( window: spo2MeasureSeconds, value: { self.latestSpO2Value }, - abort: { false } + abort: { self.spot.isRejected(token) } ) + spot.end(token) engine?.stopSpO2() // On rings whose live metrics share one stream (TK5: every metric rides `03 2f`, and its stop // is mode-agnostic), stopping SpO2 also tears down a running workout HR stream. Bring it back. @@ -381,19 +477,23 @@ final class RingSyncCoordinator { /// Spot HRV reading. The engine starts the device's dedicated HRV mode (TK5: `03 2f 010a`), we /// wait for the first live HRV sample, then stop. Capability-gated to `.manualHrv`, so only rings - /// whose live protocol has an HRV mode reach this. + /// whose live protocol has an HRV mode reach this — which, since the R99, means a ring whose own + /// bitmap claimed one. A ring that lies about it (or a firmware that changes its mind) is caught by + /// the abort below: the `03 2f` refusal ends the measurement in the time one reply takes to arrive. @discardableResult func measureHRV() async -> Int? { guard hrvState != .measuring else { return nil } guard client.state == .connected else { hrvState = .failed; return nil } hrvState = .measuring latestHRVValue = nil + let token = spot.begin(mode: YCBTMeasurementMode.hrv) engine?.startHRV() let result = await pollForValue( window: hrvMeasureSeconds, value: { self.latestHRVValue }, - abort: { false } + abort: { self.spot.isRejected(token) } ) + spot.end(token) engine?.stopHRV() // Same shared-stream teardown as SpO2 above: restore the workout's live HR if one was running. restartWorkoutHeartRateIfActive() @@ -410,12 +510,14 @@ final class RingSyncCoordinator { guard client.state == .connected else { bpState = .failed; return nil } bpState = .measuring latestBloodPressureValue = nil + let token = spot.begin(mode: YCBTMeasurementMode.bloodPressure) engine?.startBloodPressure() _ = await pollForValue( window: bpMeasureSeconds, value: { self.latestBloodPressureValue?.systolic }, - abort: { false } + abort: { self.spot.isRejected(token) } ) + spot.end(token) let result = latestBloodPressureValue engine?.stopBloodPressure() // Same shared-stream teardown as SpO2/HRV above: restore the workout's live HR if one ran. @@ -510,6 +612,12 @@ final class RingSyncCoordinator { } case let .syncProgress(stage): updateSync(stage: stage) + case let .rawPacket(direction, _, decoded): + // `.measurementRejected` has no `PulseEvent` of its own — it is a verdict on a command, not + // data — so the raw-packet feed (which carries every decoded frame) is where a measurement + // hears the ring say no. + guard direction == .incoming, case let .measurementRejected(mode) = decoded else { break } + spot.noteRejected(mode: mode) default: break } diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 78aabf20..74c64a3a 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -425,6 +425,67 @@ final class PairingMatchingTests: XCTestCase { XCTAssertTrue(ColmiSmartHealthCoordinator().bitmapGatedCapabilities.isSubset(of: derivable)) } + /// The `02 01` bitmap the owner's `R99 54DC` (firmware 2.32) actually sent — 60 bytes, of which the + /// first 24 are its reply **verbatim** and the tail is zero padding. No mapped bit lives past byte 23 + /// and every length gate the parser applies (14 / 18 / 23 / 24) is cleared either way, so the padding + /// cannot change what this resolves to. It is the best fixture we will ever have: a real ring's answer. + private var r99SupportBitmap: [UInt8] { + let claimed: [UInt8] = [ + 0xf9, 0x09, 0x00, 0x00, 0x00, 0x00, 0x0c, 0xd8, + 0x10, 0x04, 0x01, 0xb2, 0xb6, 0x00, 0x40, 0x0f, + 0x00, 0x14, 0x50, 0x00, 0x00, 0x00, 0x20, 0x00, + ] + return claimed + [UInt8](repeating: 0, count: 60 - claimed.count) + } + + /// **The bug this family's gating exists to prevent, caught on real hardware.** The R99 does not have + /// an HRV sensor: its bitmap leaves `ISHASHRV` clear, it NAKs the HRV monitor (`01 45`) and the + /// body-data history (`05 33`) with `0xFC`, and it refuses the HRV measurement start outright + /// (`03 2f` mode `0x0a` → status `0x01`). While `.hrv`/`.manualHrv` were *baseline*, none of that + /// mattered — a baseline entry is an unconditional promise — so Vitals rendered a "Measure HRV" + /// button that spun for 45 s and failed, every time. + /// + /// This pins the exact set the ring now resolves to, from its own bytes. + func testTheRealR99BitmapResolvesToWhatTheRingActuallyHas() { + let claimed = YCBTSupportFunction.capabilities(from: r99SupportBitmap) + // What the ring itself claims — the four denials above start here, with a clear HRV bit. + XCTAssertEqual(claimed, [ + .steps, .sleep, .heartRate, .bloodPressure, .spo2, + .manualHeartRate, .manualBloodPressure, .manualSpo2, + ]) + + let refined = ColmiSmartHealthCoordinator().refinedCapabilities(bitmapDerived: claimed) + XCTAssertEqual(refined, [ + .heartRate, .spo2, .spo2History, .steps, .sleep, .remSleep, .battery, + .bloodPressure, .manualBloodPressure, + .manualHeartRate, .manualSpo2, + .realtimeHeartRate, .realtimeSteps, + .findDevice, .measurementInterval, + ]) + // The gate earning its keep in both directions: BP was claimed (and a spot BP measurement on this + // ring returned 100/68), while nothing it stayed silent about is offered. + for absent: WearableCapability in [.hrv, .manualHrv, .temperature, .stress, .bloodSugar, .fatigue] { + XCTAssertFalse(refined.contains(absent), "the R99 does not claim \(absent.rawValue)") + } + } + + /// **The TK5 is untouched by the R99 fix.** Its HRV is confirmed working from its own captures, and it + /// gates nothing — so it must still declare HRV as an unconditional baseline capability even when fed + /// the R99's own HRV-denying bitmap. (The R99 needed gating precisely because a family is not a SKU; + /// the TK5 family has one SKU, and it is the ring on the bench.) + func testTK5KeepsItsHRVThroughTheR99Fix() { + let tk5 = TK5Coordinator() + XCTAssertTrue(tk5.capabilities.isSuperset(of: [.hrv, .manualHrv])) + XCTAssertTrue(tk5.bitmapGatedCapabilities.isEmpty) + + let refined = tk5.refinedCapabilities( + bitmapDerived: YCBTSupportFunction.capabilities(from: r99SupportBitmap) + ) + XCTAssertEqual(refined, tk5.capabilities) + XCTAssertTrue(refined.contains(.hrv)) + XCTAssertTrue(refined.contains(.manualHrv)) + } + /// The gated set resolves through B2's additive-only refinement formula: a silent ring keeps the /// baseline, a claiming ring gains exactly what it claimed, and nothing outside the pre-approved /// list can ever be added. diff --git a/PulseLoopTests/RingIdentityMemoryTests.swift b/PulseLoopTests/RingIdentityMemoryTests.swift index 8ac65c2f..943bca4e 100644 --- a/PulseLoopTests/RingIdentityMemoryTests.swift +++ b/PulseLoopTests/RingIdentityMemoryTests.swift @@ -1,4 +1,5 @@ import XCTest +import SwiftData @testable import PulseLoop /// What `RingBLEClient` remembers about a ring *between* connections: its family, its exact catalog @@ -84,4 +85,117 @@ final class RingIdentityMemoryTests: XCTestCase { XCTAssertEqual(client.activeCapabilities, ColmiSmartHealthCoordinator().capabilities) XCTAssertFalse(client.activeCapabilities.contains(.temperature)) } + + // MARK: - The name that reaches the store + + private func identified(_ advertisedName: String?) -> PulseEvent { + .deviceIdentified( + deviceType: .colmiSmartHealth, + wearableModelID: "colmi-r99", + advertisedName: advertisedName, + capabilities: [] + ) + } + + /// `Device.name` is what the human-facing surfaces read — the coach's device context, and the + /// diagnostics export's `wearableName` — and nothing ever wrote it. It kept the `Device()` default, + /// `"SMART_RING"` (the *jring's* name), so the owner's diagnostics for a Colmi R99 reported a jring: + /// the one field in the export a human reads first, naming the wrong ring. + func testTheAdvertisedNameBecomesTheDeviceName() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + + subscriber.persist(identified("R99 54DC")) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertEqual(device.name, "R99 54DC") + XCTAssertEqual(device.advertisedName, "R99 54DC") + } + + /// A name a *user* chose survives. `.deviceIdentified` is re-published on every handshake (and again + /// whenever the capability bitmap refines the set), so anything that overwrites `name` unconditionally + /// would undo a rename on the next connect — seconds later. Only a placeholder is ever replaced. + func testAUserChosenNameIsNotClobberedByAReconnect() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(identified("R99 54DC")) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + device.name = "Left hand" + + subscriber.persist(identified("R99 54DC")) // the reconnect's re-published identity + + XCTAssertEqual(device.name, "Left hand") + XCTAssertEqual(device.advertisedName, "R99 54DC", "the advertised name is still recorded") + } + + /// A connect with no advertised name to offer (state restoration, a cached peripheral) leaves the + /// name alone rather than blanking it. + func testAConnectWithNoAdvertisedNameLeavesTheNameAlone() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(identified("R99 54DC")) + + subscriber.persist(identified(nil)) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertEqual(device.name, "R99 54DC") + } + + /// **A ring swap must not leave the previous ring's name behind.** There is one `Device` row and it is + /// *reused* across pairings (`fetchDevices(context).first ?? Device()`), so an adopted name outlives + /// the ring it came from unless Forget clears it — and an adopted name is neither empty nor a + /// placeholder, so the "don't clobber a name the user chose" guard would then defend it against the new + /// ring's own advertisement forever. + /// + /// Pair an R99, forget it, pair a TK5, and `Device.name` stayed "R99 54DC" while `deviceType`, + /// `advertisedName` and `capabilities` all said TK5. The coach's `device_name` and the diagnostics + /// export's `wearableName` then confidently named the ring that was *gone* — strictly worse than the + /// old `SMART_RING` placeholder, because a real name reads as authoritative. + func testForgettingARingReleasesItsNameForTheNextOne() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(identified("R99 54DC")) + + subscriber.persist(.deviceForgotten) + subscriber.persist(.deviceIdentified( + deviceType: .tk5, wearableModelID: "tk5", advertisedName: "TK5 1A2B", capabilities: [] + )) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertEqual(device.name, "TK5 1A2B", "the row must name the ring on the other end of the link") + XCTAssertEqual(device.advertisedName, "TK5 1A2B") + XCTAssertEqual(device.deviceType, .tk5) + } + + /// Forget alone: nothing may keep naming a ring that is no longer paired, so the export taken between + /// Forget and the next pairing names nothing rather than the ring the user just removed. + func testForgettingARingLeavesNothingNamingIt() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(identified("R99 54DC")) + + subscriber.persist(.deviceForgotten) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertEqual(device.name, "") + XCTAssertNil(device.advertisedName) + XCTAssertNil(device.wearableModelID) + } + + /// The new ring may not advertise a name at all (state restoration, a cached peripheral). It must then + /// fall back to *its own* family's placeholder — never inherit the forgotten ring's identity. + func testARingThatAdvertisesNoNameFallsBackToItsOwnFamily() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(identified("R99 54DC")) + subscriber.persist(.deviceForgotten) + + subscriber.persist(.deviceIdentified( + deviceType: .tk5, wearableModelID: "tk5", advertisedName: nil, capabilities: [] + )) + + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertEqual(device.name, RingDeviceType.tk5.displayName) + } } diff --git a/PulseLoopTests/SpotMeasurementGateTests.swift b/PulseLoopTests/SpotMeasurementGateTests.swift new file mode 100644 index 00000000..c1bf917c --- /dev/null +++ b/PulseLoopTests/SpotMeasurementGateTests.swift @@ -0,0 +1,131 @@ +import XCTest +@testable import PulseLoop + +/// The fast-fail rule for a **refused** spot measurement. +/// +/// A YCBT ring answers `03 2f` with a verdict, and the owner's Colmi R99 uses it: it refuses the HRV +/// start (mode `0x0a` → status `0x01`) because it has no HRV sensor. `RingSyncCoordinator` used to poll +/// that ring — which had already said no — for the full 45-second window before reporting a generic +/// failure. It now aborts on the refusal. +/// +/// The danger in aborting on a device-pushed signal is aborting the *wrong* thing, so the rule lives in +/// `SpotMeasurementGate`, a value type: a refusal may only ever cancel the measurement it names, while +/// that measurement is actually running. These are the cases that must never regress. (The coordinator +/// itself owns live BLE/SwiftData wiring and is not constructed in the unit suite; this is the piece of +/// it where the correctness lives.) +/// +/// Ownership is by token because **spot measurements really do overlap**: a recording workout's +/// `WorkoutSensorPollingService` fires `measureHR()`/`measureSpO2()` on its own timer while the user can +/// start a BP or HRV reading from the Measurement modal, the calibration screen, or the coach's action +/// tools — and nothing serializes those flows against each other. +final class SpotMeasurementGateTests: XCTestCase { + + /// The R99's case: the ring refuses the measurement we are running, and the poll gives up at once. + func testARefusalOfTheMeasurementInFlightAbortsIt() { + var gate = SpotMeasurementGate() + let hrv = gate.begin(mode: YCBTMeasurementMode.hrv) + XCTAssertFalse(gate.isRejected(hrv)) + + gate.noteRejected(mode: YCBTMeasurementMode.hrv) + + XCTAssertTrue(gate.isRejected(hrv)) + } + + /// **It must not cancel a measurement it doesn't name.** A refusal for one mode arriving while + /// another is being polled (a late reply from a previous sweep; a workout HR restart that raced into + /// the write queue mid-SpO₂) leaves the running measurement alone. + func testARefusalOfADifferentModeCannotAbortTheOneInFlight() { + var gate = SpotMeasurementGate() + let spo2 = gate.begin(mode: YCBTMeasurementMode.spo2) + + gate.noteRejected(mode: YCBTMeasurementMode.hrv) + gate.noteRejected(mode: YCBTMeasurementMode.heartRate) + + XCTAssertFalse(gate.isRejected(spo2), "only the measurement the ring actually named may be aborted") + } + + /// Nothing in flight ⇒ nothing to abort. This is what keeps a stray refusal off a **workout HR + /// stream**: streaming is not a spot measurement, it never arms the gate, and it must survive + /// anything that happens to a spot reading. + func testARefusalWithNoMeasurementInFlightIsIgnored() { + var gate = SpotMeasurementGate() + XCTAssertTrue(gate.modesInFlight.isEmpty) + + gate.noteRejected(mode: YCBTMeasurementMode.heartRate) + + XCTAssertTrue(gate.modesInFlight.isEmpty) + } + + /// A refusal that lands after the window already closed cannot fail the *next* measurement: `end` + /// retires the token, and a fresh `begin` hands out a new one that nothing has refused. + func testARefusalCannotLeakIntoTheNextMeasurement() { + var gate = SpotMeasurementGate() + let hrv = gate.begin(mode: YCBTMeasurementMode.hrv) + gate.noteRejected(mode: YCBTMeasurementMode.hrv) + XCTAssertTrue(gate.isRejected(hrv)) + + gate.end(hrv) + gate.noteRejected(mode: YCBTMeasurementMode.hrv) // a late duplicate, after we gave up + XCTAssertFalse(gate.isRejected(hrv), "a retired measurement has no window left to cut short") + + let spo2 = gate.begin(mode: YCBTMeasurementMode.spo2) + XCTAssertFalse(gate.isRejected(spo2), "a fresh measurement starts clean") + } + + /// The ring accepting a start (`03 2f` → `0x00`) decodes to a plain ack, so nothing ever calls + /// `noteRejected` — the measurement runs its window out, exactly as before. Belt and braces: even the + /// mode we are running cannot abort us unless a refusal is actually reported. + func testAnAcceptedMeasurementIsNeverAborted() { + var gate = SpotMeasurementGate() + let spo2 = gate.begin(mode: YCBTMeasurementMode.spo2) + + XCTAssertFalse(gate.isRejected(spo2)) + XCTAssertEqual(gate.modesInFlight, [YCBTMeasurementMode.spo2]) + } + + // MARK: - Concurrent measurements + + /// **The wrong-cancel.** A workout HR poll is in flight when the user's BP start is refused. With one + /// shared `isRejected` flag the HR poll's abort closure — which never asked *whose* refusal it was — + /// tripped too, and the workout lost an HR reading the ring had said nothing about. A refusal must + /// abort exactly the measurement it names, and nothing else. + func testARefusalAbortsOnlyTheMeasurementItNamesWhenTwoAreInFlight() { + var gate = SpotMeasurementGate() + let hr = gate.begin(mode: YCBTMeasurementMode.heartRate) // the workout's timer poll + let bp = gate.begin(mode: YCBTMeasurementMode.bloodPressure) // the user's BP reading + + gate.noteRejected(mode: YCBTMeasurementMode.bloodPressure) + + XCTAssertTrue(gate.isRejected(bp)) + XCTAssertFalse(gate.isRejected(hr), "the ring refused BP — the workout's HR poll was never named") + } + + /// **The missed abort.** The second measurement to start must not displace the first: with one slot, + /// `begin(bp)` overwrote HR's mode, so HR's own refusal no longer matched anything and the poll spun + /// its entire window — the very failure the fast-fail exists to prevent, reintroduced by an overlap. + func testASecondMeasurementDoesNotDisplaceTheFirstsClaimOnItsMode() { + var gate = SpotMeasurementGate() + let hr = gate.begin(mode: YCBTMeasurementMode.heartRate) + let bp = gate.begin(mode: YCBTMeasurementMode.bloodPressure) + + gate.noteRejected(mode: YCBTMeasurementMode.heartRate) + + XCTAssertTrue(gate.isRejected(hr), "HR is still in flight — its refusal must still reach it") + XCTAssertFalse(gate.isRejected(bp)) + } + + /// **The premature disarm.** Whichever measurement finishes first used to call `end()` unconditionally, + /// disarming the gate for the one still running: its refusal then arrived to an empty gate and was + /// dropped. Retiring a token may only ever retire that token. + func testEndingOneMeasurementLeavesTheOtherArmed() { + var gate = SpotMeasurementGate() + let hr = gate.begin(mode: YCBTMeasurementMode.heartRate) + let bp = gate.begin(mode: YCBTMeasurementMode.bloodPressure) + + gate.end(bp) // the BP reading returns first + XCTAssertEqual(gate.modesInFlight, [YCBTMeasurementMode.heartRate]) + + gate.noteRejected(mode: YCBTMeasurementMode.heartRate) + XCTAssertTrue(gate.isRejected(hr), "HR was still mid-poll when the ring refused it") + } +} diff --git a/PulseLoopTests/YCBTDecoderTests.swift b/PulseLoopTests/YCBTDecoderTests.swift index d8cec2f0..48d8b310 100644 --- a/PulseLoopTests/YCBTDecoderTests.swift +++ b/PulseLoopTests/YCBTDecoderTests.swift @@ -209,6 +209,141 @@ final class YCBTDecoderTests: XCTestCase { XCTAssertEqual(commandId, 0x0e) } + // MARK: AppControl replies (group 0x03) — the ring's verdict on a `03 2f` start + + /// `03 2f` is answered with **one status byte and no mode**: `0x00` started, anything else refused. + /// The R99 refuses HRV (mode `0x0a` → `0x01`) because it has no HRV sensor, and we only know *which* + /// measurement was refused because the driver remembers the start it sent (`startedMode`). + func testMeasurementStartReplyDistinguishesAcceptanceFromRefusal() throws { + func reply(_ status: UInt8, startedMode: UInt8?) throws -> RingDecodedEvent { + let frame = try XCTUnwrap(YCBTFrame(validating: YCBTFrame.frame([0x03, 0x2f, status]))) + return try XCTUnwrap(decoder.decode(frame, startedMode: startedMode).first) + } + + guard case let .measurementRejected(mode) = try reply(0x01, startedMode: YCBTMeasurementMode.hrv) else { + return XCTFail("a non-zero `03 2f` status is the ring refusing to start the measurement") + } + XCTAssertEqual(mode, YCBTMeasurementMode.hrv) + + // Status 0 is the ring *starting* the sweep — the reply every working measurement gets. If this + // read as a rejection, every HR/SpO₂/BP reading would abort itself before it began. + guard case .commandAck = try reply(0x00, startedMode: YCBTMeasurementMode.hrv) else { + return XCTFail("status 0x00 is an acceptance, not a rejection") + } + + // A refusal with no start outstanding belongs to nothing (a rejected stop, a duplicate reply): + // it must not name a mode, because naming one is what cancels a measurement. + guard case .commandAck = try reply(0x01, startedMode: nil) else { + return XCTFail("a refusal that answers no start must stay an ack") + } + } + + /// The driver is the only thing that sees both directions, so it is what pairs the modeless reply with + /// the start it came from — and what makes sure a *stray* refusal can't be attributed to anything. + @MainActor + func testDriverPairsARefusalWithTheStartItSentAndOnlyOnce() { + let driver = makeDriver() + let refusal = YCBTFrame.frame([0x03, 0x2f, 0x01]) + + driver.send(start: YCBTMeasurementMode.hrv) + guard case let .measurementRejected(mode) = driver.ingest(refusal, from: commandChannel).first else { + return XCTFail("the refusal belongs to the start the driver just framed") + } + XCTAssertEqual(mode, YCBTMeasurementMode.hrv) + + // The start's one reply is spent: a duplicate or late frame answers no outstanding start. + guard case .commandAck = driver.ingest(refusal, from: commandChannel).first else { + return XCTFail("a second reply refers to no outstanding start") + } + + // A rejected *stop* has no in-flight measurement to abort — it names no mode, so it stays an ack. + driver.send(stop: YCBTMeasurementMode.hrv) + guard case .commandAck = driver.ingest(refusal, from: commandChannel).first else { + return XCTFail("a rejected stop is an ack, not a rejected measurement") + } + } + + /// **Two `03 2f` commands are routinely outstanding at once**, which is why the driver queues them + /// instead of remembering one. + /// + /// Framing happens when a command is *enqueued* (`RingBLEClient.enqueueWrite` calls `frame(_:)` on the + /// way into the serialized write queue), not when it reaches the wire — and every spot measurement ends + /// with `engine.stopX()` immediately followed by `restartWorkoutHeartRateIfActive()`, so during a + /// workout a stop and a start go into the queue back-to-back with both replies still owed. + /// + /// With one remembered mode the start overwrote the stop, the **stop's** `0x00` reply was then read as + /// the start's verdict and cleared it, and the start's real refusal decoded anonymously and was + /// swallowed — the ring said no and the user still watched the whole window run out, the exact failure + /// this path exists to kill. + @MainActor + func testAStopsReplyDoesNotConsumeTheModeOfTheStartQueuedBehindIt() { + let driver = makeDriver() + + // What `measureSpO2()` emits while a workout is streaming HR: stop SpO₂, then restart HR. + driver.send(stop: YCBTMeasurementMode.spo2) + driver.send(start: YCBTMeasurementMode.heartRate) + + // Reply 1 answers the stop. A stop names no measurement, so it can only ever be an ack … + guard case .commandAck = driver.ingest(YCBTFrame.frame([0x03, 0x2f, 0x00]), from: commandChannel).first else { + return XCTFail("the first reply answers the stop, which has no measurement to name") + } + // … and the HR start, still outstanding, keeps its mode for its own reply. + guard case let .measurementRejected(mode) = + driver.ingest(YCBTFrame.frame([0x03, 0x2f, 0x01]), from: commandChannel).first else { + return XCTFail("the second reply is the start's refusal — swallowing it is the bug") + } + XCTAssertEqual(mode, YCBTMeasurementMode.heartRate) + } + + /// The wrong-cancel half of the same pipelining bug: a **NAKed stop** must not cancel the measurement + /// started right behind it. With one slot the start's mode was the only one held, so the stop's + /// non-zero status decoded as a refusal *of the start* — aborting a measurement the ring had accepted + /// and was busy running. + @MainActor + func testANakedStopCannotCancelTheMeasurementStartedBehindIt() { + let driver = makeDriver() + + driver.send(stop: YCBTMeasurementMode.hrv) + driver.send(start: YCBTMeasurementMode.heartRate) + + // The ring NAKs the stop (status 0x02). It refuses a *stop*: nothing is cancelled. + let events = driver.ingest(YCBTFrame.frame([0x03, 0x2f, 0x02]), from: commandChannel) + guard case .commandAck = events.first else { + return XCTFail("a refused stop must never be attributed to the start queued behind it") + } + // The HR start's own reply is still owed, and still pairs with HR. + guard case let .measurementRejected(mode) = + driver.ingest(YCBTFrame.frame([0x03, 0x2f, 0x01]), from: commandChannel).first else { + return XCTFail("the start's reply is still outstanding") + } + XCTAssertEqual(mode, YCBTMeasurementMode.heartRate) + } + + /// A reconnect re-uses the driver, so commands the old link never answered must not pair with the new + /// link's replies: the fresh connection's first `03 2f` verdict would otherwise be stamped with a dead + /// command's mode. + @MainActor + func testAReconnectDropsTheCommandsTheOldLinkNeverAnswered() { + let driver = makeDriver() + driver.send(start: YCBTMeasurementMode.hrv) // the ring drops out before replying + + driver.connectionDidEnd() + driver.connectionDidStart() + + guard case .commandAck = driver.ingest(YCBTFrame.frame([0x03, 0x2f, 0x01]), from: commandChannel).first else { + return XCTFail("the new connection owes nothing to a command the old one never answered") + } + } + + /// A rejection is a verdict on a command, not data: it must never reach the metric store. It travels + /// on the raw-packet feed (whence `RingSyncCoordinator` reads it) and shows up in the debug trace. + func testARejectionProducesNoPulseEventButIsVisibleInTheDebugFeed() { + let rejected = RingDecodedEvent.measurementRejected(mode: YCBTMeasurementMode.hrv) + XCTAssertTrue(RingEventBridge.events(for: rejected).isEmpty) + XCTAssertEqual(rejected.kind, "measurement_rejected") + XCTAssertEqual(rejected.debugJSON, #"{"rejected_mode":10}"#) + } + // MARK: Command channel (be940001, group 0x02) /// `02 00` is **GetDeviceInfo** (`CMD.KEY_Get.DeviceInfo == 0`), not "status": battery state at @@ -288,4 +423,29 @@ final class YCBTDecoderTests: XCTestCase { XCTAssertEqual(YCBTBytes.u24([0x40, 0x19, 0x01], 0), 72_000) XCTAssertEqual(YCBTBytes.u24([0x00, 0x00], 0), 0, "a short buffer reads 0, never out of bounds") } + + // MARK: Driver test support + + /// Command replies come back on `be940001` — the same characteristic the app writes to. + private var commandChannel: CBUUID { CBUUID(string: YCBTUUIDs.command) } + + @MainActor + private func makeDriver() -> YCBTDriver { + YCBTDriver(writer: SilentRingWriter()) + } +} + +/// A driver needs a writer (it ACKs DevControl pushes through one); nothing in these tests reads what it +/// sends, so it drops everything. +private final class SilentRingWriter: RingCommandWriter { + nonisolated deinit {} + func enqueue(_ command: Data) {} +} + +/// The two commands `RingSyncCoordinator` actually emits, sent the way `RingBLEClient.enqueueWrite` does: +/// through `frame(_:)`, which is where the driver records that a reply is owed. +@MainActor +private extension YCBTDriver { + func send(start mode: UInt8) { _ = frame(Data([0x03, 0x2f, 0x01, mode])) } + func send(stop mode: UInt8) { _ = frame(Data([0x03, 0x2f, 0x00, mode])) } } diff --git a/docs/YCBT-Protocol.md b/docs/YCBT-Protocol.md index e9b3312c..de5c4da7 100644 --- a/docs/YCBT-Protocol.md +++ b/docs/YCBT-Protocol.md @@ -429,6 +429,26 @@ mode 0 tells the ring to stop *heart rate* — which is exactly the bug this rep 03 2f 08 00 01 02 0d 3b start SpO₂ (stop = …00 02) ``` +**The ring answers a start with a verdict — one status byte, and no mode.** `0x00` = started; anything +else is the firmware declining. The SDK never names the code (`packetAppControlHandle` falls through to +`onDataResponse(bArr[last], …)`), and because the reply doesn't echo the mode, the only way to know +*which* measurement was refused is to remember the start you sent — which is what `YCBTDriver` does, +handing the mode to the decoder so it can emit `.measurementRejected(mode:)`. + +The owner's R99 (firmware 2.32) is why this matters: it has no HRV sensor and says so four ways — a +clear `ISHASHRV` bit, `0xFC` on the HRV monitor (`01 45`), `0xFC` on the body-data history (`05 33`), +and here: + +``` +app → 03 2f 08 00 01 0a start HRV +ring → 03 2f 07 00 01 status 0x01 — refused +``` + +Treating that as an ordinary ack is what made PulseLoop poll a ring that had already said no for the +full 45-second measurement window before giving a generic failure. Observed timings for the sweeps it +*does* run, start frame → value, all on the same ring: **HR ~19 s · SpO₂ 38 s · BP ~12 s** (which is why +the SpO₂ window is 60 s and not 40 s — 38 s was landing inside a 40 s window by two seconds). + ### 5.2 Other AppControl commands PulseLoop sends ``` From aaa302c4b37e369f70e5400874a48f8e8221f546 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 17:24:52 -0400 Subject: [PATCH 4/6] Let each ring's capability bitmap decide its sensor cards instead of promising them unconditionally --- .../ColmiSmartHealthCoordinator.swift | 16 ++- PulseLoop/RingProtocol/TK5Coordinator.swift | 107 ++++++++++---- PulseLoop/RingProtocol/YCBTProtocol.swift | 26 +++- PulseLoop/Wearables/WearableCoordinator.swift | 4 +- PulseLoop/Wearables/WearableModel.swift | 6 +- PulseLoopTests/MeasurementSettingsTests.swift | 11 +- PulseLoopTests/PairingMatchingTests.swift | 136 ++++++++++++++++-- PulseLoopTests/RingIdentityMemoryTests.swift | 34 +++++ PulseLoopTests/YCBTSupportFunctionTests.swift | 62 ++++---- docs/YCBT-Protocol.md | 4 +- docs/hardware/tk5.md | 37 +++-- 11 files changed, 352 insertions(+), 91 deletions(-) diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 6a063bea..1cc68607 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -148,9 +148,14 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { /// HRV, where four independent denials said the button can never work. Left as a baseline promise, /// recorded here as untested; the first person to press it on an R99 settles it. /// - /// **`.fatigue` is deliberately absent**, unlike on the TK5. It rides the body-data record (`05 33`), - /// which the R99 answered with `0xFC` (unsupported key) — so on this ring it is not merely unnamed by - /// any bit, it is confirmed absent. Its Vitals gauge would sit permanently at "No fatigue score yet". + /// **`.fatigue` is deliberately absent, from *both* lists.** It rides the body-data record (`05 33`), + /// which the R99 answered with `0xFC` (unsupported key): on this ring it is confirmed absent, so a + /// baseline entry would leave its Vitals gauge permanently at "No fatigue score yet". It is not in + /// `bitmapGatedCapabilities` either, though it now *could* be — `YCBTSupportFunction` derives it from + /// `IS_HAS_PRESSURE`, the bit the vendor app gates the whole body-data query on (see the bit table, + /// and `TK5Coordinator`, which gates it that way). Gating it here would be a provable no-op — the R99 + /// leaves that bit clear — so it stays out until a SmartHealth-Colmi that *sets* it turns up and can + /// say whether its `body` field is real. Nothing in this family has ever produced a fatigue score. let capabilities: Set = [ .heartRate, .spo2, .spo2History, .steps, .sleep, .remSleep, .battery, .manualHeartRate, .manualSpo2, @@ -179,7 +184,10 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { /// The user pressed "Measure HRV", the ring never answered, and the app spun the full 45 s window /// before failing. Gating both on the bitmap turns a broken button into an absent one — and costs a /// ring that *does* claim HRV (the bits exist: byte 1 bit 1 and byte 23 bit 0) nothing at all. - /// `TK5Coordinator` is untouched: its HRV works, and it gates nothing. + /// + /// The TK5 took the same lesson and now gates its own sensor set — but it keeps `.hrv`/`.manualHrv` + /// as baseline, because on *that* ring HRV was observed working (48 / 79 ms, cross-checked against + /// the vendor app). Same discipline, opposite evidence, opposite conclusion; see `TK5Coordinator`. /// /// The same session settled the rest of this list on the same ring: `.bloodPressure` + /// `.manualBloodPressure` were claimed and a spot BP measurement returned 100/68 — the gate working diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 9915f1be..c0d78cb2 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -30,46 +30,97 @@ final class TK5Coordinator: WearableCoordinator { return false } - /// Everything the ring stores and `YCBTHealthRecords` decodes: live + history HR, SpO₂ (live *and* - /// the all-day `05 1A` log), day steps, HRV, blood pressure, temperature, blood sugar, the - /// deep/light/REM sleep timeline, and the in-band battery. + /// The floor: what the TK5 has been *seen* doing, plus what every YCBT ring does regardless of which + /// sensors its SKU carries. Live + history HR, SpO₂ (live *and* the all-day `05 1A` log), day steps, + /// HRV, the deep/light/REM sleep timeline, and the in-band battery. /// - /// **Stress and fatigue are ring-stored, not app-derived**: the body-data record (`05 33`) carries - /// them as the SDK's `pressure` and `body` fields, alongside VO₂max and the HRV frequency-domain - /// metrics. Temperature likewise has both a dedicated log (`05 1E`) and a field in the All record. + /// **A baseline entry is an unconditional promise**: the refinement is additive-only + /// (`WearableCoordinator.refinedCapabilities`), so the ring's own bitmap can never take one back. + /// Everything sensor-dependent therefore moved to `bitmapGatedCapabilities` — see there for the R99 + /// session that is the reason why. /// - /// `manualHeartRate` / `manualSpo2` / `manualHrv` / `manualBloodPressure` surface the "Measure now" - /// buttons in Vitals: a spot reading toggles the live `03 2f` stream on in the metric's own mode, - /// collects the first good sample from the `06 01` (HR) / `06 02` (SpO₂) / `06 03` (BP *and* HRV) - /// frames, then toggles the same mode off. All four ride the one stream, so any of them can be - /// measured on demand — one at a time. + /// **`.hrv` / `.manualHrv` stay here, and they are the one sensor that earns it.** The TK5's HRV is + /// not an SDK inference: it was *observed on this ring*, values (48 / 79 ms) cross-checked against + /// what the vendor app showed for the same session. That is the strongest evidence class we have — + /// stronger than any bit — and it is exactly the opposite of the R99, whose HRV was denied four + /// independent ways. Gating it could only ever *lose* it: we have never captured the TK5's `02 01` + /// reply, so we do not know its length, and `.manualHrv` lives at byte 23 behind the SDK's 24-byte + /// block gate. A firmware that answers with a legal shorter array would silently delete a "Measure + /// HRV" button that demonstrably works. Deleting a working feature is a worse bug than the one being + /// fixed here. (If a TK5 bitmap ever turns up with `ISHASHRV` set, gating becomes a no-op and can be + /// done freely; if it turns up *clear*, we have a firmware under-reporting a sensor it provably has — + /// which is evidence against trusting that bitmap, not for it.) + /// + /// Three more entries look gate-able and deliberately are not: + /// + /// - `.spo2History` — the all-day `05 1A` log. No bit names it: it is one of the SpO₂ sources + /// `ISHASBLOODOXYGEN` already grants, not a separate sensor. A ring without the log answers the + /// query with a no-data header or `0xFC`, which `YCBTHistoryTransfer` skips permanently. + /// - `.remSleep` — a stage tag (`3`) *inside* the `05 04` timeline `ISHASSLEEP` grants. Same shape. + /// Gating either would not defer the decision, it would make them permanently unreachable, because + /// no bit can ever satisfy the gate (`PairingMatchingTests`). + /// - `.findDevice` — a bit *does* name it (`ISHASFINDDEVICE`, byte 6 bit 4), but nobody has pressed + /// Find Ring on a TK5, so we have neither a working buzz nor a refusal, and gating it would + /// *remove* a button that may well work. Left as a baseline promise on no evidence either way — + /// the same call `ColmiSmartHealthCoordinator` makes, for the same reason. + /// + /// `manualHeartRate` / `manualSpo2` / `manualHrv` surface the "Measure now" buttons in Vitals: a spot + /// reading toggles the live `03 2f` stream on in the metric's own mode, collects the first good + /// sample from the `06 01` (HR) / `06 02` (SpO₂) / `06 03` (HRV) frames, then toggles the same mode + /// off. (`manualBloodPressure` rides the same stream — it is gated below only because the *sensor* + /// is.) /// /// `measurementInterval` surfaces the Measurement-settings screen: the ring's five `01 xx /// {enable, interval}` monitors map 1:1 onto it (HR `01 0C`, BP `01 1C`, temperature `01 20`, - /// SpO₂ `01 26`, HRV `01 45`), and the interval is floored at the firmware's 30-minute minimum. - /// - /// This is the *decodable* set, not a per-unit truth: firmware variants differ, and which history - /// types actually return records is what the on-device checkpoint establishes (which is also why - /// `supportLevel` stays `.limited` — see `docs/hardware/tk5.md`). + /// SpO₂ `01 26`, HRV `01 45`), and the interval is floored at the firmware's 30-minute minimum. It is + /// a settings screen, not a sensor: a ring that doesn't implement one of the five NAKs that one write + /// (the R99 NAKed two with `0xFC` and honoured three), so it stays baseline. let capabilities: Set = [ - .heartRate, .spo2, .spo2History, .steps, .battery, .hrv, .bloodPressure, - .temperature, .stress, .fatigue, .bloodSugar, + .heartRate, .spo2, .spo2History, .steps, .battery, + .hrv, .manualHrv, .sleep, .remSleep, - .manualHeartRate, .manualSpo2, .manualHrv, .manualBloodPressure, + .manualHeartRate, .manualSpo2, .realtimeHeartRate, .realtimeSteps, .findDevice, .measurementInterval, ] - /// The TK5 keeps its static set: nothing above is bitmap-gated, so its `02 01` reply can neither add - /// nor remove a capability. That is deliberate — there is one TK5 SKU and it is the ring we have on - /// the bench, so gating it would trade a known-good capability set for a runtime dependency on a - /// parser no hardware had yet exercised. + /// The per-SKU sensors: offered only if this unit's `02 01` bitmap claims them. + /// + /// ## Why the TK5 stopped claiming these unconditionally: the R99 session /// - /// The handshake still *requests* the bitmap and `YCBTSupportFunction` still parses it, so every TK5 - /// session prints the decoded claim to the debug feed. That is the point: it validates the bit table - /// against real hardware, at zero behavioural risk, before the Colmi family — whose SKUs genuinely - /// differ on which sensors they carry — starts depending on it. - let bitmapGatedCapabilities: Set = [] + /// These four (five, with on-demand BP) used to be baseline here — added in A3 because the *SDK* + /// defines their record types (`05 1E` temperature, `05 33` body data, `05 2F` comprehensive), not + /// because a TK5 was ever seen producing one. This project's own earlier TK5 notes said the opposite: + /// *"skin temperature and stress aren't decoded at all — no capture contains the data."* An + /// SDK-defined record type is a statement about the *protocol*, never about the silicon in a + /// particular ring. + /// + /// The sibling family then paid for exactly that reasoning. `ColmiSmartHealthCoordinator` baselined + /// `.hrv`/`.manualHrv` on the same grounds; the owner's `R99 54DC` denied HRV four independent ways + /// (bitmap bit clear, `01 45` → `0xFC`, `05 33` → `0xFC`, `03 2f` mode `0x0a` → refusal), the owner + /// pressed "Measure HRV", and the app spun the full 45 s window before failing. The *same* session + /// showed the gate working as designed in both directions: BP was claimed and a spot reading returned + /// 100/68, while temperature, stress and blood sugar were not claimed and correctly never appeared. + /// + /// So the TK5's unconditional claims are no longer trusted either. It is a `.limited`-support ring + /// whose sensor complement nobody has confirmed on hardware, and an unfilled card with a dead + /// "Measure now" button is worse than an absent one. The bitmap costs a ring that *does* have these + /// sensors nothing: it claims them, and it gets them. + /// + /// ## `.fatigue` is gated on the stress bit + /// + /// There is no `ISHASFATIGUE` — not in the SDK, not in the app (`HomeFragmentModelUtil.checkedFunction` + /// has no fatigue card at all). But fatigue is not ungatable: it is the `body` field of the body-data + /// record (`05 33`), whose *whole query* the vendor app gates on `IS_HAS_PRESSURE` + /// (`DataSyncUtils`: `if (isSupportFunction(IS_HAS_PRESSURE)) add(Health_History_Body_Data)`) — the + /// same bit that gates stress, the `pressure` field of the same record. One record, one bit, two + /// fields: a ring with the bit clear is never asked for the record, so it has neither score. That is + /// what `YCBTSupportFunction` now derives, and it is why `.fatigue` is here rather than dropped — + /// gating it defers the decision to the ring instead of guessing for it. + let bitmapGatedCapabilities: Set = [ + .temperature, .bloodPressure, .manualBloodPressure, + .stress, .fatigue, .bloodSugar, + ] let iconSystemName = "circle.circle.fill" diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index e1abe1ae..cd03080a 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -437,6 +437,24 @@ enum YCBTSupportFunction { Bit(byte: 17, bit: 3, minLength: 18, capability: .bloodSugar), // ISHASBLOODSUGAR Bit(byte: 22, bit: 6, minLength: 23, capability: .stress), // IS_HAS_PRESSURE + // **Fatigue rides the stress bit** — the one entry where two capabilities share a bit, because + // the *ring* gives them one switch. There is no `ISHASFATIGUE` anywhere in the SDK (no `FATIGUE`, + // no `TIRED`, no 疲劳), and the vendor app has no fatigue home card for `checkedFunction` to gate. + // What it has instead is `DataSyncUtils.syncData`, which gates the **whole body-data history + // query** — `Health_History_Body_Data`, our `05 33` — on `IS_HAS_PRESSURE` and nothing else: + // + // if (YCBTClient.isSupportFunction(FunctionConstant.IS_HAS_PRESSURE)) { + // arrayList.add(DATATYPE.Health_History_Body_Data); + // } + // + // Stress (`pressureInteger`) and fatigue (`bodyInteger`) are two fields of that one record + // (`DataUnpack.unpackBodyData`), so a ring with the bit clear is never even *asked* for the + // record — the vendor app can no more show a fatigue score on it than a stress one. Deriving both + // from byte 22 bit 6 reproduces exactly that, and is what lets `.fatigue` be gated at all: an + // ungated `.fatigue` is an unconditional promise (the R99 answered `05 33` with `0xFC`), and a + // gate no bit can satisfy is a dead one (`PairingMatchingTests`). + Bit(byte: 22, bit: 6, minLength: 23, capability: .fatigue), // IS_HAS_PRESSURE (same record) + // Find-my-ring. `DeviceSupportFunctionUtil.isHasFindDevice` reads it, and `MeAntiLostActivity` // hides the whole screen without it. Bit(byte: 6, bit: 4, minLength: 14, capability: .findDevice), // ISHASFINDDEVICE @@ -461,9 +479,11 @@ enum YCBTSupportFunction { // power-off command, so a derived `.factoryReset`/`.powerOff` could never be honoured. // • IS_HAS_BATTERY_INFO_UPLOAD (22.5) — declares the unsolicited `06 15` push, not whether battery is // readable. Battery is in-band on `02 00` for every YCBT ring, so `.battery` stays a baseline. - // • `.remSleep`, `.spo2History`, `.fatigue` — no bit names them. They are sub-features of bits that - // don't distinguish them (REM-ness of the sleep timeline, the all-day `05 1A` SpO₂ log, the - // body-data record's fatigue field), so the family's baseline stays the only source for them. + // • `.remSleep`, `.spo2History` — no bit names them, and no *caller* gates them separately either. + // They are sub-features of bits that don't distinguish them: REM is a stage tag inside the `05 04` + // timeline `ISHASSLEEP` already grants, and the all-day `05 1A` log is one of the SpO₂ sources + // `ISHASBLOODOXYGEN` grants. A family's baseline is therefore the only source for them — and, + // unlike `.fatigue` above, there is nothing to defer to: no bit's *behaviour* implies them. // • `.measurementInterval` — IS_HAS_INDEPENDENT_AUTOMATIC_TIME_MEASUREMENT (20.5) is the nearest, but // "independent" is unpinned by any caller: it may mean "has per-metric intervals" or "has an // interval at all", and those gate different screens. diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index 35abc046..955fffc2 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -74,8 +74,8 @@ protocol WearableCoordinator { extension WearableCoordinator { var displayName: String { Self.deviceType.displayName } - /// Nothing is bitmap-gated unless a family opts in, so jring / QRing-Colmi / TK5 keep their static - /// sets and never consult a bitmap. + /// Nothing is bitmap-gated unless a family opts in, so jring / QRing-Colmi — neither of which speaks + /// YCBT, and so has no bitmap to consult — keep their static sets. Both YCBT families opt in. var bitmapGatedCapabilities: Set { [] } /// Fold a device-reported capability bitmap into this family's capability set. diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 9204917c..ea56991c 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -164,10 +164,12 @@ extension WearableModel { // TK5 — the YCBT protocol (be940 service, SmartHealth app), shared with the SmartHealth-flavoured // Colmi rings. Advertises as "TK5 <4 hex>", which is unambiguous, so it needs no app-variant picker. - // Blurb mirrors `TK5Coordinator.capabilities`; the driver is `.limited` (see `supportLevel`). + // Blurb mirrors `TK5Coordinator.capabilities` — the *baseline*, so it no longer promises BP: like the + // rest of the TK5's per-SKU sensors, on-demand BP is now claimed from the ring's own capability + // bitmap at connect time. The driver is `.limited` (see `supportLevel`). static let tk5 = WearableModel( id: "tk5", displayName: "TK5", brand: "TK", family: .tk5, - tint: PulseColors.spo2, blurb: "HR · SpO₂ · HRV · BP · Sleep", + tint: PulseColors.spo2, blurb: "HR · SpO₂ · HRV · Sleep · Steps", advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: "tk5" ) diff --git a/PulseLoopTests/MeasurementSettingsTests.swift b/PulseLoopTests/MeasurementSettingsTests.swift index 590c69c2..1c03dde7 100644 --- a/PulseLoopTests/MeasurementSettingsTests.swift +++ b/PulseLoopTests/MeasurementSettingsTests.swift @@ -70,9 +70,18 @@ final class MeasurementSettingsTests: XCTestCase { /// SmartHealth's own BP screen sends (`appStartMeasurement(1, 1)`); the reading streams back on /// `06 03`. The note here used to claim the TK5 had no on-demand BP command. Colmi has no BP sensor /// at all. + /// + /// The TK5's BP is a *command* the stack has and a *sensor* nobody has confirmed on the ring, so it + /// is bitmap-gated (`ISHASBLOOD` / `ISHASTESTBLOOD`) rather than promised: what this asserts is that + /// the family can reach it at all, i.e. that a TK5 which claims the bits gets the button. func testManualBloodPressureRequiresALiveBPMode() { XCTAssertTrue(JringCoordinator().capabilities.contains(.manualBloodPressure)) - XCTAssertTrue(TK5Coordinator().capabilities.contains(.manualBloodPressure)) + let tk5 = TK5Coordinator() + XCTAssertTrue(tk5.bitmapGatedCapabilities.contains(.manualBloodPressure)) + XCTAssertTrue( + tk5.refinedCapabilities(bitmapDerived: [.bloodPressure, .manualBloodPressure]) + .contains(.manualBloodPressure) + ) XCTAssertFalse(ColmiCoordinator().capabilities.contains(.manualBloodPressure)) XCTAssertFalse(ColmiCoordinator().capabilities.contains(.bloodPressure)) } diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 74c64a3a..4e9b3322 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -403,12 +403,18 @@ final class PairingMatchingTests: XCTestCase { // MARK: - SmartHealth-Colmi capabilities (B3) /// The two YCBT families drive one shared stack, so this family can only ever claim what the stack - /// implements — i.e. a subset of the (hardware-exercised) TK5's set. A capability outside that is a - /// card that can never fill. + /// implements — i.e. a subset of everything the TK5 could ever resolve to. A capability outside that + /// is a card that can never fill. + /// + /// "Could ever resolve to" is baseline ∪ gated on both sides now: the TK5 gates its own sensors too, + /// so comparing against its *baseline* alone would say the shared stack cannot deliver temperature — + /// which it plainly can, for either family, the moment a ring claims the bit. func testSmartHealthColmiClaimsNothingTheYCBTStackCannotDeliver() { let colmi = ColmiSmartHealthCoordinator() + let tk5 = TK5Coordinator() let everythingItCouldClaim = colmi.capabilities.union(colmi.bitmapGatedCapabilities) - XCTAssertTrue(everythingItCouldClaim.isSubset(of: TK5Coordinator().capabilities)) + let everythingTheStackDelivers = tk5.capabilities.union(tk5.bitmapGatedCapabilities) + XCTAssertTrue(everythingItCouldClaim.isSubset(of: everythingTheStackDelivers)) // It must not inherit jring-only or QRing-only actions the YCBT stack has no command for. for absent: WearableCapability in [.combinedVitalsMeasurement, .powerOff, .factoryReset] { XCTAssertFalse(everythingItCouldClaim.contains(absent), absent.rawValue) @@ -416,13 +422,23 @@ final class PairingMatchingTests: XCTestCase { } /// A gate no bit can ever satisfy is not a deferred decision — it is a dead promise: it reads as - /// "supported if the ring says so" while being permanently unreachable. Every gated capability must - /// be derivable from the bitmap parser. + /// "supported if the ring says so" while being permanently unreachable. Every gated capability, in + /// *either* YCBT family, must be derivable from the bitmap parser. + /// + /// This is the invariant that shaped the `.fatigue` decision: it has no bit of its own, so it could + /// only be gated once `YCBTSupportFunction` derived it from the bit the vendor app actually gates its + /// record on (`IS_HAS_PRESSURE` — the whole `05 33` body-data query). Baseline entries are exempt: + /// they are promises we make on our own evidence, not deferrals to the ring. func testEveryGatedCapabilityIsDerivableFromTheBitmap() { let allOnes = [UInt8](repeating: 0xFF, count: 32) let derivable = YCBTSupportFunction.capabilities(from: allOnes) - XCTAssertFalse(ColmiSmartHealthCoordinator().bitmapGatedCapabilities.isEmpty) - XCTAssertTrue(ColmiSmartHealthCoordinator().bitmapGatedCapabilities.isSubset(of: derivable)) + for coordinator in [ColmiSmartHealthCoordinator(), TK5Coordinator()] as [any WearableCoordinator] { + let gated = coordinator.bitmapGatedCapabilities + XCTAssertFalse(gated.isEmpty, "\(type(of: coordinator)) gates nothing") + XCTAssertTrue(gated.isSubset(of: derivable), "\(type(of: coordinator)): \(gated.subtracting(derivable))") + // A gated capability the baseline already grants would be a no-op wearing a gate's clothes. + XCTAssertTrue(coordinator.capabilities.isDisjoint(with: gated), "\(type(of: coordinator))") + } } /// The `02 01` bitmap the owner's `R99 54DC` (firmware 2.32) actually sent — 60 bytes, of which the @@ -469,23 +485,117 @@ final class PairingMatchingTests: XCTestCase { } } - /// **The TK5 is untouched by the R99 fix.** Its HRV is confirmed working from its own captures, and it - /// gates nothing — so it must still declare HRV as an unconditional baseline capability even when fed - /// the R99's own HRV-denying bitmap. (The R99 needed gating precisely because a family is not a SKU; - /// the TK5 family has one SKU, and it is the ring on the bench.) + /// **The TK5 keeps its HRV — and only its HRV — through the R99 fix.** + /// + /// The R99 taught the TK5 the same lesson, but not the same conclusion, because the evidence points + /// the other way. The TK5's HRV was *observed on the ring* (48 / 79 ms, cross-checked against the + /// vendor app); the R99's was denied four independent ways. So HRV stays an unconditional baseline + /// promise on the TK5 even when fed the R99's own HRV-denying bitmap — a bitmap can never remove a + /// baseline capability, which is exactly what makes a baseline entry the place for something we have + /// *seen work*. (It also could only lose: no TK5 `02 01` reply has ever been captured, and + /// `.manualHrv` sits at byte 23 behind the SDK's 24-byte block gate.) + /// + /// What the TK5 no longer promises unconditionally is the sensors nobody has seen it use. func testTK5KeepsItsHRVThroughTheR99Fix() { let tk5 = TK5Coordinator() XCTAssertTrue(tk5.capabilities.isSuperset(of: [.hrv, .manualHrv])) - XCTAssertTrue(tk5.bitmapGatedCapabilities.isEmpty) + XCTAssertTrue(tk5.bitmapGatedCapabilities.isDisjoint(with: [.hrv, .manualHrv])) let refined = tk5.refinedCapabilities( bitmapDerived: YCBTSupportFunction.capabilities(from: r99SupportBitmap) ) - XCTAssertEqual(refined, tk5.capabilities) XCTAssertTrue(refined.contains(.hrv)) XCTAssertTrue(refined.contains(.manualHrv)) } + /// **A TK5 that claims nothing gets its baseline, and not one sensor more.** The bug this fixes: the + /// TK5 baselined `.temperature`, `.stress`, `.fatigue` and `.bloodSugar` because the *SDK* defines + /// their record types (`05 1E`, `05 33`, `05 2F`) — not because a TK5 was ever seen producing one. A + /// baseline entry is an unconditional promise, so those four rendered cards, and "Measure now" + /// buttons, that may never fill. The R99 is what that costs when the ring disagrees. + /// + /// A ring that claims nothing — an all-zero bitmap, a truncated reply, or no reply at all — now + /// resolves to exactly the TK5's baseline, and not one sensor more. + func testTK5WithASensorDenyingBitmapResolvesToItsBaselineOnly() { + let tk5 = TK5Coordinator() + let expectedBaseline: Set = [ + .heartRate, .spo2, .spo2History, .steps, .battery, + .hrv, .manualHrv, + .sleep, .remSleep, + .manualHeartRate, .manualSpo2, + .realtimeHeartRate, .realtimeSteps, + .findDevice, .measurementInterval, + ] + XCTAssertEqual(tk5.capabilities, expectedBaseline) + + let claimsNothing = YCBTSupportFunction.capabilities(from: [UInt8](repeating: 0, count: 27)) + XCTAssertEqual(claimsNothing, []) + for derived in [Set(), claimsNothing] { + let refined = tk5.refinedCapabilities(bitmapDerived: derived) + XCTAssertEqual(refined, expectedBaseline) + for absent: WearableCapability in [ + .temperature, .stress, .fatigue, .bloodSugar, .bloodPressure, .manualBloodPressure, + ] { + XCTAssertFalse(refined.contains(absent), "an unclaimed \(absent.rawValue) must not be offered") + } + } + } + + /// The gate is per-bit, not all-or-nothing — so a *partly* claiming ring gets exactly the parts it + /// claimed. Fed the R99's real bytes (the only YCBT bitmap we have from hardware): it claims BP and + /// on-demand BP — and a spot BP on that ring really did return 100/68 — while staying silent about + /// temperature, stress, fatigue and blood sugar, which are therefore withheld. + func testTK5WithThePartlyClaimingR99BitmapGetsOnlyWhatWasClaimed() { + let tk5 = TK5Coordinator() + let refined = tk5.refinedCapabilities( + bitmapDerived: YCBTSupportFunction.capabilities(from: r99SupportBitmap) + ) + XCTAssertEqual(refined, tk5.capabilities.union([.bloodPressure, .manualBloodPressure])) + for absent: WearableCapability in [.temperature, .stress, .fatigue, .bloodSugar] { + XCTAssertFalse(refined.contains(absent), "an unclaimed \(absent.rawValue) must not be offered") + } + } + + /// …and a TK5 whose bitmap claims the sensors gets them — every one of them, so nothing the ring + /// really has was lost in the move. The union is the *old* unconditional set exactly. + func testTK5WithASensorClaimingBitmapResolvesToTheFullSet() { + let tk5 = TK5Coordinator() + let claimsEverything = YCBTSupportFunction.capabilities(from: [UInt8](repeating: 0xFF, count: 27)) + let refined = tk5.refinedCapabilities(bitmapDerived: claimsEverything) + + XCTAssertEqual(refined, [ + .heartRate, .spo2, .spo2History, .steps, .battery, + .hrv, .manualHrv, .bloodPressure, .manualBloodPressure, + .temperature, .stress, .fatigue, .bloodSugar, + .sleep, .remSleep, + .manualHeartRate, .manualSpo2, + .realtimeHeartRate, .realtimeSteps, + .findDevice, .measurementInterval, + ]) + XCTAssertEqual(refined, tk5.capabilities.union(tk5.bitmapGatedCapabilities)) + } + + /// **`.fatigue` is gated on the stress bit, because the ring gives them one switch.** There is no + /// `ISHASFATIGUE` in the SDK; what there is, is `DataSyncUtils` gating the *entire* body-data query + /// (`05 33` — the record that carries both scores, as its `pressure` and `body` fields) on + /// `IS_HAS_PRESSURE`. A ring with that bit clear is never even asked for the record, so it can no more + /// produce a fatigue score than a stress one. The two therefore arrive and depart together. + func testFatigueAndStressAreClaimedTogetherOrNotAtAll() { + let tk5 = TK5Coordinator() + var bodyDataRing = [UInt8](repeating: 0, count: 27) + bodyDataRing[22] = 1 << 6 // IS_HAS_PRESSURE + + let claimed = YCBTSupportFunction.capabilities(from: bodyDataRing) + XCTAssertEqual(claimed, [.stress, .fatigue]) + + let refined = tk5.refinedCapabilities(bitmapDerived: claimed) + XCTAssertEqual(refined, tk5.capabilities.union([.stress, .fatigue])) + // Never one without the other, whichever way the bit falls. + XCTAssertEqual(refined.contains(.stress), refined.contains(.fatigue)) + let silent = tk5.refinedCapabilities(bitmapDerived: []) + XCTAssertEqual(silent.contains(.stress), silent.contains(.fatigue)) + } + /// The gated set resolves through B2's additive-only refinement formula: a silent ring keeps the /// baseline, a claiming ring gains exactly what it claimed, and nothing outside the pre-approved /// list can ever be added. diff --git a/PulseLoopTests/RingIdentityMemoryTests.swift b/PulseLoopTests/RingIdentityMemoryTests.swift index 943bca4e..5dcb6609 100644 --- a/PulseLoopTests/RingIdentityMemoryTests.swift +++ b/PulseLoopTests/RingIdentityMemoryTests.swift @@ -86,6 +86,40 @@ final class RingIdentityMemoryTests: XCTestCase { XCTAssertFalse(client.activeCapabilities.contains(.temperature)) } + /// The TK5 gates its sensors now, so it needs the same two guarantees the R99 got — and this is the + /// one that protects an existing owner. + /// + /// **A TK5 whose bitmap already told us about a sensor keeps it**, even on a connect whose `02 01` + /// reply is lost or answered short: `installDriver` seeds from the remembered *refined* set, through + /// the same additive-only refinement, so a temperature card the ring earned does not blink out and + /// come back a second later on every launch. + /// + /// **And a first-ever TK5 connect that never sees a bitmap degrades to the baseline, not to nothing** + /// — HR, SpO₂, HRV, sleep and steps still work; only the unconfirmed sensors are withheld. + func testTK5CapabilityMemorySurvivesAConnectWithNoBitmap() { + let earnedTemperature = TK5Coordinator().capabilities.union([.temperature]) + remember(capabilities: earnedTemperature) + let client = RingBLEClient(startManager: false) + + client.installDriver(TK5Coordinator.self) // no `.supportFunctions` follows + + XCTAssertEqual(client.activeCapabilities, earnedTemperature) + XCTAssertTrue(client.activeCapabilities.contains(.temperature)) + } + + func testAFirstTK5ConnectWithNoBitmapDegradesToTheBaselineNotToNothing() { + remember() + let client = RingBLEClient(startManager: false) + + client.installDriver(TK5Coordinator.self) + + XCTAssertEqual(client.activeCapabilities, TK5Coordinator().capabilities) + XCTAssertTrue(client.activeCapabilities.isSuperset(of: [.heartRate, .spo2, .hrv, .sleep, .steps])) + for withheld: WearableCapability in [.temperature, .stress, .fatigue, .bloodSugar, .bloodPressure] { + XCTAssertFalse(client.activeCapabilities.contains(withheld)) + } + } + // MARK: - The name that reaches the store private func identified(_ advertisedName: String?) -> PulseEvent { diff --git a/PulseLoopTests/YCBTSupportFunctionTests.swift b/PulseLoopTests/YCBTSupportFunctionTests.swift index 4c708a86..be428d78 100644 --- a/PulseLoopTests/YCBTSupportFunctionTests.swift +++ b/PulseLoopTests/YCBTSupportFunctionTests.swift @@ -25,31 +25,36 @@ final class YCBTSupportFunctionTests: XCTestCase { // MARK: - Bit → capability mapping - /// Each mapped bit, in isolation, yields exactly its own capability and nothing else. This is the - /// table's regression net: a transposed byte or bit index shows up here as a wrong capability. + /// Each mapped bit, in isolation, yields exactly its own capability (or capabilities) and nothing + /// else. This is the table's regression net: a transposed byte or bit index shows up here as a wrong + /// capability. + /// + /// `IS_HAS_PRESSURE` is the one bit that yields **two**, because the ring gives them one switch: the + /// vendor app gates the entire body-data query (`05 33`) on it, and stress (`pressure`) and fatigue + /// (`body`) are two fields of that one record. func testEachMappedBitYieldsExactlyItsCapability() { - let expected: [(byte: Int, bit: Int, capability: WearableCapability)] = [ - (0, 7, .steps), // ISHASSTEPCOUNT - (0, 6, .sleep), // ISHASSLEEP - (0, 3, .heartRate), // ISHASHEARTRATE - (0, 0, .bloodPressure), // ISHASBLOOD - (1, 3, .spo2), // ISHASBLOODOXYGEN - (1, 1, .hrv), // ISHASHRV - (6, 4, .findDevice), // ISHASFINDDEVICE - (8, 0, .temperature), // ISHASTEMP - (15, 1, .manualHeartRate), // ISHATESTHEART - (15, 2, .manualBloodPressure),// ISHASTESTBLOOD - (15, 3, .manualSpo2), // ISHASTESTSPO2 - (17, 3, .bloodSugar), // ISHASBLOODSUGAR - (22, 6, .stress), // IS_HAS_PRESSURE - (23, 0, .manualHrv), // IS_HAS_HRV_MEASUREMENT + let expected: [(byte: Int, bit: Int, capabilities: Set)] = [ + (0, 7, [.steps]), // ISHASSTEPCOUNT + (0, 6, [.sleep]), // ISHASSLEEP + (0, 3, [.heartRate]), // ISHASHEARTRATE + (0, 0, [.bloodPressure]), // ISHASBLOOD + (1, 3, [.spo2]), // ISHASBLOODOXYGEN + (1, 1, [.hrv]), // ISHASHRV + (6, 4, [.findDevice]), // ISHASFINDDEVICE + (8, 0, [.temperature]), // ISHASTEMP + (15, 1, [.manualHeartRate]), // ISHATESTHEART + (15, 2, [.manualBloodPressure]),// ISHASTESTBLOOD + (15, 3, [.manualSpo2]), // ISHASTESTSPO2 + (17, 3, [.bloodSugar]), // ISHASBLOODSUGAR + (22, 6, [.stress, .fatigue]), // IS_HAS_PRESSURE — the whole `05 33` record + (23, 0, [.manualHrv]), // IS_HAS_HRV_MEASUREMENT ] for entry in expected { let payload = bitmap(length: 27, set: [(entry.byte, entry.bit)]) XCTAssertEqual( YCBTSupportFunction.capabilities(from: payload), - [entry.capability], - "byte \(entry.byte) bit \(entry.bit) should map to \(entry.capability) alone" + entry.capabilities, + "byte \(entry.byte) bit \(entry.bit) should map to \(entry.capabilities) alone" ) } } @@ -75,7 +80,7 @@ final class YCBTSupportFunctionTests: XCTestCase { YCBTSupportFunction.capabilities(from: allOnes), [ .steps, .sleep, .heartRate, .bloodPressure, .spo2, .hrv, .findDevice, .temperature, - .bloodSugar, .stress, + .bloodSugar, .stress, .fatigue, .manualHeartRate, .manualBloodPressure, .manualSpo2, .manualHrv, ] ) @@ -143,10 +148,14 @@ final class YCBTSupportFunctionTests: XCTestCase { ) } - /// The same block rule at the far end of the array: stress needs 23 bytes, manual-HRV needs 24. + /// The same block rule at the far end of the array: stress (and, on the same bit, fatigue) needs + /// 23 bytes, manual-HRV needs 24. func testTrailingBlocksNeedTheirOwnLengths() { XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 22, set: [(22, 6)])), []) - XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(22, 6)])), [.stress]) + XCTAssertEqual( + YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(22, 6)])), + [.stress, .fatigue] + ) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(23, 0)])), []) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 24, set: [(23, 0)])), [.manualHrv]) } @@ -202,11 +211,12 @@ final class YCBTSupportFunctionTests: XCTestCase { XCTAssertEqual(refined, [.heartRate, .steps, .battery]) } - /// A family that gates nothing is bit-for-bit unaffected by any bitmap — the zero-regression - /// property that keeps jring, QRing-Colmi and the TK5 out of this feature's blast radius. + /// A family that gates nothing is bit-for-bit unaffected by any bitmap — the zero-regression property + /// that keeps jring and QRing-Colmi out of this feature's blast radius. (Neither speaks YCBT, so + /// neither has a bitmap to consult in the first place; both YCBT families now gate.) @MainActor func testFamilyThatGatesNothingIsUnaffectedByAnyBitmap() { - for coordinator in [TK5Coordinator(), JringCoordinator(), ColmiCoordinator()] as [any WearableCoordinator] { + for coordinator in [JringCoordinator(), ColmiCoordinator()] as [any WearableCoordinator] { XCTAssertTrue(coordinator.bitmapGatedCapabilities.isEmpty) let refined = coordinator.refinedCapabilities(bitmapDerived: YCBTSupportFunction.capabilities(from: allOnes)) XCTAssertEqual(refined, coordinator.capabilities, "\(type(of: coordinator)) must ignore the bitmap") @@ -234,7 +244,7 @@ final class YCBTSupportFunctionTests: XCTestCase { guard case let .supportFunctions(claimed) = events.first else { return XCTFail("expected .supportFunctions, got \(events)") } - XCTAssertEqual(claimed, [.heartRate, .spo2, .stress]) + XCTAssertEqual(claimed, [.heartRate, .spo2, .stress, .fatigue]) } /// The `02 1b` reply reaches the debug feed with its value (`InnerUtils.isJieLiChipScheme`: 3/4/5). diff --git a/docs/YCBT-Protocol.md b/docs/YCBT-Protocol.md index de5c4da7..3956d111 100644 --- a/docs/YCBT-Protocol.md +++ b/docs/YCBT-Protocol.md @@ -26,7 +26,7 @@ only how a ring announces itself and which sensors it happens to carry: |---|---|---| | **Advertised name** | `TK5 <4 hex>` — unambiguous, so it auto-detects | a Colmi-line name (`R09_ABCD`, `COLMI R10_…`) — **collides with QRing-Colmi**, so the app *asks* (see below) | | **Manufacturer data** | prefix `10786501` (captured) | expected to contain the `1078` product code; ⚠️ **no capture yet** | -| **SupportFunction bitmap** (`02 01`, §2) | parsed and logged, but **not** used to gate: one SKU, on the bench | **gates the per-SKU sensors** — temperature, blood pressure, stress, blood sugar and on-demand BP are claimed only if this unit's bitmap sets their bit | +| **SupportFunction bitmap** (`02 01`, §2) | **gates the per-SKU sensors** — temperature, BP (incl. on-demand), stress, fatigue, blood sugar. HRV is *not* gated: it was observed working on a TK5 (48 / 79 ms), and hardware evidence outranks a bit | **gates the per-SKU sensors** — temperature, BP (incl. on-demand), stress, blood sugar, **and HRV**, which the owner's R99 denies four ways | | **chipScheme** (`02 1b`) | JieLi (3–5), inferred from the `AE00` service | ❓ unknown; only selects the OTA stack, which PulseLoop does not implement (§9) | | **Support level** | 🧪 Limited | 🧪 Limited — never connected | @@ -156,7 +156,7 @@ real settings (`YCBTEncoder.startupSequence`). |---:|---|---| | 1 | `01 00` `[year:u16][mon][day][hh][mm][ss][weekday]` | Set clock. **Weekday is Mon = 0 … Sun = 6.** | | 2 | `02 00` `47 43` | GetDeviceInfo → battery + firmware come back in the reply | -| 3 | `02 01` `47 46` | GetSupportFunction → capability bitmap. The reply is a bit-per-feature map (`ISHASTEMP`, `ISHASBLOOD` = blood *pressure*, `IS_HAS_PRESSURE` = *stress*, …) parsed by `YCBTSupportFunction`. It can only **add** capabilities a family has pre-approved, never remove one — which is how the SmartHealth-Colmi's per-SKU sensors are claimed (§0) while the TK5 keeps its static set | +| 3 | `02 01` `47 46` | GetSupportFunction → capability bitmap. The reply is a bit-per-feature map (`ISHASTEMP`, `ISHASBLOOD` = blood *pressure*, `IS_HAS_PRESSURE` = *stress* — and, on that same bit, *fatigue*: it gates the whole `05 33` body-data query the two scores share) parsed by `YCBTSupportFunction`. It can only **add** capabilities a family has pre-approved, never remove one — which is how **both** YCBT families claim their per-SKU sensors (§0) | | 4 | `02 1b` | GetChipScheme (JieLi vs Nordic vs Realtek — selects the OTA stack; see §8) | | 5 | `02 03` `47 50` | GetDeviceName | | 6 | `02 07` `43 46` | GetUserConfig | diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 1e6c7e1b..5c5b020b 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -39,7 +39,7 @@ differ in exactly three ways: | | TK5 | SmartHealth-Colmi | |---|---|---| | **Advertisement** | `TK5 <4 hex>` + manufacturer prefix `10786501` — unambiguous, so it auto-detects | a Colmi-line name a *QRing* Colmi can advertise too, so PulseLoop asks the user which app their ring came with | -| **SupportFunction bitmap** (`02 01`) | parsed and logged, but nothing is gated on it — one SKU, and it's the ring on the bench | **gates the per-SKU sensors**: temperature, BP, stress, blood sugar | +| **SupportFunction bitmap** (`02 01`) | **gates the per-SKU sensors**: temperature, BP, stress, fatigue, blood sugar. HRV is *not* gated — it was observed working on this ring | **gates the per-SKU sensors**: temperature, BP, stress, blood sugar — *and* HRV, which the owner's R99 denies | | **chipScheme** (`02 1b`) | JieLi | ❓ unknown — it selects the OTA stack only, which PulseLoop doesn't implement | The practical consequence for the TK5: a fix to any `YCBT*` file fixes both rings, and a regression in @@ -82,23 +82,40 @@ Full byte-level spec: **[YCBT protocol](../YCBT-Protocol.md)**. Everything here is decoded from the vendor SDK and covered by unit tests against fixture bytes. The right-hand column is the honest one: what a physical ring still has to confirm. +**Ring-declared vs. baseline.** The rows marked **🔓 ring-declared** are no longer promised by the app +at all: they are offered only if *this* unit's `02 01` capability bitmap sets their bit +(`YCBTSupportFunction` → `TK5Coordinator.bitmapGatedCapabilities`). Everything else is a **baseline** +promise — the app claims it unconditionally, and the bitmap can only ever *add*, never remove. + +The reason for the split is a sibling ring. The owner's **R99** (a Colmi on this same YCBT protocol) +had HRV promised unconditionally, denied it four independent ways — bitmap bit clear, `01 45` → `0xFC`, +`05 33` → `0xFC`, `03 2f` mode `0a` → outright refusal — and the "Measure HRV" button spun for 45 s and +failed, every time. The TK5's temperature / stress / fatigue / blood-sugar claims rested on exactly the +same kind of reasoning that produced that bug (*the SDK defines the record type*), and no TK5 has ever +been seen producing one of those records. So the ring now says, and the app listens. + +**HRV is the exception that proves it**: it stays a baseline promise because it was *observed working on +a TK5* (48 / 79 ms, cross-checked against the vendor app). Evidence from the hardware outranks a bit — +and since no TK5 `02 01` reply has ever been captured, gating HRV could only risk losing a feature that +demonstrably works. + | Capability | Status | Needs on-device confirmation | Notes | |---|:---:|---|---| | Heart rate — spot | 🧪 | — | `03 2f` mode `00` → `06 01` stream; the standard `180D` char is deliberately ignored (see below) | | Heart rate — live | 🧪 | — | verified against the capture (climbed 82→86) | | Heart rate — history | 🧪 | — | `05 06` query, 6-byte records | | SpO₂ — spot | 🧪 | — | red/IR LED, `03 2f` mode `02` | -| SpO₂ — history | 🧪 | — | dedicated `05 1A` log **and** the All record | +| SpO₂ — history | 🧪 | — | dedicated `05 1A` log **and** the All record. Not separately gated: no bit names it — it is one of the SpO₂ sources `ISHASBLOODOXYGEN` already grants | | Steps / distance / calories | 🧪 | distance & calories byte offsets in the `06 00` live push | live push + `05 02` history buckets + the All record's cumulative counter | | Sleep (light / deep / awake) | 🧪 | — | `05 04` timeline; stage = `tag & 0x0F` | -| REM sleep | 🧪 | — | stage tag `3` | -| HRV | 🧪 | the three tail bytes of the `01 45` monitor enable | spot (`03 2f` mode `0a`) + the All and body-data records | -| Blood pressure — spot | 🧪 | per-mode stop (`03 2f {00, 01}`) | `03 2f` mode `01`; no cuff calibration, so treat as a trend, not a number | -| Blood pressure — history | 🧪 | — | dedicated `05 08` log + the All record | -| Skin temperature | 🧪 | one real reading (the string-concat scale) | dedicated `05 1E` log + the All record; monitor enabled by `01 20` | -| Stress | 🧪 | — | **ring-stored**, from the body-data record (`05 33`) — the SDK's `pressure` field | -| Fatigue | 🧪 | — | body-data record — the SDK's `body` field | -| Blood sugar | 🧪 | **one real reading — the scale is inferred, not observed** | All record @17 and the comprehensive record (`05 2F`); tenths of mmol/L → mg/dL | +| REM sleep | 🧪 | — | stage tag `3` — a stage *inside* the timeline `ISHASSLEEP` grants, so no bit names it and it is not gated | +| HRV | 🧪 | the three tail bytes of the `01 45` monitor enable | spot (`03 2f` mode `0a`) + the All and body-data records. **Baseline — observed on the ring** (see above) | +| Blood pressure — spot | 🔓 | per-mode stop (`03 2f {00, 01}`) | ring-declared (`ISHASTESTBLOOD`, byte 15 bit 2). `03 2f` mode `01`; no cuff calibration, so treat as a trend, not a number | +| Blood pressure — history | 🔓 | — | ring-declared (`ISHASBLOOD`, byte 0 bit 0). Dedicated `05 08` log + the All record | +| Skin temperature | 🔓 | one real reading (the string-concat scale) | ring-declared (`ISHASTEMP`, byte 8 bit 0). Dedicated `05 1E` log + the All record; monitor enabled by `01 20` | +| Stress | 🔓 | — | ring-declared (`IS_HAS_PRESSURE`, byte 22 bit 6). **Ring-stored**, from the body-data record (`05 33`) — the SDK's `pressure` field | +| Fatigue | 🔓 | — | ring-declared on the **same bit as stress**: no `ISHASFATIGUE` exists, but the vendor app gates the whole `05 33` query on `IS_HAS_PRESSURE`, and fatigue (the SDK's `body` field) is a field of that one record. No body data ⇒ neither score | +| Blood sugar | 🔓 | **one real reading — the scale is inferred, not observed** | ring-declared (`ISHASBLOODSUGAR`, byte 17 bit 3). All record @17 and the comprehensive record (`05 2F`); tenths of mmol/L → mg/dL | | Respiratory rate | 🧪 | — | All record @10 | | VO₂max | 🧪 | raw byte taken unscaled | body-data record @16 | | Battery level | 🧪 | — | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | From 37e78913a83ca4bef331278252d0f8370477f482 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 17:32:11 -0400 Subject: [PATCH 5/6] Document SmartHealth-app Colmi support and record what the R09 confirmed on hardware --- README.md | 20 ++--- docs/hardware/colmi.md | 173 +++++++++++++++-------------------------- docs/hardware/index.md | 8 +- docs/hardware/tk5.md | 80 ++++++++++--------- 4 files changed, 116 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index a336ccad..81527850 100644 --- a/README.md +++ b/README.md @@ -140,19 +140,19 @@ declares exactly what it can do and the app shows only those features. | --- | --- | --- | --- | | jring (generic smart ring) | `56ff` | `SMART_RING` | $7–12 | | Colmi / Yawell ring family — **QRing app** | `6e40fff0` / `de5bf728` | `R02_…`, `R0x…`, `COLMI R1x…`, `H59_…` | $15–30 | -| Colmi / Yawell ring family — **SmartHealth app** — 🧪 **limited support** | `be940` (Yucheng YCBT) | same names as above | $15–30 | +| Colmi / Yawell ring family — **SmartHealth app** | `be940` (Yucheng YCBT) | `R99 54DC` and similar | $15–30 | | TK5 ring — 🧪 **limited support** | `be940` (Yucheng YCBT) | `TK5 …` (e.g. `TK5 24AA`) | ❓ | -> 🧪 **The two YCBT rings (TK5, SmartHealth-app Colmi) are experimental.** Their driver is rebuilt from -> the decompiled SmartHealth vendor SDK, but a handful of value scales still need one confirmed reading -> on real hardware, and the SmartHealth-Colmi variant has never been connected at all. The app labels -> both "Limited support" when you pair them. +> ⚠️ **A Colmi ring ships with *either* the QRing or the SmartHealth app** — two completely different +> BLE protocols — and its Bluetooth name doesn't reliably say which. So PulseLoop asks you when you +> pair. Pick the wrong one and it simply won't connect; the app then offers a one-tap "try the other +> app" retry. Both are supported. See the +> [Colmi page](https://saksham2001.github.io/PulseLoopiOS/hardware/colmi/#smarthealth-app-colmi-rings). > -> ⚠️ **A Colmi ring ships with *either* the QRing or the SmartHealth app**, and its Bluetooth name -> doesn't say which — so PulseLoop asks you when you pair. Pick the wrong one and it just won't -> connect; the app then offers a one-tap "try the other app" retry. See the -> [Colmi page](https://saksham2001.github.io/PulseLoopiOS/hardware/colmi/#smarthealth-app-colmi-rings) -> and the [TK5 page](https://saksham2001.github.io/PulseLoopiOS/hardware/tk5/). +> 🧪 **The TK5 is still experimental.** It shares its driver with the SmartHealth-app Colmi rings, and +> that driver is confirmed working on one — but no TK5 has run it, and a few value scales still need a +> confirmed reading. The app labels it "Limited support" when you pair it. See the +> [TK5 page](https://saksham2001.github.io/PulseLoopiOS/hardware/tk5/). > 📚 **Full hardware specs, per-model capability matrix, and buying guidance: > [Supported hardware docs](https://saksham2001.github.io/PulseLoopiOS/hardware/).** diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 42c5c789..2ede0df8 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -2,26 +2,28 @@ title: Colmi / Yawell description: >- The $15–30 Colmi/Yawell ring family (R02/R0x/R1x/H59) — sold with either the - QRing app (Nordic-UART protocol) or the SmartHealth app (Yucheng YCBT). + QRing app (Nordic-UART protocol) or the SmartHealth app (Yucheng YCBT). Both supported. --- # Colmi / Yawell -**PulseLoop support: ✅ Supported on QRing** (R11 tested; rest implemented, needs testing 🧪) · -**🧪 Limited on SmartHealth** ([see below](#smarthealth-app-colmi-rings)) +**PulseLoop support: ✅ Supported & tested** — with either app The QRing family — manufactured by Yawell and most commonly sold under the **Colmi** brand. These $15–30 rings speak a Nordic-UART–based protocol and bring sensors the cheaper [56ff / Jring](jring.md) lacks: skin temperature, REM sleep, HRV, stress, and continuous background sync. -!!! warning "The same ring is sold with two different firmwares" - A Colmi ring ships with **either the QRing app or the SmartHealth app**, and the two speak - *completely different BLE protocols*. Everything on this page down to - [Hackability](#hackability) describes the **QRing** firmware. If your ring came with - **SmartHealth**, jump to [SmartHealth-app Colmi rings](#smarthealth-app-colmi-rings) — it is a - different driver, a different capability set, and PulseLoop asks you which one you have when you - pair. +!!! info "The same ring is sold with two apps — PulseLoop supports both" + A Colmi ring ships with **either QRing or SmartHealth**, and the two speak completely different + BLE protocols. The box tells you which; the ring itself doesn't, so **PulseLoop asks you when you + pair** ([details](#the-pairing-app-type-picker)). Pick wrong and it's a 20-second dead end with a + one-tap fix — never a broken ring. + + | | Protocol | Covered by | + |---|---|---| + | **QRing** app | Nordic-UART (`6e40fff0` / `de5bf728`) | this page, down to [Hackability](#hackability) | + | **SmartHealth** app | Yucheng YCBT (`be940`) — the [TK5](tk5.md)'s protocol | [SmartHealth-app Colmi rings](#smarthealth-app-colmi-rings) | ## At a glance @@ -156,120 +158,71 @@ The VC30F is the PPG bio-sensor used in R10, R11, and R12: ## SmartHealth-app Colmi rings -**PulseLoop support: 🧪 Limited — implemented, never connected to a physical ring** +**PulseLoop support: ✅ Tested on the Colmi R09** — other models implemented, untested -Some Colmi rings ship with **SmartHealth** (`com.zhuoting.healthyucheng`) instead of QRing. Same -brand, same product numbers, often the same box art — but the firmware inside speaks the **Yucheng -YCBT** protocol on a `be940…` service, which has *nothing* in common at the wire level with QRing's -Nordic-UART frames. It is the identical protocol the [TK5](tk5.md) speaks, byte for byte, so PulseLoop -drives these rings with the same shared stack and only a per-family capability set on top. +Some Colmi rings ship with **SmartHealth** (`com.zhuoting.healthyucheng`) instead of QRing: same brand, +same product numbers, often the same box — but the firmware speaks the **Yucheng YCBT** protocol +(`be940`), which has nothing in common at the wire level with QRing's Nordic-UART frames. It is the +[TK5](tk5.md)'s protocol byte for byte, so these rings run the same shared driver. -Byte-level spec: **[YCBT protocol](../YCBT-Protocol.md)** — and [§0](../YCBT-Protocol.md#0-the-two-families-that-speak-it) -in particular, which is the complete list of what differs between a TK5 and a SmartHealth-Colmi. +Byte-level spec: **[YCBT protocol](../YCBT-Protocol.md)**. ### Which rings -| Ring | Ships with SmartHealth? | +| Ring | Status | |---|---| -| **Colmi R09, R10** | ✅ Confirmed by the project owner (these are the units this support was written for) | -| Any other Colmi/Yawell model (R02, R03, R06, R07, R08, R11, R12, H59, Yawell R05/R10/R11) | ❔ Possible. The app a ring ships with is a seller/OEM choice, not a hardware one — the *same* model number is sold both ways. If yours came with SmartHealth, PulseLoop will try to drive it as a YCBT ring; it should work, and a report either way is genuinely useful | - -### How to tell which app your ring uses - -There is no way to tell from the ring itself, and **PulseLoop cannot reliably detect it either** — the -Bluetooth local name (`R09_ABCD`, `COLMI R10_1234`) is set by the OEM, not by the app, so a QRing ring -and a SmartHealth ring can advertise the *identical* name. So: check the box, the manual, the QR code -on the leaflet, or the listing that sold it to you. Whichever app it told you to install is the answer. +| **Colmi R09** (advertises `R99 54DC`) | ✅ Tested — pairs, syncs, HR + SpO₂ + blood pressure | +| Colmi R10 | 🧪 Implemented, untested | +| Any other Colmi / Yawell model | ❔ Possible — which app a ring ships with is a seller choice, not a hardware one, and the *same* model number is sold both ways. If yours came with SmartHealth it should just work | -If you genuinely don't know, just try one — a wrong pick is a 20-second dead end with a one-tap fix, -not a broken ring ([below](#if-you-pick-the-wrong-app)). +Every SmartHealth ring seen so far advertises as ` <4 hex>` — with a **space** (`R99 54DC`, +`TK5 24AA`) — where QRing rings use an **underscore** (`R02_A1B2`). PulseLoop uses that to pre-select +the picker, but your pick always wins. ### The pairing app-type picker -Because the answer can't be detected, PulseLoop **asks**. Under every Colmi card in *Add your ring* -there is a segmented picker — *"Which app came with your ring?"* — with **QRing** and **SmartHealth**. - -- **QRing is the default**, because it is the mature, hardware-proven driver. -- The scan may *hint*: a ring whose advertisement looks like a SmartHealth unit defaults the picker to - SmartHealth. The hint is only a default — it is [provisional](#whats-still-provisional) and it may - never fire. -- **Your pick is authoritative.** It, not the scan, chooses the driver; and it changes what the card - shows you before you connect (capability chips and the *Limited support* badge both follow the - picker, because the two firmwares expose different metric sets). -- jring and TK5 cards show no picker: those rings ship with exactly one app, so they stay fully - auto-detected. - -### If you pick the wrong app - -Nothing breaks, and nothing wrong gets saved. The driver for the app you picked goes looking for -service UUIDs the ring doesn't have: the Bluetooth link opens, GATT discovery comes up empty, and the -connection never completes. +The ring can't be asked which app it came with, so PulseLoop **asks you**. Under every Colmi card in +*Add your ring* there's a segmented picker — *"Which app came with your ring?"* — with **QRing** (the +default) and **SmartHealth**. It chooses the driver, and the card's capability chips and support badge +follow it. jring and TK5 show no picker: those ship with exactly one app. -PulseLoop times a user-initiated connect out after **20 seconds** and says so in the app's own terms — -*"This ring didn't answer as a SmartHealth ring. If it came with the QRing app, switch the app type -and try again."* — with a one-tap **"Try as QRing"** button that flips the picker and re-dials the same -ring. (And symmetrically in the other direction.) Only the pairing attempt times out; a background -reconnect to a ring you have already paired keeps waiting, as it should. +**If you pick wrong**, the driver looks for services the ring doesn't have, the connect stalls, and +after **20 seconds** PulseLoop says so — *"This ring didn't answer as a SmartHealth ring…"* — with a +one-tap **"Try as QRing"** that flips the picker and re-dials. Nothing wrong is saved. (Only pairing +times out; a background reconnect to a ring you've already paired keeps waiting, as it should.) ### Capabilities -The baseline is **what every YCBT ring does regardless of its sensors** — it is a protocol floor, not a -SKU description. Everything sensor-dependent is *gated on the ring's own capability bitmap*: the -handshake asks the ring what it has (`02 01` → `YCBTSupportFunction`), and PulseLoop claims those -extras only if the ring itself claims them. The bitmap can only **add** capabilities from a -pre-approved list — a garbled or truncated reply can never take one away. - -| Capability | QRing-Colmi | **SmartHealth-Colmi** | TK5 | -|---|:---:|:---:|:---:| -| Heart rate — history / live / spot | ✅ | 🧪 baseline | 🧪 | -| SpO₂ — history | ✅ | 🧪 baseline | 🧪 | -| SpO₂ — **spot** | ❌ (all-day only) | 🧪 baseline | 🧪 | -| Steps / distance / calories | ✅ | 🧪 baseline | 🧪 | -| Sleep, incl. REM | ✅ | 🧪 baseline | 🧪 | -| HRV — history | ✅ | 🧪 baseline | 🧪 | -| HRV — **spot** | ❌ | 🧪 baseline | 🧪 | -| Battery level | ✅ | 🧪 baseline | 🧪 | -| Find device | ✅ | 🧪 baseline | 🧪 | -| Measurement intervals | ✅ | 🧪 baseline | 🧪 | -| Skin temperature | ✅ | ❔ **bitmap** | 🧪 | -| Stress | ✅ | ❔ **bitmap** | 🧪 | -| Blood pressure — history | ❌ | ❔ **bitmap** | 🧪 | -| Blood pressure — spot | ❌ | ❔ **bitmap** | 🧪 | -| Blood sugar | ❌ | ❔ **bitmap** | 🧪 | -| Fatigue | ✅ | ❌ | 🧪 | -| Power off / factory reset | ✅ | ❌ | ❌ | -| Continuous background sync | ✅ | ❌ | ❌ | - -- **🧪 baseline** — claimed for every SmartHealth-Colmi, but no unit has confirmed any of it yet. -- **❔ bitmap** — claimed *only* if this particular ring's SupportFunction bitmap sets the bit. Two - Colmi rings on the identical protocol genuinely differ on whether they carry a temperature or - blood-pressure sensor, so this is a per-unit answer, not a per-family one. -- **Fatigue is deliberately not claimed**, unlike on the TK5. It rides the body-data record and *no - bit names it*, so it can be neither gated nor honestly promised on hardware nobody has connected — - and an unsupported claim here is user-visible, as a Vitals gauge stuck at "No fatigue score yet". The - first real sync decides; adding it back is a one-line change. -- **Power off / factory reset** are QRing-protocol commands with no YCBT equivalent in PulseLoop, so - the SmartHealth firmware simply doesn't offer them. -- **No background sync while disconnected** — like the TK5, the ring keeps logging on its own (that's - what the all-day monitors are for), but PulseLoop only reads it while connected: on connect, every - 30 minutes thereafter, and after a workout. - -### 🧪 What's still provisional - -**No SmartHealth-Colmi has ever been connected to PulseLoop.** The protocol is not the risk — it is -byte-identical to the TK5's and is exercised by the same unit tests — but everything *specific to this -family* is a prediction until a real ring answers: - -| Provisional | What it is | If it's wrong | -|---|---|---| -| **The advertisement match** | `ColmiSmartHealthCoordinator.Advertisement` — a Colmi-line local name **and** the `1078` product code somewhere in the manufacturer data **and** *no* QRing service UUID (`6e40fff0` / `de5bf728`). Taken from the vendor SDK's own scan filter (`BleHelper.filterDevice` accepts any advertisement whose hex *contains* `1078`), never from a capture of one of these rings | **Nothing stops working.** This is only the *hint* that pre-selects the picker. The user's pick chooses the driver, so a hint that never fires costs one toggle flip, not a connection. Both constants live in one place, to be refined the moment a capture exists | -| **The capability bitmap** | Which bits an R09/R10 actually sets — i.e. whether it really has temperature, BP, stress, blood sugar | A gated capability silently stays off (safe), or the ring claims one whose data never arrives (the card stays empty) | -| **`AE00` / JieLi RCSP gating** | The TK5 answers health commands in plaintext with no auth handshake, and the SDK proves the two code paths are independent — but it cannot prove a given *firmware* doesn't refuse `05 xx` until RCSP auth completes. See [TK5 → the AE00 service](tk5.md#the-ae00-service) | The one scenario that would be a **hard stop** for this family: the RCSP key is native and not recoverable. It is the first thing the first real sync checks | -| **GATT topology** | That a SmartHealth-Colmi exposes the same `be940000/01/03` service and characteristics as the TK5 | The driver finds nothing to subscribe and the connect times out — same failure, and the same recovery, as picking the wrong app | - -Support stays **Limited** until a physical R09/R10 pairs, syncs, and its bitmap is read. If you own -one, [Contributing](../project/contributing.md) explains how to send a report — a `nRF Connect` scan of -the advertisement alone is already useful. +**The ring decides.** The handshake asks what sensors it has (`02 01` → its capability bitmap) and +PulseLoop offers only those, so two Colmi rings on the identical protocol correctly show different +cards. The bitmap can only *add* from a pre-approved list — a garbled reply never takes one away. + +The R09 shows why that matters: it reports **no HRV, temperature, stress or blood sugar** — but it +*does* have blood pressure, including calibration. + +| Capability | QRing-Colmi | SmartHealth-Colmi (R09) | +|---|:---:|:---:| +| Heart rate — history / live / spot | ✅ | ✅ | +| SpO₂ — history | ✅ | ✅ | +| SpO₂ — **spot** | ❌ (all-day only) | ✅ (takes ~40 s) | +| Steps / distance / calories | ✅ | ✅ | +| Sleep, incl. REM | ✅ | 🧪 untested | +| Blood pressure — spot + history | ❌ | ✅ ¹ | +| Battery · find device · measurement intervals | ✅ | ✅ | +| HRV | ✅ | ❌ ¹ | +| Skin temperature · Stress | ✅ | ❌ ¹ | +| Blood sugar | ❌ | ❌ ¹ | +| Power off / factory reset | ✅ | ❌ | +| Continuous background sync | ✅ | ❌ — read only while connected | + +¹ **Ring-declared.** A different SmartHealth-Colmi that sets the bit would get these cards. The R09 +leaves them clear, and backs that up on the wire — it refuses the HRV commands outright. + +### 🧪 Still unconfirmed + +**Sleep** is the one significant path no SmartHealth-Colmi has exercised yet. Everything else above is +confirmed on the R09 — including that its JieLi chip needs **no `AE00` auth**: every health command +answered in plaintext. --- diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 7067cad6..128c635b 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -40,8 +40,8 @@ section breaks the hardware down by manufacturer. --- - The $15–30 R02/R0x/R1x/H59 family. Sold with **two different firmwares**: - QRing (Nordic-UART, ✅ supported) or SmartHealth (Yucheng **YCBT**, 🧪 limited). + ✅ Supported. The $15–30 R02/R0x/R1x/H59 family, sold with **two different + firmwares**: QRing (Nordic-UART) or SmartHealth (Yucheng **YCBT**). Both work — PulseLoop asks which app your ring came with. [:octicons-arrow-right-24: Colmi / Yawell](colmi.md) @@ -51,8 +51,8 @@ section breaks the hardware down by manufacturer. --- 🧪 Limited. Yucheng **YCBT** protocol (SmartHealth app), rebuilt from the - vendor SDK — the broadest metric set here, awaiting on-device confirmation. - Shares its driver with the SmartHealth-app Colmi rings. + vendor SDK — the broadest metric set here. The protocol is proven on a + SmartHealth-app Colmi, which shares its driver; the TK5 itself is untested. [:octicons-arrow-right-24: TK5 / SmartHealth](tk5.md) diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 5c5b020b..8efcc7af 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -7,43 +7,40 @@ description: >- # TK5 / SmartHealth -**PulseLoop support: 🧪 Limited — built from the vendor SDK, not yet confirmed on hardware** +**PulseLoop support: 🧪 Limited — the protocol is confirmed on hardware, this ring isn't** The TK5 pairs with the **SmartHealth** app (`com.zhuoting.healthyucheng`) and speaks the **Yucheng YCBT** protocol on a `be940` service — nothing in common at the wire level with the [56ff / Jring](jring.md) or [Colmi / Yawell QRing](colmi.md) families. PulseLoop's driver is reconstructed from the **decompiled Yucheng YCBT SDK** (`com.yucheng.ycbtsdk`, -v4.0.10) that ships inside the SmartHealth Android app, corroborated by an Android btsnoop capture of -one TK5 session. The byte-level reference is [YCBT protocol](../YCBT-Protocol.md). +v4.0.10) that ships inside the SmartHealth Android app. The byte-level reference is +[YCBT protocol](../YCBT-Protocol.md). -!!! warning "Limited support — pending an on-device checkpoint" - Every layout below is read from the vendor SDK, so this is no longer guesswork from a single - capture. But *"decoded correctly from the SDK"* is not the same claim as *"verified against the - hardware"*, and the badge should mean the latter. A handful of scales and payloads (blood sugar, - temperature, find-device) still need one confirmed reading from a real ring — see - **[Needs on-device confirmation](#needs-on-device-confirmation)**. +!!! warning "Limited support — the protocol is proven, the TK5 hasn't been re-tested" + The YCBT stack is confirmed working on real hardware — but on a *sibling* ring, the + [Colmi R09](colmi.md#smarthealth-app-colmi-rings), not on a TK5. Pairing, the handshake, history + sync and live measurements all check out there, and both rings run the identical driver. - Every decoded metric is range-gated before it's stored, so a misdecode is dropped rather than - saved as garbage. Treat TK5 readings as approximate until the checkpoint clears. + What's still open here is TK5-*specific*: a few scales and payloads (blood sugar, temperature, + find-device) need one confirmed reading — see + **[Needs on-device confirmation](#needs-on-device-confirmation)**. Every decoded metric is + range-gated before storage, so a misdecode is dropped rather than saved as garbage. ## Not the only ring that speaks it The TK5 is one of **two** ring families PulseLoop drives over YCBT. The other is -**[Colmi rings that ship with SmartHealth](colmi.md#smarthealth-app-colmi-rings)** instead of QRing -(R09 / R10 confirmed) — byte-identical protocol, so they run the *same* driver, encoder, decoder, -history transfer and sync engine (the device-neutral `YCBT*` core in `PulseLoop/RingProtocol/`). Each -family adds only a small coordinator with its advertisement matcher and its capability set, and they -differ in exactly three ways: +**[Colmi rings that ship with SmartHealth](colmi.md#smarthealth-app-colmi-rings)** instead of QRing. +The protocol is byte-identical, so they share the whole driver (the device-neutral `YCBT*` core); each +family adds only a coordinator with its advertisement matcher and capability set. They differ in two +ways: | | TK5 | SmartHealth-Colmi | |---|---|---| -| **Advertisement** | `TK5 <4 hex>` + manufacturer prefix `10786501` — unambiguous, so it auto-detects | a Colmi-line name a *QRing* Colmi can advertise too, so PulseLoop asks the user which app their ring came with | -| **SupportFunction bitmap** (`02 01`) | **gates the per-SKU sensors**: temperature, BP, stress, fatigue, blood sugar. HRV is *not* gated — it was observed working on this ring | **gates the per-SKU sensors**: temperature, BP, stress, blood sugar — *and* HRV, which the owner's R99 denies | -| **chipScheme** (`02 1b`) | JieLi | ❓ unknown — it selects the OTA stack only, which PulseLoop doesn't implement | +| **Advertisement** | `TK5 <4 hex>` — unambiguous, so it auto-detects | a Colmi-line name, which a *QRing* Colmi can also carry, so PulseLoop asks which app the ring came with | +| **SupportFunction bitmap** (`02 01`) | gates temperature, BP, stress, fatigue, blood sugar. HRV is **not** gated — it was observed working on this ring | gates those *and* HRV, which the tested R09 denies | -The practical consequence for the TK5: a fix to any `YCBT*` file fixes both rings, and a regression in -one breaks both. See [YCBT protocol §0](../YCBT-Protocol.md#0-the-two-families-that-speak-it). +A fix to any `YCBT*` file fixes both rings; a regression in one breaks both. ## At a glance @@ -130,36 +127,36 @@ demonstrably works. The open items, in priority order. Each is range-gated in code, so a wrong guess degrades to "no reading", never to a wrong reading: -1. **AE00 gating** — do the `05 xx` history queries answer before any RCSP auth? (Evidence says yes; - see [below](#the-ae00-service).) -2. **Blood-sugar scale** — the tenths-of-mmol/L reading is inferred from SmartHealth's database +1. **Blood-sugar scale** — the tenths-of-mmol/L reading is inferred from SmartHealth's database column and chart filter, not from an observed non-zero record. Cross-check one reading. -3. **Temperature scale** — the int/fraction pair is *string-concatenated* (`5` → `.5`, `25` → `.25`), +2. **Temperature scale** — the int/fraction pair is *string-concatenated* (`5` → `.5`, `25` → `.25`), not divided. Confirm a real reading lands at ~36.x °C. -4. **Find-device payload** — `{1, 5, 2}` replays the app's own button; the SDK never names the +3. **Find-device payload** — `{1, 5, 2}` replays the app's own button; the SDK never names the arguments. First suspect if the ring doesn't buzz. -5. **Per-mode stop** — an SpO₂/HRV sweep is stopped with its *own* mode byte. If the LED stays on +4. **Per-mode stop** — an SpO₂/HRV sweep is stopped with its *own* mode byte. If the LED stays on afterwards, this is the line to look at. -6. **HRV monitor tail bytes** — `01 45 {enable, interval, 0, 0, 0}`; only the first two arguments are +5. **HRV monitor tail bytes** — `01 45 {enable, interval, 0, 0, 0}`; only the first two arguments are named in the SDK, so the rest are zero-filled rather than guessed. +6. **Sleep** — the multi-session timeline is decoded from the SDK and unit-tested, but no YCBT ring has + been worn overnight yet. ## The AE00 service The ring exposes a second service, `AE00`, carrying an encrypted `FE DC BA …` / `02 "pass"` -handshake. It looks like a login gating the health data. **It is not.** +handshake. It looks like a login gating the health data. **It is not** — and that is now settled on +hardware, not just from the SDK. It is **JieLi RCSP** — the chipset vendor's challenge-response auth, an entirely separate subsystem from the Yucheng health protocol. It authorizes the **JieLi feature set only: OTA / firmware update, -watch-face upload, and log extraction.** The YC health commands on `be940001` are plaintext, -CRC-framed and carry no auth of any kind; they are an independent code path in the SDK. The AES key -lives in a native library (`libjl_rcsp.so`) and is not recoverable from the decompile. +watch-face upload, and log extraction.** The health commands on `be940001` are plaintext, CRC-framed +and carry no auth of any kind. The AES key lives in a native library (`libjl_rcsp.so`) and is not +recoverable from the decompile. -**PulseLoop deliberately implements none of it** — it does no firmware updates and no watch faces, so -there is nothing behind that handshake it wants. The one residual risk: the SDK proves the two paths -are independent, but cannot prove a given *firmware* doesn't refuse YC commands until RCSP auth -completes. The capture showed plaintext health traffic with no AE00 exchange, so the evidence points -the right way — but if a unit NAKs every `05 xx` until `02 "pass"`, that is a hard stop, and it is -item 1 on the checkpoint. +This was the project's one potential hard stop: the SDK proves the two code paths are independent, but +it cannot prove a given *firmware* doesn't refuse health commands until RCSP auth completes. The +[Colmi R09](colmi.md#smarthealth-app-colmi-rings) answered that — it reports **chip scheme 4 (JieLi)** +and every health command, history query and measurement still answered **in plaintext, with no AE00 +exchange at all**. PulseLoop implements none of RCSP, and doesn't need to. ## How PulseLoop diverges from SmartHealth @@ -178,9 +175,10 @@ are the delete opcodes. The real enables are the five `01 xx {enable, interval}` ## Known limitations -- **Not yet confirmed on hardware.** The layouts come from the vendor SDK rather than guesswork, but - the items in [Needs on-device confirmation](#needs-on-device-confirmation) are still open, which is - why support stays "Limited". See [Contributing](../project/contributing.md) if you own one. +- **No TK5 has run this driver yet.** The protocol is confirmed on a [sibling YCBT ring](colmi.md#smarthealth-app-colmi-rings) + and the layouts come from the vendor SDK, but the TK5-specific items in + [Needs on-device confirmation](#needs-on-device-confirmation) are still open, which is why support + stays "Limited". See [Contributing](../project/contributing.md) if you own one. - **~8-day history horizon.** `RingEventBridge` drops any history sample, sleep session or activity timestamp outside `now − 8 days … now + 1 hour`. A ring's log can hold records stamped under a *previous* clock, which decode hours or days out of place — and because history rows upsert, one From e7fa45579edd61b75101ad1071fe85cd74509339 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 11 Jul 2026 17:49:49 -0400 Subject: [PATCH 6/6] Replace a four-member tuple in the pairing matrix test with a named struct --- PulseLoopTests/PairingMatchingTests.swift | 41 ++++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 4e9b3322..9b1e41ae 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -121,27 +121,36 @@ final class PairingMatchingTests: XCTestCase { /// The R99 rows are the point of the whole exercise: the owner's ring (`R99 54DC`) has to land on /// `.colmiSmartHealth` from its *name alone*, because its advertisement is the one thing the nRF /// capture didn't record. + /// One row of the cross-claim matrix. A struct rather than a tuple: four anonymous members read as + /// noise at the call site, and the labels are the whole point of the table. + private struct ClaimCase { + let label: String + let name: String? + let advertisement: AdvertisementInfo + let expected: RingDeviceType? + } + func testRegistryCrossClaimMatrix() { - let cases: [(String, String?, AdvertisementInfo, RingDeviceType?)] = [ - ("jring", "SMART_RING", noAdv, .jring), - ("QRing-Colmi advertising its service", "R09_00AA", qringAdv, .colmiR02), - ("QRing-Colmi with a bare advertisement", "R09_00AA", noAdv, .colmiR02), + let cases: [ClaimCase] = [ + ClaimCase(label: "jring", name: "SMART_RING", advertisement: noAdv, expected: .jring), + ClaimCase(label: "QRing-Colmi advertising its service", name: "R09_00AA", advertisement: qringAdv, expected: .colmiR02), + ClaimCase(label: "QRing-Colmi with a bare advertisement", name: "R09_00AA", advertisement: noAdv, expected: .colmiR02), // Underscore = QRing, and the marker no longer overrides that (it may well be on a QRing ring // too — every ring in this SDK family carries the Yucheng company ID). - ("QRing-Colmi carrying the Yucheng company ID", "R09_00AA", smartHealthAdv, .colmiR02), - ("QRing-Colmi (COLMI-prefixed name)", "COLMI R10_xyz", smartHealthAdv, .colmiR02), - ("R99, name only — the capture we actually have", "R99 54DC", noAdv, .colmiSmartHealth), - ("R99 with the Yucheng company ID", "R99 54DC", smartHealthAdv, .colmiSmartHealth), - ("R99 lowercase hex suffix", "R99 54dc", noAdv, .colmiSmartHealth), - ("TK5", "TK5 24AA", tk5Adv, .tk5), - ("TK5, name only", "TK5 24AA", noAdv, .tk5), - ("unknown peripheral", "Galaxy Watch", noAdv, nil), + ClaimCase(label: "QRing-Colmi carrying the Yucheng company ID", name: "R09_00AA", advertisement: smartHealthAdv, expected: .colmiR02), + ClaimCase(label: "QRing-Colmi (COLMI-prefixed name)", name: "COLMI R10_xyz", advertisement: smartHealthAdv, expected: .colmiR02), + ClaimCase(label: "R99, name only — the capture we actually have", name: "R99 54DC", advertisement: noAdv, expected: .colmiSmartHealth), + ClaimCase(label: "R99 with the Yucheng company ID", name: "R99 54DC", advertisement: smartHealthAdv, expected: .colmiSmartHealth), + ClaimCase(label: "R99 lowercase hex suffix", name: "R99 54dc", advertisement: noAdv, expected: .colmiSmartHealth), + ClaimCase(label: "TK5", name: "TK5 24AA", advertisement: tk5Adv, expected: .tk5), + ClaimCase(label: "TK5, name only", name: "TK5 24AA", advertisement: noAdv, expected: .tk5), + ClaimCase(label: "unknown peripheral", name: "Galaxy Watch", advertisement: noAdv, expected: nil), ] - for (label, name, advertisement, expected) in cases { + for ring in cases { XCTAssertEqual( - RingBLEClient.matchDeviceType(name: name, advertisement: advertisement), - expected, - label + RingBLEClient.matchDeviceType(name: ring.name, advertisement: ring.advertisement), + ring.expected, + ring.label ) } }