diff --git a/PulseLoop/Assets.xcassets/r10m.imageset/Contents.json b/PulseLoop/Assets.xcassets/r10m.imageset/Contents.json new file mode 100644 index 0000000..3f8c93b --- /dev/null +++ b/PulseLoop/Assets.xcassets/r10m.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "r10m.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseLoop/Assets.xcassets/r10m.imageset/r10m.png b/PulseLoop/Assets.xcassets/r10m.imageset/r10m.png new file mode 100644 index 0000000..ee6e308 Binary files /dev/null and b/PulseLoop/Assets.xcassets/r10m.imageset/r10m.png differ diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 1cc6860..04f6089 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -201,6 +201,11 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { let iconSystemName = "circle.circle.fill" func makeDriver(writer: RingCommandWriter) -> WearableDriver { - YCBTDriver(writer: writer) + YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: capabilities, + bitmapGatedCapabilities: bitmapGatedCapabilities, + queryChipSchemeAtStartup: true, + supportsBloodPressureMonitor: true + )) } } diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 51603a7..7a2c3be 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -38,6 +38,11 @@ final class RingBLEClient: NSObject { /// `TK5x`-named LuckRing sibling. ("TK18" does not hit the `TK5` prefix, so today it is moot.) static let coordinators: [WearableCoordinator.Type] = [ JringCoordinator.self, + // Ahead of both Colmi coordinators: `ColmiSmartHealthCoordinator`'s ` <4 hex>` name + // convention accepts "R10M FCF4", so an R10M carrying the shared `1078` company ID would + // otherwise be claimed as a Colmi and handed the Colmi baseline. This coordinator's own matcher + // is narrow enough (see there) that leading the Colmis costs them nothing. + YCBTCoordinator.self, ColmiSmartHealthCoordinator.self, ColmiCoordinator.self, LuckRingCoordinator.self, @@ -106,6 +111,10 @@ final class RingBLEClient: NSObject { /// Optional second write characteristic for big-data requests (Colmi `de5bf72a`). private var commandChar: CBCharacteristic? private var notifyChars: [CBUUID: CBCharacteristic] = [:] + /// Notify characteristics that have actually reported `isNotifying` on *this* link. Reset per + /// connection: a driver survives a reconnect, so a set carried over would let the next link claim + /// readiness on subscriptions that belong to the dead one. + private var subscribedNotifyUUIDs: Set = [] private var batteryCharacteristic: CBCharacteristic? // MARK: Active driver / engine (selected per connection) @@ -297,6 +306,19 @@ final class RingBLEClient: NSObject { pumpWrites() } + /// Put commands at the **head** of the write queue, preserving their order relative to each other. + /// + /// Only for `WearableDriver.immediatePostSubscriptionCommands()`. Everything else must append: the + /// queue is what makes writes serial and ordered, and a caller jumping it would reorder a protocol + /// that depends on its own sequence. + private func prependWrites(_ commands: [Data]) { + let framed = commands.map { command -> (data: Data, useCommandChannel: Bool) in + let framed = activeDriver?.frame(command) ?? command + return (data: framed, useCommandChannel: activeDriver?.usesCommandChannel(for: framed) ?? false) + } + writeQueue.insert(contentsOf: framed, at: 0) + } + func readBattery() { guard let peripheral, let batteryCharacteristic else { return } peripheral.readValue(for: batteryCharacteristic) @@ -358,6 +380,7 @@ final class RingBLEClient: NSObject { central.cancelPeripheralConnection(old) } writeChar = nil; commandChar = nil; notifyChars = [:]; batteryCharacteristic = nil + subscribedNotifyUUIDs = [] writeInFlight = false; writeQueue = [] peripheral = target target.delegate = self @@ -732,6 +755,7 @@ extension RingBLEClient: CBCentralManagerDelegate { writeChar = nil commandChar = nil notifyChars = [:] + subscribedNotifyUUIDs = [] batteryCharacteristic = nil writeInFlight = false writeQueue = [] @@ -817,8 +841,12 @@ extension RingBLEClient: CBPeripheralDelegate { guard let driver = activeDriver, driver.notifyUUIDs.contains(characteristic.uuid), characteristic.isNotifying else { return } - // Fully connected once at least one notify char is live. (Multi-notify devices may fire - // this twice; guard against re-running startup.) + subscribedNotifyUUIDs.insert(characteristic.uuid) + // Fully connected once every channel the driver declared *required* is live — or, for a + // driver that declares none, on the first one, which is the historical behaviour. (Multi-notify + // devices fire this once per channel; guard against re-running startup.) + let required = driver.requiredSubscriptionsBeforeConnected + guard required.allSatisfy(subscribedNotifyUUIDs.contains) else { return } guard state != .connected else { return } state = .connected cancelConnectTimeout() // the attempt landed @@ -844,6 +872,10 @@ extension RingBLEClient: CBPeripheralDelegate { startKeepalive() startWatchdog() readBattery() + // Order on the wire: the driver's own handshake, then the engine's startup sequence. + // `onConnected` is what queues the latter, so the prepend has to happen first — after it, the + // engine's commands are already in the queue and "head" would mean jumping them too. + prependWrites(driver.immediatePostSubscriptionCommands()) onConnected?() pumpWrites() } diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index c0d78cb..fe10521 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -124,7 +124,15 @@ final class TK5Coordinator: WearableCoordinator { let iconSystemName = "circle.circle.fill" + /// Both firmware flags stay `true`: the TK5 answers `02 1b` GetChipScheme without dropping the link, + /// and the R99 session showed a YCBT ring NAKing the all-day BP monitor harmlessly rather than + /// needing it suppressed. Only the R10M turns either off. func makeDriver(writer: RingCommandWriter) -> WearableDriver { - YCBTDriver(writer: writer) + YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: capabilities, + bitmapGatedCapabilities: bitmapGatedCapabilities, + queryChipSchemeAtStartup: true, + supportsBloodPressureMonitor: true + )) } } diff --git a/PulseLoop/RingProtocol/YCBTCoordinator.swift b/PulseLoop/RingProtocol/YCBTCoordinator.swift new file mode 100644 index 0000000..bf35dfd --- /dev/null +++ b/PulseLoop/RingProtocol/YCBTCoordinator.swift @@ -0,0 +1,134 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for generic YCBT / SmartHealth rings that belong to neither the Colmi line nor the TK5 — +/// the **LittleMeatball R10M** is the hardware-validated unit (FCF4, firmware 2.32). +/// +/// The *protocol* is not R10M-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 an R10M an R10M — +/// its advertised identity, its capability set, and the two firmware quirks carried in +/// `YCBTFamilyProfile`. +/// +/// ## Why a separate family rather than another `.colmiSmartHealth` card +/// +/// `.colmiSmartHealth` is the Colmi line's SmartHealth firmware; its capability baseline, its product art +/// and its app-variant picker are all Colmi facts. The R10M is a different vendor's ring that happens to +/// speak the same protocol. Folding it in would have it inherit Colmi art and Colmi claims, and would put +/// an "is this a QRing or a SmartHealth ring?" question in front of a user whose ring only ever shipped +/// one firmware. +@MainActor +final class YCBTCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .ycbt + + /// The R10M naming convention, normalized (trimmed + uppercased) before matching. + /// + /// Deliberately **looser** than `WearableModel.r10m`'s catalog pattern: that one is user-facing + /// identity and must not mislabel a ring, while this one only decides which driver to install. A + /// bare `R10M`, a `-` separator, or a non-hex suffix still gets the right protocol — it just resolves + /// to no catalog model, so it pairs with a generic name and the fallback art. + private static let namePattern = "^R10M(?:[ _-][0-9A-Z]+)?$" + + /// The QRing-flavoured Colmi rings advertise one of these. Presence is a positive disqualifier: that + /// ring answers to `ColmiDriver`, and this coordinator is registered ahead of it. + private static let qringServiceUUIDs: [CBUUID] = [ + CBUUID(string: ColmiUUIDs.serviceV1), + CBUUID(string: ColmiUUIDs.serviceV2), + ] + + /// Service-first, then name — the reverse of `TK5Coordinator`, because unlike the TK5 the R10M + /// **does** advertise its proprietary `be940000` service. + /// + /// 1. A QRing service disqualifies outright. + /// 2. If a catalog card claims the name, the card decides — so a `TK5 24AA` or an `R09_A1B2` is + /// handed straight back even though both are YCBT rings, because their cards name other families. + /// 3. Otherwise: the `be940000` service, or an R10M-shaped name, is enough on its own. + /// + /// Note what is **not** here: no `TK5`/`T50`/`SR0x`/`R0x` name prefixes and no `1078` manufacturer + /// marker. Both would let this coordinator — registered ahead of `TK5Coordinator` and + /// `ColmiSmartHealthCoordinator` — swallow uncataloged rings that belong to those families and hand + /// them a capability set built for a different ring. A YCBT ring nothing recognizes is better served + /// by the coordinator whose baseline was written for it. + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + guard !advertisement.serviceUUIDs.contains(where: { qringServiceUUIDs.contains($0) }) + else { return false } + if let model = WearableModel.model(advertisedName: name) { + return model.families.contains(deviceType) + } + if advertisement.serviceUUIDs.contains(CBUUID(string: YCBTUUIDs.service)) { return true } + return isYCBTName(name) + } + + /// Does this local name follow the R10M convention? + static func isYCBTName(_ name: String?) -> Bool { + guard let name, let regex = try? NSRegularExpression(pattern: namePattern) else { return false } + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let range = NSRange(normalized.startIndex.. = [ + .heartRate, .spo2, .steps, .sleep, .remSleep, .battery, + .manualHeartRate, .manualSpo2, + .realtimeHeartRate, .realtimeSteps, + .measurementInterval, + ] + + /// The per-SKU sensors: offered only if this unit's `02 01` bitmap claims them. + /// + /// FW 2.32 on the validated unit declares none of temperature, blood sugar, HRV, stress, fatigue or + /// Find Device, so in practice the R10M shows exactly its baseline plus blood pressure. They stay + /// listed because the gate costs a ring that *does* have them nothing — it claims them, and it gets + /// them — and because "R10M" is a model, not a firmware. + /// + /// `.fatigue` rides the stress bit for the same reason it does on the TK5: there is no + /// `ISHASFATIGUE`, but fatigue is the `body` field of the body-data record (`05 33`) whose whole query + /// the vendor app gates on `IS_HAS_PRESSURE` — one record, one bit, two fields. + let bitmapGatedCapabilities: Set = [ + .temperature, .bloodPressure, .manualBloodPressure, + .stress, .fatigue, .bloodSugar, + .hrv, .manualHrv, + .findDevice, + ] + + let iconSystemName = "circle.circle.fill" + + /// Two firmware quirks, both observed on the R10M and both absent on the TK5 / SmartHealth-Colmi + /// (which pass these flags `true`): + /// + /// - **No `02 1b` GetChipScheme.** The R10M closes an otherwise healthy connection with HCI `0x13` on + /// this purely informational query. + /// - **No `01 1c` all-day blood-pressure monitor.** The ring does not implement it; sending it earns a + /// NAK and nothing else. + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: capabilities, + bitmapGatedCapabilities: bitmapGatedCapabilities, + queryChipSchemeAtStartup: false, + supportsBloodPressureMonitor: false + )) + } +} diff --git a/PulseLoop/RingProtocol/YCBTDecoder.swift b/PulseLoop/RingProtocol/YCBTDecoder.swift index 749ef5b..5e3aab4 100644 --- a/PulseLoop/RingProtocol/YCBTDecoder.swift +++ b/PulseLoop/RingProtocol/YCBTDecoder.swift @@ -79,7 +79,11 @@ struct YCBTDecoder { // 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)] } + // + // All three fields or none. `YCBTBytes.u16` returns 0 past the end of the buffer, so a short + // frame decoded as a *valid* activity row with zeroed distance and calories — the ratchet + // keeps the totals safe, but the row itself asserts the ring reported a zero it never sent. + guard p.count >= 6 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)))] diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift index fc5be61..a036eaf 100644 --- a/PulseLoop/RingProtocol/YCBTDriver.swift +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -1,6 +1,30 @@ import Foundation @preconcurrency import CoreBluetooth +/// The handful of facts that differ between the ring families sharing this driver. Everything else about +/// the YCBT stack is protocol, identical for all of them. +/// +/// The two `Bool`s are firmware quirks, not preferences — both default to the permissive value so an +/// existing family keeps its behaviour, and only the R10M turns them off. +struct YCBTFamilyProfile { + /// Unconditional promises from the coordinator. The ring's own bitmap can never take one back. + let baselineCapabilities: Set + /// Capabilities the coordinator will grant *if* the ring's `02 01` reply claims them. + let bitmapGatedCapabilities: Set + /// Send the informational `02 1b` GetChipScheme during startup. The R10M closes an otherwise healthy + /// connection with HCI `0x13` when asked. + var queryChipSchemeAtStartup: Bool = true + /// Send the all-day blood-pressure monitor `01 1c`. The R10M does not implement it even when its + /// bitmap declares the BP sensor, so the flag is separate from the `.bloodPressure` capability. + var supportsBloodPressureMonitor: Bool = true + + /// The additive refinement, identical in rule to `WearableCoordinator.refinedCapabilities` — the + /// driver needs its own copy because it filters decoded events before the client ever sees them. + func refined(bitmapDerived: Set) -> Set { + baselineCapabilities.union(bitmapGatedCapabilities.intersection(bitmapDerived)) + } +} + /// 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 @@ -52,8 +76,19 @@ final class YCBTDriver: WearableDriver { /// entries are then the stale ones, and dropping them keeps the newest command pairable. private static let maxPendingMeasurementReplies = 8 - init(writer: RingCommandWriter) { + /// The family this connection is serving — capability sets plus the two firmware quirks. + private let profile: YCBTFamilyProfile + private let encoder = YCBTEncoder() + + /// What this ring is currently believed to have: the family baseline until its `02 01` reply lands, + /// then the additive refinement. Reset on every connect and disconnect, because the answer belongs to + /// one link — a reconnect to a *different* ring of the same family must not inherit it. + private var capabilities: Set + + init(writer: RingCommandWriter, profile: YCBTFamilyProfile) { self.writer = writer + self.profile = profile + self.capabilities = profile.baselineCapabilities self.transfer = YCBTHistoryTransfer(writer: writer) } @@ -73,6 +108,23 @@ final class YCBTDriver: WearableDriver { let batteryServiceUUID: CBUUID? = nil // battery is in-band (GetDeviceInfo 02 00, payload[5]) let batteryCharUUID: CBUUID? = nil + /// **Both** channels must be live before the connection counts as up. They are not redundant: command + /// replies (the whole handshake — device info, the capability bitmap, every ACK) arrive on `be940001`, + /// while the live and history streams arrive on `be940003`. Publishing `.connected` on whichever + /// subscribed first — which is what a driver that declares nothing here gets — starts the handshake + /// against a half-subscribed ring, and whichever half was still pending silently swallows its share + /// of the replies. + let requiredSubscriptionsBeforeConnected: [CBUUID] = [ + CBUUID(string: YCBTUUIDs.command), + CBUUID(string: YCBTUUIDs.stream), + ] + + /// Run ahead of the startup sequence, the moment both channels are up. See + /// `YCBTEncoder.postSubscriptionHandshake`. + func immediatePostSubscriptionCommands() -> [Data] { + encoder.postSubscriptionHandshake().map { Data($0) } + } + // MARK: Framing func frame(_ command: Data) -> Data { // Every outbound command passes through here exactly once, which is what makes this the seam that @@ -110,6 +162,7 @@ final class YCBTDriver: WearableDriver { // 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() + capabilities = profile.baselineCapabilities } /// Cancelling on the next *connect* is too late for the transfer: its stall watchdog is a timer, and @@ -121,6 +174,7 @@ final class YCBTDriver: WearableDriver { assembler.reset() transfer.cancel() pendingMeasurementReplies.removeAll() + capabilities = profile.baselineCapabilities } // MARK: Inbound decode @@ -151,7 +205,53 @@ final class YCBTDriver: WearableDriver { events.append(contentsOf: decoder.decode(frame)) } } - return events + updateCapabilities(from: events) + return events.filter(isSupported) + } + + /// Fold the ring's own `02 01` reply into what we believe it has. Additive only, exactly as the + /// coordinator does for the UI-facing set — the bitmap can grant a gated capability, never revoke a + /// baseline one. + private func updateCapabilities(from events: [RingDecodedEvent]) { + for case let .supportFunctions(derived) in events { + capabilities = profile.refined(bitmapDerived: derived) + } + } + + /// Drop samples for sensors this ring has not claimed. + /// + /// The UI already hides the *cards* for unclaimed metrics, so why also drop the data? Because the two + /// gates answer different questions. A hidden card still accumulates rows in the store, and those rows + /// outlive the gate: they sync to HealthKit, they feed the coach's summaries, and they reappear the + /// day the capability is granted — as history the ring never actually measured. The R99 session is the + /// precedent: a ring that denies a sensor four ways still emits *something* in that field, and + /// whatever it is, it is not a reading. + /// + /// Heart rate, SpO₂, respiratory rate and VO₂max are never filtered: no support-function bit names + /// them, so a gate here could only ever be a permanent deletion. + private func isSupported(_ event: RingDecodedEvent) -> Bool { + switch event { + case .bloodPressureSample: return capabilities.contains(.bloodPressure) + case .bloodSugarSample: return capabilities.contains(.bloodSugar) + case .hrvSample: return capabilities.contains(.hrv) + case .stressSample: return capabilities.contains(.stress) + case .fatigueSample: return capabilities.contains(.fatigue) + case .temperatureSample: return capabilities.contains(.temperature) + case let .historyMeasurement(kind, _, _): return isSupported(kind) + default: return true + } + } + + private func isSupported(_ kind: MeasurementKind) -> Bool { + switch kind { + case .heartRate, .spo2, .respiratoryRate, .vo2max: return true + case .bloodPressureSystolic, .bloodPressureDiastolic: return capabilities.contains(.bloodPressure) + case .bloodSugar: return capabilities.contains(.bloodSugar) + case .hrv: return capabilities.contains(.hrv) + case .stress: return capabilities.contains(.stress) + case .fatigue: return capabilities.contains(.fatigue) + case .temperature: return capabilities.contains(.temperature) + } } /// The ring **retransmits an unacknowledged DevControl push** until the app answers `04 {00}`, @@ -172,6 +272,6 @@ final class YCBTDriver: WearableDriver { } func makeSyncEngine() -> RingSyncEngine { - YCBTSyncEngine(writer: writer, transfer: transfer) + YCBTSyncEngine(writer: writer, transfer: transfer, profile: profile) } } diff --git a/PulseLoop/RingProtocol/YCBTEncoder.swift b/PulseLoop/RingProtocol/YCBTEncoder.swift index 2eba5ef..b38577b 100644 --- a/PulseLoop/RingProtocol/YCBTEncoder.swift +++ b/PulseLoop/RingProtocol/YCBTEncoder.swift @@ -17,8 +17,27 @@ struct YCBTEncoder { 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. + /// The two commands that go out the *instant* both indication channels are live, ahead of everything + /// `startupSequence` queues — `RingBLEClient` prepends them via + /// `WearableDriver.immediatePostSubscriptionCommands()`. + /// + /// They lead for different reasons. `02 03` GetDeviceName is the cheapest possible round-trip that + /// proves the ring is answering on `be940001` at all, so a topology that came up half-subscribed + /// fails here rather than silently swallowing the whole handshake. `01 00` SetTime leads because + /// every record the ring stores is stamped from its own RTC in local wall-clock: a clock set *after* + /// the history walk has begun mis-stamps everything read before it. + func postSubscriptionHandshake(_ date: Date = Date(), calendar: Calendar = .current) -> [[UInt8]] { + [deviceNameRequest(), setTime(date, calendar: calendar)] + } + + /// Read the ring's stored name (`02 03`). + func deviceNameRequest() -> [UInt8] { + logical(YCBTGroup.get, YCBTCommand.getDeviceName, [0x47, 0x50]) + } + + /// The connect handshake, in the SmartHealth app's own order: device interrogation → locale → + /// all-day monitors → user profile → live-status stream. The clock and the name request run ahead of + /// this, in `postSubscriptionHandshake`. /// /// **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 @@ -29,33 +48,71 @@ struct YCBTEncoder { /// 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. + /// + /// - Parameters: + /// - capabilities: what this ring is currently believed to have. Filters the monitor writes so a + /// ring is never asked to run a sensor it doesn't declare. + /// - queryChipScheme: `false` suppresses the informational `02 1b`. The R10M closes an otherwise + /// healthy link with HCI `0x13` on that query; the TK5 and SmartHealth-Colmi answer it fine. + /// - supportsBloodPressureMonitor: `false` suppresses the all-day BP monitor `01 1c` outright, + /// regardless of the `.bloodPressure` capability — the R10M has the sensor but not the monitor. 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 + is24Hour: Bool = true, + capabilities: Set = Set(WearableCapability.allCases), + queryChipScheme: Bool = true, + supportsBloodPressureMonitor: 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])) + if queryChipScheme { + seq.append(logical(YCBTGroup.get, YCBTCommand.getChipScheme, [])) + } 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(contentsOf: monitorCommands( + measurement, + capabilities: capabilities, + supportsBloodPressureMonitor: supportsBloodPressureMonitor + )) 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) + /// + /// Filtered by capability on the way out: each `01 xx {enable, interval}` write names one sensor, and + /// a ring that lacks it answers `0xFC`. Sending it anyway is not merely noise — it puts a rejection + /// in the middle of the handshake for something the user can't fix, and on the R10M the all-day BP + /// monitor is unimplemented even though the BP *sensor* is present (hence the separate flag). + /// + /// A capability we can't map to a monitor byte drops the command rather than passing it through: the + /// default `capabilities` is the full set, so an unfiltered caller still gets all five. + func monitorCommands( + _ measurement: MeasurementSettings, + capabilities: Set = Set(WearableCapability.allCases), + supportsBloodPressureMonitor: Bool = true + ) -> [[UInt8]] { + settings.monitorCommands(measurement).filter { command in + guard command.count >= 2 else { return false } + switch command[1] { + case YCBTSettingKey.heartMonitor: return capabilities.contains(.heartRate) + case YCBTSettingKey.bloodPressureMonitor: + return supportsBloodPressureMonitor && capabilities.contains(.bloodPressure) + case YCBTSettingKey.temperatureMonitor: return capabilities.contains(.temperature) + case YCBTSettingKey.bloodOxygenMonitor: return capabilities.contains(.spo2) + case YCBTSettingKey.hrvMonitor: return capabilities.contains(.hrv) + default: return false + } + } } /// Push the user's real height/weight/sex/age (`01 03`). diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift index 3beb7d4..ecc2fbc 100644 --- a/PulseLoop/RingProtocol/YCBTHealthRecords.swift +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -105,8 +105,12 @@ enum YCBTHealthRecords { 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)) + // Steps (`u16` @4) are deliberately **not** emitted. The field is the ring's *cumulative* + // day counter as of this record, and `.activityUpdate` is a per-day max ratchet — so the + // oldest record in a dump that spans midnight sets today's total to yesterday's count and + // nothing can lower it again for the rest of the day. Activity has two sources that don't + // have this problem: the `05 02` sport buckets (additive intervals) and the `06 00` live + // status (the counter, always current). 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)) @@ -249,8 +253,15 @@ enum YCBTHealthRecords { guard seenStarts.insert(segmentStart).inserted else { continue } // firmware repeat — count once let segmentSeconds = YCBTBytes.u24(buffer, offset + 5) if sessionStart == nil { sessionStart = YCBTBytes.date(segmentStart) } + // The timeline is expanded positionally — one entry per minute from the session start — + // so a bogus duration doesn't just mis-size one stage, it allocates. The field is u24: + // an all-ones segment is 194 days, or ~280 000 array entries for a single 8-byte record, + // and a buffer of them exhausts memory before anything gets a chance to reject it. No + // real session exceeds a day, so cap there and stop. + let remaining = Self.maxSleepSessionMinutes - stages.count + if remaining <= 0 { break } let minutes = Int((Double(segmentSeconds) / 60.0).rounded()) - stages.append(contentsOf: Array(repeating: stage, count: max(1, minutes))) + stages.append(contentsOf: Array(repeating: stage, count: min(max(1, minutes), remaining))) } if let start = sessionStart, !stages.isEmpty { @@ -291,6 +302,9 @@ enum YCBTHealthRecords { /// 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. + /// Ceiling on one decoded sleep session, in minutes. See the sleep segment loop. + static let maxSleepSessionMinutes = 24 * 60 + private static let temperatureFiller: UInt8 = 15 /// Temperature from an int/fraction pair, shared by the dedicated record and the All record. diff --git a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift index 2201e09..c95bc8f 100644 --- a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift +++ b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift @@ -44,6 +44,9 @@ final class YCBTHistoryTransfer { 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 + /// What the header promised, kept for the terminal block to be checked against. See `handleTerminal`. + private var expectedPackets = 0 + private var expectedBytes = 0 /// Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. private var unsupported: Set = [] @@ -82,6 +85,29 @@ final class YCBTHistoryTransfer { publishOutOfBand(advance()) } + /// Add types to the queue **without** disturbing whatever is in flight. + /// + /// `start` deliberately refuses while a transfer is running, which is right for a second full pass + /// but wrong for the one case this exists for: the ring's `02 01` capability bitmap arrives *during* + /// the startup history walk, and the types it unlocks would otherwise wait for the next sync. Appending + /// leaves the current type's buffer and terminal untouched — the new keys are simply asked for when + /// the queue reaches them. + /// + /// Duplicates are dropped rather than re-queued: asking twice would re-dump a log we already hold and + /// re-upsert every record in it. Idle is handled too, so a late bitmap after a finished walk still + /// pulls its types. + func append(types: [YCBTHistoryType]) { + let additions = types.filter { type in + !unsupported.contains(type.queryKey) + && !queue.contains(type) + && state.type != type + } + guard !additions.isEmpty else { return } + queue.append(contentsOf: additions) + guard !isActive else { return } + publishOutOfBand(advance()) + } + /// Abandon any in-flight transfer (disconnect / teardown). func cancel() { cancelWatchdog() @@ -98,6 +124,8 @@ final class YCBTHistoryTransfer { buffer.removeAll(keepingCapacity: false) bufferCap = Self.defaultBufferCap retriedCurrentType = false + expectedPackets = 0 + expectedBytes = 0 guard !queue.isEmpty else { state = .idle return [.historySyncFinished] @@ -147,6 +175,8 @@ final class YCBTHistoryTransfer { private func handleHeader(_ type: YCBTHistoryType, payload: [UInt8]) -> [RingDecodedEvent] { guard payload.count >= YCBTHealth.headerPayloadLength else { return advance() } let totalBytes = YCBTBytes.u32(payload, 6) + expectedPackets = YCBTBytes.u16(payload, 2) + expectedBytes = totalBytes buffer.removeAll(keepingCapacity: true) bufferCap = max(totalBytes, Self.defaultBufferCap) state = .receiving(type) @@ -175,6 +205,23 @@ final class YCBTHistoryTransfer { private func handleTerminal(_ type: YCBTHistoryType, payload: [UInt8]) -> [RingDecodedEvent] { if case .requestSent = state, buffer.isEmpty { return [] } guard payload.count >= YCBTHealth.terminalPayloadLength else { return advance() } + + // Count check before CRC, because the CRC alone does not catch a *short* buffer. A dropped data + // frame leaves us with fewer bytes than the ring sent, and the terminal's CRC was computed over + // what the ring sent — so it fails, which looks the same as corruption but isn't. Worse is the + // reverse: a transfer whose last frames never arrived can still terminate with a buffer whose CRC + // happens to be checked over exactly what we hold if the ring recomputed it. Cross-checking the + // packet and byte counts against *both* the header's promise and the buffer we actually built + // turns "silently short" into a NAK and a retry. + let packets = YCBTBytes.u16(payload, 0) + let bytes = YCBTBytes.u16(payload, 2) + let matchesHeader = packets == expectedPackets && bytes == expectedBytes + let matchesBuffer = bytes == buffer.count + guard matchesHeader, matchesBuffer else { + writer?.enqueue(Data(YCBTHealthCommand.historyBlockAck(status: YCBTHealth.ackCrcFailure))) + return retryOrSkip(type) + } + let expected = UInt16(YCBTBytes.u16(payload, 4)) let matches = YCBTFrame.crc16(buffer) == expected @@ -227,11 +274,18 @@ final class YCBTHistoryTransfer { typeDeadline = nil } + /// Set by `YCBTSyncEngine`, which needs to see a completion it did not cause. `handle`'s events reach + /// the engine via the driver's return value; `start`, `append` and the watchdog have no such channel, + /// and the engine acts on `.historySyncFinished` (it re-issues the live-status subscription), so a + /// walk that the watchdog ended would otherwise leave today's steps stale until the next sync. + var onOutOfBandEvents: (([RingDecodedEvent]) -> Void)? + /// `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. + /// such return channel, so the one event they can produce — completion — is surfaced here. private func publishOutOfBand(_ events: [RingDecodedEvent]) { guard events.contains(where: { if case .historySyncFinished = $0 { return true } else { return false } }) else { return } + onOutOfBandEvents?(events) Task { await PulseEventBus.shared.publish(.syncProgress(stage: "done")) } } } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index cd03080..3cca23d 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -122,12 +122,34 @@ enum YCBTBytes { /// 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. + /// of cancelling it. + /// + /// The offset is resolved **at the timestamp's own date**, not today's. Reading the ring seconds as + /// UTC recovers the wall-clock fields the ring actually stored; re-interpreting those fields in the + /// local zone then picks whichever offset was in force *then*. Subtracting today's offset instead — + /// which is what this did — silently shifts every record on the far side of a DST transition by an + /// hour, so a Sunday-night sleep session read on Monday lands in the wrong hour bucket and, near + /// midnight, the wrong day. `ringSeconds(_:)` has always been date-aware; this makes the pair + /// symmetric. + /// + /// Ambiguous and skipped wall-clock times (the hour that repeats in autumn, the hour that doesn't + /// exist in spring) resolve however `Calendar` resolves them — earliest match and forward shift + /// respectively. Both are one-hour errors in a one-hour window per year, and neither is recoverable + /// from the wire: the ring simply does not record which side of the transition it meant. static func date(_ ringSeconds: Int, timeZone: TimeZone = .current) -> Date { - let offset = TimeInterval(timeZone.secondsFromGMT()) - return Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset - offset) + let wallClock = Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset) + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(secondsFromGMT: 0) ?? timeZone + let fields: Set = [.year, .month, .day, .hour, .minute, .second] + var localCalendar = Calendar(identifier: .gregorian) + localCalendar.timeZone = timeZone + guard let resolved = localCalendar.date(from: utcCalendar.dateComponents(fields, from: wallClock)) + else { + // Unreachable for a Gregorian calendar with complete components; fall back to the old + // fixed-offset behaviour rather than returning a timestamp that isn't a time at all. + return wallClock.addingTimeInterval(-TimeInterval(timeZone.secondsFromGMT())) + } + return resolved } /// Convert a `Date` to ring seconds (2000-epoch), the inverse of `date(_:timeZone:)`. diff --git a/PulseLoop/RingProtocol/YCBTSyncEngine.swift b/PulseLoop/RingProtocol/YCBTSyncEngine.swift index fd56392..efb7b1c 100644 --- a/PulseLoop/RingProtocol/YCBTSyncEngine.swift +++ b/PulseLoop/RingProtocol/YCBTSyncEngine.swift @@ -17,6 +17,14 @@ final class YCBTSyncEngine: RingSyncEngine { private weak var writer: RingCommandWriter? private let encoder = YCBTEncoder() private let transfer: YCBTHistoryTransfer + private let profile: YCBTFamilyProfile + + /// What this ring is believed to have — the family baseline until its `02 01` bitmap lands. Drives + /// which history types are worth asking for and which all-day monitors are worth writing. + private var historyCapabilities: Set + + /// Set for the startup walk only, cleared when it finishes. See `handle(_:)`. + private var requestActivityAfterStartupHistory = false /// Every type `YCBTHealthRecords` can decode, in the SDK's own ascending-key sync order. /// @@ -42,15 +50,22 @@ final class YCBTSyncEngine: RingSyncEngine { private var measurementSettings = MeasurementSettings.allOnDefault private var userProfile = UserProfileValues(metric: true, sex: nil, age: nil, heightCm: nil, weightKg: nil) - init(writer: RingCommandWriter?, transfer: YCBTHistoryTransfer) { + init(writer: RingCommandWriter?, transfer: YCBTHistoryTransfer, profile: YCBTFamilyProfile) { self.writer = writer self.transfer = transfer + self.profile = profile + self.historyCapabilities = profile.baselineCapabilities + // A walk the watchdog ends, or one `append` restarts from idle, produces its `.historySyncFinished` + // outside `handle`'s return path. Route it back in so the completion hook below fires either way. + transfer.onOutOfBandEvents = { [weak self] events in + events.forEach { self?.handle($0) } + } } // MARK: Startup func runStartup() { - for command in encoder.startupSequence(measurement: measurementSettings, profile: userProfile) { + for command in startupCommands() { writer?.enqueue(Data(command)) } // The transfer machine writes the first `05 ` query itself and advances off the ring's @@ -58,11 +73,103 @@ final class YCBTSyncEngine: RingSyncEngine { // `.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) + requestActivityAfterStartupHistory = true + transfer.start(types: supportedHistoryTypes(Self.historyTypes)) + } + + private func startupCommands() -> [[UInt8]] { + encoder.startupSequence( + measurement: measurementSettings, + profile: userProfile, + capabilities: historyCapabilities, + queryChipScheme: profile.queryChipSchemeAtStartup, + supportsBloodPressureMonitor: profile.supportsBloodPressureMonitor + ) + } + + /// Two events need acting on; everything else the transfer machine handles itself. + func handle(_ event: RingDecodedEvent) { + switch event { + case let .supportFunctions(derived): + applySupportFunctions(derived) + case .historySyncFinished: + // Some R10M firmware ACKs the `03 09` live-status subscription sent during the handshake but + // doesn't actually start publishing until after its history dump is done — so today's step + // count sits at whatever the last session left until the user pulls to refresh. Asking once + // more, after the walk, costs one frame and fixes a reconnect that otherwise looks frozen. + guard requestActivityAfterStartupHistory else { return } + requestActivityAfterStartupHistory = false + writer?.enqueue(Data(encoder.enableLiveStatus())) + default: + return + } + } + + /// The ring's own capability bitmap, which arrives mid-handshake — after the startup sequence has + /// already been queued from the baseline. Two things it can unlock: + /// + /// 1. **All-day monitors.** Only the *newly* permitted ones are pushed; re-sending a monitor already + /// in the startup sequence would just re-write the same setting. + /// 2. **History types.** `append` rather than `start`, so the in-flight block is not disturbed. + private func applySupportFunctions(_ derived: Set) { + let refined = profile.refined(bitmapDerived: derived) + guard refined != historyCapabilities else { return } + let added = refined.subtracting(historyCapabilities) + historyCapabilities = refined + + let alreadySent = encoder.monitorCommands( + measurementSettings, + capabilities: refined.subtracting(added), + supportsBloodPressureMonitor: profile.supportsBloodPressureMonitor + ) + for command in encoder.monitorCommands( + measurementSettings, + capabilities: refined, + supportsBloodPressureMonitor: profile.supportsBloodPressureMonitor + ) where !alreadySent.contains(command) { + writer?.enqueue(Data(command)) + } + + var newTypes = supportedHistoryTypes(Self.historyTypes) + // The `05 09` combined record carries BP, HRV, temperature and blood sugar as *optional fields* of + // a record we always fetch — so unlike the others, a late unlock of one of these doesn't add a new + // query, it means the one we already ran was decoded with those fields dropped. Re-run it once. + if added.contains(where: { [.bloodPressure, .hrv, .temperature, .bloodSugar].contains($0) }) { + newTypes.append(.all) + } + transfer.append(types: newTypes) } - /// History is protocol-driven now — nothing here advances it. - func handle(_ event: RingDecodedEvent) {} + /// Ask only for logs this ring is believed to keep. + /// + /// A ring that doesn't implement a type answers with a no-data header or a `0xFC`, both of which + /// `YCBTHistoryTransfer` handles — so this is not about correctness, it is about not spending ten + /// seconds of watchdog per absent type on every sync. `.all` is never gated: the combined record is + /// the fallback source for half these metrics, and no bit names it. + private func supportedHistoryTypes(_ types: [YCBTHistoryType]) -> [YCBTHistoryType] { + types.filter { type in + switch type { + case .sport: return historyCapabilities.contains(.steps) + case .sleep: return historyCapabilities.contains(.sleep) + case .heart: return historyCapabilities.contains(.heartRate) + case .blood: return historyCapabilities.contains(.bloodPressure) + case .all: return true + // Not `.spo2` — this is the dedicated `05 1a` all-day log, which a ring can lack while still + // reporting SpO₂ in the combined record. The R10M is exactly that ring. + case .spo2: return historyCapabilities.contains(.spo2History) + case .temperature: return historyCapabilities.contains(.temperature) + case .comprehensive: return historyCapabilities.contains(.bloodSugar) + case .bodyData: + return historyCapabilities.contains(.hrv) + || historyCapabilities.contains(.stress) + || historyCapabilities.contains(.fatigue) + // `YCBTHistoryType` is a struct of static members, not an enum, so the compiler can't check + // this for exhaustiveness — every member of `YCBTHistoryType.catalog` is named above, and a + // new one has to declare its gate here before it will ever be requested. + default: return false + } + } + } // MARK: History passes // @@ -72,14 +179,18 @@ final class YCBTSyncEngine: RingSyncEngine { /// 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) + // Re-assert the live-status subscription first. Pull-to-refresh is the gesture a user makes + // *because* today's steps look stale, and on a ring whose `03 09` went quiet the history walk + // alone won't move them — the cumulative counter comes from the `06 00` stream, not the logs. + writer?.enqueue(Data(encoder.enableLiveStatus())) + transfer.start(types: supportedHistoryTypes(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) + transfer.start(types: supportedHistoryTypes(Self.vitalsTypes)) } // MARK: All-day measurement config (the five `01 xx {enable, interval}` monitors) @@ -92,7 +203,11 @@ final class YCBTSyncEngine: RingSyncEngine { /// Store *and* push immediately — the live "Save" path while connected. func applyMeasurementSettings(_ settings: MeasurementSettings) { measurementSettings = settings - for command in encoder.monitorCommands(settings) { + for command in encoder.monitorCommands( + settings, + capabilities: historyCapabilities, + supportsBloodPressureMonitor: profile.supportsBloodPressureMonitor + ) { writer?.enqueue(Data(command)) } } diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index c4e19af..fe3a553 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -239,6 +239,10 @@ struct DeviceHeroCard: View { case .colmiR02, .colmiSmartHealth: return nil case .tk5: return "tk5" case .luckRing: return "luckring-tk18" + // R10M is the only catalogued ring in this family and the only one anyone has tested, so its art + // is the family's representative — an uncatalogued YCBT ring is far more likely to be one of + // these than anything else. + case .ycbt: return "r10m" case nil: return nil } } diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index c00eb80..13d18cc 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -15,6 +15,11 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { /// LuckRing / TK18 family (the "K6" vendor SDK, company ID `0xFF64`). Sold under simsonlab and other /// brands; TK18 is the hardware-tested unit. See `LuckRingCoordinator`. case luckRing + /// Generic YCBT / SmartHealth rings that are **not** part of the Colmi line and carry no TK5 + /// identity — the LittleMeatball R10M is the hardware-validated unit. Same wire protocol as `.tk5` + /// and `.colmiSmartHealth` (so it shares the whole `YCBT*` stack), kept a separate family because its + /// capability set, product art and firmware quirks are its own. See `YCBTCoordinator`. + case ycbt /// Human-facing default name when no advertised name is available. var displayName: String { @@ -24,6 +29,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .tk5: return "TK5 ring" case .colmiSmartHealth: return "Colmi ring (SmartHealth)" case .luckRing: return "LuckRing" + case .ycbt: return "YCBT / SmartHealth ring" } } } diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index baf170d..1c5a406 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -58,6 +58,25 @@ protocol WearableDriver: AnyObject { /// must be stopped here. Default: no-op. func connectionDidEnd() + /// Notify characteristics that must **all** be subscribed before the connection counts as up. + /// + /// A driver that leaves this empty gets the historical behaviour: `.connected` fires — and with it + /// the startup handshake — on whichever notify characteristic reports `isNotifying` first. That is + /// fine for a device whose channels are interchangeable, and wrong for one that splits its protocol + /// across them: YCBT puts every command *reply* on `be940001` and the live/history stream on + /// `be940003`, so a handshake begun after only one is live loses whichever half was still pending — + /// silently, because a missing reply is indistinguishable from a slow one. + /// + /// These must be a subset of `notifyUUIDs`; anything else can never be satisfied. + var requiredSubscriptionsBeforeConnected: [CBUUID] { get } + + /// Logical commands to write the moment those subscriptions complete, **ahead of** the sync engine's + /// startup sequence. For a vendor handshake that has to lead — a clock write whose ordering changes + /// how subsequent records are stamped, say — queueing it in `runStartup` is too late: the engine's + /// commands are appended to a queue that may already hold battery reads and optional CCCD work. + /// Default: none. + func immediatePostSubscriptionCommands() -> [Data] + /// The stateful brain: startup sequence + (for Colmi) the response-driven history machine. func makeSyncEngine() -> RingSyncEngine } @@ -68,6 +87,10 @@ extension WearableDriver { func usesCommandChannel(for frame: Data) -> Bool { false } func connectionDidStart() {} func connectionDidEnd() {} + /// Empty keeps the historical behaviour: the connection is up as soon as *any* notify characteristic + /// starts notifying. + var requiredSubscriptionsBeforeConnected: [CBUUID] { [] } + func immediatePostSubscriptionCommands() -> [Data] { [] } } /// User-chosen all-day measurement configuration, passed as a plain value from the app layer into a diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 5babdcd..23131af 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -53,7 +53,7 @@ enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { switch family { case .colmiR02: self = .qring case .colmiSmartHealth: self = .smartHealth - case .jring, .tk5, .luckRing: return nil + case .jring, .tk5, .luckRing, .ycbt: return nil } } @@ -114,6 +114,9 @@ extension RingDeviceType { case .tk5: return .limited // TK18 is the only hardware-tested LuckRing; every 0xFF64 sibling is still a prediction. case .luckRing: return .limited + // Validated end-to-end on an R10M FCF4 running firmware 2.32 — pairing, handshake, reconnect, + // activity and history sync, HR/SpO₂/BP, battery, sleep stages and REM. + case .ycbt: return .full } } } @@ -174,6 +177,23 @@ extension WearableModel { advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: "tk5" ) + /// R10M — sold as the "LittleMeatball" smart ring, and the hardware-validated unit of the generic + /// `.ycbt` family (FCF4, firmware 2.32). It speaks the same YCBT protocol as the TK5 and the + /// SmartHealth-flavoured Colmis, but it is **not** a Colmi ring, so it carries its own product art and + /// its own capability set rather than borrowing theirs. + /// + /// The pattern accepts both separators (`R10M FCF4` and `R10M_FCF4`) because both forms have been + /// observed. It is deliberately narrower than `YCBTCoordinator`'s own name check: this one is + /// user-facing identity, and mis-labelling a ring is worse than showing it un-named. + /// + /// Blurb mirrors `YCBTCoordinator`'s baseline plus the BP its bitmap grants — BP, temperature, HRV, + /// stress and blood sugar are all claimed from the ring's own `02 01` reply at connect time. + static let r10m = WearableModel( + id: "r10m", displayName: "R10M (LittleMeatball)", brand: "LittleMeatball", family: .ycbt, + tint: PulseColors.hrv, blurb: "HR · SpO₂ · BP · Sleep", + advertisedNamePatterns: ["^R10M[ _][0-9A-F]{4}$"], imageName: "r10m" + ) + // TK18 — the LuckRing app / "K6" protocol (company ID 0xFF64). The only hardware-tested unit of the // whole 0xFF64 family, so it is `.limited`. Its baseline is what the driver can decode; untested // siblings still pair via the coordinator's strong-signal match and get generic art + a fallback name. @@ -310,6 +330,9 @@ extension WearableModel { colmiR02, colmiR03, colmiR06, colmiR07, colmiR08, colmiR09, colmiR10, colmiR11, colmiR12, colmiR99, yawellR05, yawellR10, yawellR11, h59, + // Ahead of the YCBT siblings: `model(advertisedName:)` takes the first pattern that matches, and + // R10M is the narrowest of the three. + r10m, tk5, luckRingTK18, ] diff --git a/PulseLoopTests/YCBTCoordinatorTests.swift b/PulseLoopTests/YCBTCoordinatorTests.swift new file mode 100644 index 0000000..b9b0e6e --- /dev/null +++ b/PulseLoopTests/YCBTCoordinatorTests.swift @@ -0,0 +1,177 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// What makes an R10M an R10M: its advertised identity and its capability set. The protocol itself is +/// the shared `YCBT*` stack, covered by the other YCBT suites. +@MainActor +final class YCBTCoordinatorTests: XCTestCase { + private func advertisement(services: [String] = [], manufacturer: Data? = nil) -> AdvertisementInfo { + AdvertisementInfo(serviceUUIDs: services.map { CBUUID(string: $0) }, manufacturerData: manufacturer) + } + + // MARK: Matching + + /// Both separators have been observed on the hardware. `R10M FCF4` is the validated unit. + func testClaimsTheR10MByName() { + for name in ["R10M FCF4", "R10M_FCF4", "R10M-FCF4", "R10M", "r10m fcf4"] { + XCTAssertTrue( + YCBTCoordinator.matches(name: name, advertisement: advertisement()), + "\(name) should be claimed" + ) + } + } + + /// The R10M advertises its proprietary service, unlike the TK5 — so the service alone is enough even + /// when the local name is something nobody recognizes. + func testClaimsAnyRingAdvertisingTheProprietaryService() { + XCTAssertTrue(YCBTCoordinator.matches( + name: "Unknown Ring", + advertisement: advertisement(services: [YCBTUUIDs.service]) + )) + } + + /// A QRing service means the ring answers to `ColmiDriver`. This coordinator is registered ahead of + /// it, so the disqualifier has to run first and win outright. + func testRejectsRingsAdvertisingAQRingService() { + XCTAssertFalse(YCBTCoordinator.matches( + name: "R10M FCF4", + advertisement: advertisement(services: [ColmiUUIDs.serviceV1]) + )) + XCTAssertFalse(YCBTCoordinator.matches( + name: "R10M FCF4", + advertisement: advertisement(services: [ColmiUUIDs.serviceV2]) + )) + } + + /// Registered ahead of `TK5Coordinator` and `ColmiSmartHealthCoordinator`, so it must not claim rings + /// those families catalog — they are YCBT too, but their capability baselines were written for + /// different hardware. + func testDoesNotClaimTheOtherYCBTFamilies() { + XCTAssertFalse(YCBTCoordinator.matches(name: "TK5 24AA", advertisement: advertisement())) + XCTAssertFalse(YCBTCoordinator.matches(name: "R99 54DC", advertisement: advertisement())) + XCTAssertFalse(YCBTCoordinator.matches(name: "R02_A1B2", advertisement: advertisement())) + XCTAssertFalse(YCBTCoordinator.matches(name: "SMART_RING", advertisement: advertisement())) + XCTAssertFalse(YCBTCoordinator.matches(name: nil, advertisement: advertisement())) + } + + /// The Yucheng company ID is shared by every ring in this SDK family, the TK5 included, so it must + /// not be a claim signal here — `TK5Coordinator` is checked after us and would never get the chance. + func testDoesNotClaimOnTheSharedManufacturerMarkerAlone() { + XCTAssertFalse(YCBTCoordinator.matches( + name: "Some Ring", + advertisement: advertisement(manufacturer: Data([0x10, 0x78, 0x65, 0x01])) + )) + } + + /// Registry order is load-bearing: `ColmiSmartHealthCoordinator`'s ` <4 hex>` convention + /// accepts "R10M FCF4", so it must not be reached first. + func testIsRegisteredAheadOfTheColmiCoordinators() { + let order = RingBLEClient.coordinators.map { ObjectIdentifier($0) } + guard let ycbt = order.firstIndex(of: ObjectIdentifier(YCBTCoordinator.self)), + let smartHealth = order.firstIndex(of: ObjectIdentifier(ColmiSmartHealthCoordinator.self)), + let colmi = order.firstIndex(of: ObjectIdentifier(ColmiCoordinator.self)), + let tk5 = order.firstIndex(of: ObjectIdentifier(TK5Coordinator.self)) else { + return XCTFail("every YCBT-adjacent coordinator must be registered") + } + XCTAssertLessThan(ycbt, smartHealth) + XCTAssertLessThan(ycbt, colmi) + XCTAssertLessThan(ycbt, tk5) + } + + /// The whole point of the family: an R10M resolves to the YCBT driver, not a Colmi one. + func testScanResolvesAnR10MToTheYCBTFamily() { + XCTAssertEqual( + RingBLEClient.matchDeviceType(name: "R10M FCF4", advertisement: advertisement()), + .ycbt + ) + } + + // MARK: Capabilities + + /// Everything in the baseline is an unconditional promise — the refinement is additive-only, so the + /// ring's bitmap can never take one back. Only what the R10M was *seen* doing belongs here. + func testBaselineIsWhatTheHardwareSessionConfirmed() { + XCTAssertEqual(YCBTCoordinator().capabilities, [ + .heartRate, .spo2, .steps, .sleep, .remSleep, .battery, + .manualHeartRate, .manualSpo2, + .realtimeHeartRate, .realtimeSteps, + .measurementInterval, + ]) + } + + /// HRV is gated here and baseline on the TK5 — deliberately. The TK5's HRV was observed and + /// cross-checked against the vendor app; nobody has seen an R10M produce one, and FW 2.32 does not + /// declare the bit. + func testPerSKUSensorsAreBitmapGated() { + let gated = YCBTCoordinator().bitmapGatedCapabilities + for capability in [ + WearableCapability.temperature, .bloodPressure, .manualBloodPressure, + .stress, .fatigue, .bloodSugar, .hrv, .manualHrv, .findDevice, + ] { + XCTAssertTrue(gated.contains(capability), "\(capability) should be bitmap-gated") + XCTAssertFalse(YCBTCoordinator().capabilities.contains(capability), "\(capability) is not baseline") + } + } + + /// `.spo2History` is in **neither** set, and that is what stops the `05 1a` query being issued at + /// all — the R10M has no dedicated SpO₂ log, its all-day SpO₂ rides the `05 09` combined record. + /// Putting it in the gated set instead would be worse than useless: no bit maps to it, so the gate + /// could never be satisfied, and the type would be unreachable rather than simply unrequested. + func testSpo2HistoryIsNeitherBaselineNorGated() { + let coordinator = YCBTCoordinator() + XCTAssertFalse(coordinator.capabilities.contains(.spo2History)) + XCTAssertFalse(coordinator.bitmapGatedCapabilities.contains(.spo2History)) + } + + /// A bitmap claiming something outside the gated set cannot smuggle it in. + func testRefinementStaysAdditiveAndPreApproved() { + let coordinator = YCBTCoordinator() + let refined = coordinator.refinedCapabilities(bitmapDerived: [.bloodPressure, .powerOff]) + XCTAssertTrue(refined.contains(.bloodPressure), "a pre-approved claim is granted") + XCTAssertFalse(refined.contains(.powerOff), "a claim this family never gated is refused") + XCTAssertTrue(coordinator.capabilities.isSubset(of: refined), "the baseline is never revoked") + } + + // MARK: Firmware quirks + + /// The two R10M workarounds, asserted where they are declared. Their effect on the wire is covered by + /// `YCBTEncoderTests`. + func testDriverCarriesTheR10MFirmwareQuirks() { + final class Writer: RingCommandWriter { + nonisolated deinit {} + func enqueue(_ command: Data) {} + } + guard let driver = YCBTCoordinator().makeDriver(writer: Writer()) as? YCBTDriver else { + return XCTFail("the YCBT family must build a YCBTDriver") + } + // Both indication channels are required before the handshake may start. + XCTAssertEqual(driver.requiredSubscriptionsBeforeConnected, [ + CBUUID(string: YCBTUUIDs.command), + CBUUID(string: YCBTUUIDs.stream), + ]) + } + + // MARK: Catalog identity + + func testR10MCatalogEntry() { + guard let model = WearableModel.model(advertisedName: "R10M FCF4") else { + return XCTFail("R10M FCF4 must resolve to a catalog model") + } + XCTAssertEqual(model.id, "r10m") + XCTAssertEqual(model.displayName, "R10M (LittleMeatball)") + XCTAssertEqual(model.brand, "LittleMeatball") + XCTAssertEqual(model.family, .ycbt) + XCTAssertEqual(model.imageName, "r10m") + XCTAssertTrue(model.appVariants.isEmpty, "single-firmware ring — no app-variant picker") + XCTAssertEqual(WearableModel.model(advertisedName: "R10M_FCF4")?.id, "r10m") + } + + /// The catalog pattern is narrower than the coordinator's on purpose: this one drives user-facing + /// identity, and mislabelling a ring is worse than showing it un-named. + func testCatalogPatternIsNarrowerThanTheCoordinators() { + XCTAssertNil(WearableModel.model(advertisedName: "R10M-FCF4")) + XCTAssertNil(WearableModel.model(advertisedName: "R10M")) + XCTAssertTrue(YCBTCoordinator.matches(name: "R10M-FCF4", advertisement: advertisement())) + } +} diff --git a/PulseLoopTests/YCBTDecoderTests.swift b/PulseLoopTests/YCBTDecoderTests.swift index 48d8b31..5df703f 100644 --- a/PulseLoopTests/YCBTDecoderTests.swift +++ b/PulseLoopTests/YCBTDecoderTests.swift @@ -72,6 +72,22 @@ final class YCBTDecoderTests: XCTestCase { XCTAssertEqual(calories, 26) // 0x001a (capture-inferred) } + /// All three fields or none. `YCBTBytes.u16` returns 0 past the end of the buffer, so a truncated + /// `06 00` used to decode as a *valid* activity row with zeroed distance and calories — the ratchet + /// keeps the totals safe, but the row itself asserts a zero the ring never reported. + func testShortLiveStatusFrameEmitsNoActivityRow() { + // 06 00 with only the step field — 2 payload bytes where the layout needs 6. + let frame = YCBTFrame(validating: YCBTFrame.frame([0x06, 0x00, 0x7b, 0x02]))! + let events = decoder.decode(frame) + XCTAssertFalse( + events.contains { if case .activityUpdate = $0 { return true } else { return false } }, + "a short frame must not publish a half-read activity row, got \(events)" + ) + guard case .commandAck = events.first else { + return XCTFail("expected a bare ack, got \(events)") + } + } + /// `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 @@ -418,6 +434,41 @@ final class YCBTDecoderTests: XCTestCase { XCTAssertEqual(decoded.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1) } + /// The offset must be resolved at the **timestamp's own date**, not today's. A ring record written in + /// January and read back in July is stamped with a wall-clock the ring never converted, so applying + /// July's offset to it shifts the whole record by an hour — enough to move a late-evening sample into + /// the wrong day, and every sleep stage into the wrong hour bucket. + func testDateDecodeUsesTheOffsetInForceAtTheRecordsOwnDate() { + let tz = TimeZone(identifier: "America/New_York")! // EST (-5) in January, EDT (-4) in July + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + + // The ring stored "2026-01-15 22:30:00" as naive local wall-clock. + let stored = DateComponents(year: 2026, month: 1, day: 15, hour: 22, minute: 30) + guard let naive = utc.date(from: stored) else { return XCTFail("fixture") } + let ringSeconds = Int(naive.timeIntervalSince1970 - YCBTBytes.epochOffset) + + let decoded = YCBTBytes.date(ringSeconds, timeZone: tz) + + // Read back as local components it must still say 22:30 on the 15th — whatever today's offset is. + var local = Calendar(identifier: .gregorian) + local.timeZone = tz + let parts = local.dateComponents([.year, .month, .day, .hour, .minute], from: decoded) + XCTAssertEqual(parts.year, 2026) + XCTAssertEqual(parts.month, 1) + XCTAssertEqual(parts.day, 15) + XCTAssertEqual(parts.hour, 22) + XCTAssertEqual(parts.minute, 30) + + // Concretely: EST is -5, so the true instant is 03:30 UTC on the 16th. Using July's -4 would put + // it at 02:30 — the hour of drift this test exists to catch. + XCTAssertEqual( + decoded.timeIntervalSince1970, + naive.timeIntervalSince1970 + 5 * 3600, + 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) @@ -431,7 +482,7 @@ final class YCBTDecoderTests: XCTestCase { @MainActor private func makeDriver() -> YCBTDriver { - YCBTDriver(writer: SilentRingWriter()) + YCBTDriver(writer: SilentRingWriter(), profile: .permissiveTestProfile) } } diff --git a/PulseLoopTests/YCBTDriverTests.swift b/PulseLoopTests/YCBTDriverTests.swift index 77691f3..a52e5e0 100644 --- a/PulseLoopTests/YCBTDriverTests.swift +++ b/PulseLoopTests/YCBTDriverTests.swift @@ -26,7 +26,7 @@ final class YCBTDriverTests: XCTestCase { /// 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) + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) // 04 13 | type 0 (heart rate), state 1, 72 bpm let push = YCBTFrame.frame([0x04, 0x13, 0x00, 0x01, 72]) @@ -45,7 +45,7 @@ final class YCBTDriverTests: XCTestCase { /// (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) + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) // 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) @@ -62,7 +62,7 @@ final class YCBTDriverTests: XCTestCase { /// 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) + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) _ = driver.ingest(YCBTFrame.frame([0x04, 0x13, 0xfc]), from: stream) @@ -73,7 +73,7 @@ final class YCBTDriverTests: XCTestCase { /// write on the wire for every heartbeat the ring streams. func testLiveStreamFramesAreNotAcked() { let writer = FakeWriter() - let driver = YCBTDriver(writer: writer) + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) _ = driver.ingest(YCBTFrame.frame([0x06, 0x01, 82]), from: stream) @@ -92,19 +92,88 @@ final class YCBTDriverTests: XCTestCase { /// query again is the proof that the disconnect really did abandon the first. func testConnectionDidEndAbandonsTheInFlightHistoryTransfer() { let writer = FakeWriter() - let driver = YCBTDriver(writer: writer) + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) let engine = driver.makeSyncEngine() + // `syncHistory` re-asserts the live-status subscription before the walk, so the query is the + // *second* write, not the first. + let liveStatus = Data([0x03, 0x09, 0x01, 0x00, 0x02]) engine.syncHistory() // → `05 02` (sport); the transfer is now in flight - XCTAssertEqual(writer.sent, [Data([0x05, 0x02])]) + XCTAssertEqual(writer.sent, [liveStatus, Data([0x05, 0x02])]) writer.sent.removeAll() engine.syncHistory() - XCTAssertTrue(writer.sent.isEmpty, "sanity: an in-flight transfer refuses a re-entrant start") + XCTAssertEqual(writer.sent, [liveStatus], "sanity: an in-flight transfer refuses a re-entrant start") + writer.sent.removeAll() 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") + XCTAssertEqual( + writer.sent, [liveStatus, Data([0x05, 0x02])], + "the transfer must have been abandoned on disconnect" + ) } + + // MARK: Capability gating of decoded events + + /// A sample for a sensor the ring hasn't claimed must not reach the store. The UI already hides the + /// *card*, but a hidden card still accumulates rows — and those rows outlive the gate: they sync to + /// HealthKit and reappear as history the moment the capability is granted. + func testGatedSamplesAreDroppedUntilTheBitmapGrantsThem() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: [.heartRate, .spo2], + bitmapGatedCapabilities: [.bloodPressure] + )) + + // 06 03 live vitals: 118/79, hr 64. BP is gated and the bitmap hasn't spoken yet. + let vitals = YCBTFrame.frame([0x06, 0x03, 118, 79, 64]) + XCTAssertFalse( + driver.ingest(vitals, from: stream).contains { if case .bloodPressureSample = $0 { true } else { false } }, + "BP must be dropped before the ring claims the sensor" + ) + + // `02 01` reply with ISHASBLOOD (byte 0 bit 0) set, at the SDK's 14-byte minimum length. + var bitmap: [UInt8] = Array(repeating: 0, count: 14) + bitmap[0] = 0x01 + _ = driver.ingest(YCBTFrame.frame([0x02, 0x01] + bitmap), from: stream) + + XCTAssertTrue( + driver.ingest(vitals, from: stream).contains { if case .bloodPressureSample = $0 { true } else { false } }, + "once the bitmap claims BP the same frame must decode through" + ) + + // A reconnect starts from the family baseline again — the answer belonged to that one link, and + // the next one may be a different ring of the same family. + driver.connectionDidEnd() + XCTAssertFalse( + driver.ingest(vitals, from: stream).contains { if case .bloodPressureSample = $0 { true } else { false } }, + "disconnect must reset the capability set to the baseline" + ) + } + + /// Heart rate, SpO₂, respiratory rate and VO₂max carry no support-function bit, so a gate on them + /// could only ever be a permanent deletion rather than a deferral. + func testUngatedMetricsAlwaysPassRegardlessOfBitmap() { + let writer = FakeWriter() + let driver = YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: [], bitmapGatedCapabilities: [] + )) + + let events = driver.ingest(YCBTFrame.frame([0x06, 0x01, 82]), from: stream) + guard case let .heartRateSample(bpm, _) = events.first else { + return XCTFail("expected a heart-rate sample, got \(events)") + } + XCTAssertEqual(bpm, 82) + } +} + +extension YCBTFamilyProfile { + /// Everything claimed, both quirk flags permissive — i.e. exactly how the driver behaved before + /// families were distinguished, so tests about *other* behaviour stay unaffected by the gate. + static let permissiveTestProfile = YCBTFamilyProfile( + baselineCapabilities: Set(WearableCapability.allCases), + bitmapGatedCapabilities: [] + ) } diff --git a/PulseLoopTests/YCBTEncoderTests.swift b/PulseLoopTests/YCBTEncoderTests.swift index 705126a..ae3bbe3 100644 --- a/PulseLoopTests/YCBTEncoderTests.swift +++ b/PulseLoopTests/YCBTEncoderTests.swift @@ -105,22 +105,23 @@ final class YCBTEncoderTests: XCTestCase { /// 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). + /// + /// The clock (`01 00`) and the name read (`02 03`) are **not** here — they moved to + /// `postSubscriptionHandshake`, which `RingBLEClient` writes ahead of this whole sequence. 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]), [ + XCTAssertEqual(Array(sequence[0...3]), [ [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...]), [ + XCTAssertEqual(Array(sequence[4...]), [ [0x01, 0x12], [0x01, 0x04], [0x01, 0x0c], [0x01, 0x1c], [0x01, 0x20], [0x01, 0x26], [0x01, 0x45], [0x01, 0x03], @@ -128,6 +129,70 @@ final class YCBTEncoderTests: XCTestCase { ]) } + /// The two commands that lead, before anything the sync engine queues. The clock leads for a reason + /// beyond tidiness: every record the ring stores is stamped from its own RTC in local wall-clock, so + /// a clock written *after* the history walk has begun mis-stamps everything read before it. + func testPostSubscriptionHandshakeIsTheNameReadThenTheClock() { + let handshake = encoder.postSubscriptionHandshake() + XCTAssertEqual(handshake.count, 2) + XCTAssertEqual(handshake[0], [0x02, 0x03, 0x47, 0x50], "GetDeviceName — the cheapest proof of life") + XCTAssertEqual(Array(handshake[1].prefix(2)), [0x01, 0x00], "then SetTime") + } + + // MARK: Family quirks + + /// The R10M closes an otherwise healthy link with HCI 0x13 when asked for the chip scheme, and does + /// not implement the all-day BP monitor. Both are suppressed by profile flag, and neither suppression + /// may leak into the families that answer those commands fine. + func testR10MStartupOmitsChipSchemeAndTheBloodPressureMonitor() { + let sequence = encoder.startupSequence( + capabilities: YCBTCoordinator().capabilities.union([.bloodPressure]), + queryChipScheme: false, + supportsBloodPressureMonitor: false + ).map { Array($0.prefix(2)) } + + XCTAssertFalse(sequence.contains([0x02, 0x1b]), "02 1b drops the R10M's link") + XCTAssertFalse(sequence.contains([0x01, 0x1c]), "the R10M has no all-day BP monitor") + XCTAssertEqual(sequence, [ + [0x02, 0x00], [0x02, 0x01], [0x02, 0x07], + [0x01, 0x12], [0x01, 0x04], + [0x01, 0x0c], // heart — baseline + [0x01, 0x26], // SpO₂ — baseline + [0x01, 0x03], [0x03, 0x09], + ]) + } + + /// The TK5 / SmartHealth-Colmi path is unchanged: both flags stay true and all five monitors go out. + func testDefaultStartupStillSendsChipSchemeAndEveryMonitor() { + let sequence = encoder.startupSequence().map { Array($0.prefix(2)) } + XCTAssertTrue(sequence.contains([0x02, 0x1b])) + for monitor in [0x0c, 0x1c, 0x20, 0x26, 0x45] as [UInt8] { + XCTAssertTrue(sequence.contains([0x01, monitor]), "monitor 01 \(String(monitor, radix: 16)) missing") + } + } + + /// A monitor names one sensor; a ring that lacks it answers `0xFC`. Filtering them out keeps a + /// rejection the user can't act on out of the middle of the handshake. + func testMonitorCommandsAreFilteredByCapability() { + let commands = encoder.monitorCommands(.allOnDefault, capabilities: [.heartRate, .spo2]) + .map { Array($0.prefix(2)) } + XCTAssertEqual(commands, [[0x01, 0x0c], [0x01, 0x26]]) + } + + /// The BP monitor needs *both* the sensor capability and the family flag — the R10M has the sensor + /// and not the monitor, so the capability alone must not be enough. + func testBloodPressureMonitorNeedsTheFamilyFlagAsWellAsTheCapability() { + XCTAssertTrue( + encoder.monitorCommands(.allOnDefault, capabilities: [.bloodPressure]) + .contains { Array($0.prefix(2)) == [0x01, 0x1c] } + ) + XCTAssertFalse( + encoder.monitorCommands( + .allOnDefault, capabilities: [.bloodPressure], supportsBloodPressureMonitor: false + ).contains { Array($0.prefix(2)) == [0x01, 0x1c] } + ) + } + // MARK: History commands func testHistoryRequestAndBlockAckBytes() { diff --git a/PulseLoopTests/YCBTHealthRecordsTests.swift b/PulseLoopTests/YCBTHealthRecordsTests.swift index 2aea513..7ee6392 100644 --- a/PulseLoopTests/YCBTHealthRecordsTests.swift +++ b/PulseLoopTests/YCBTHealthRecordsTests.swift @@ -45,12 +45,15 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(values(.bloodPressureDiastolic, in: events).last, 70) XCTAssertFalse(events.contains { if case .bloodPressureSample = $0 { return true } else { return false } }) - // Steps are the ring's cumulative daily counter, so they ride `.activityUpdate` (per-day max), - // not an additive bucket. First record = the 23:00 daily total. - let steps = events.compactMap { event -> Int? in - if case let .activityUpdate(_, value, _, _) = event { return value } else { return nil } - } - XCTAssertEqual(steps.max(), 3336) + // Steps (@4) are deliberately **not** emitted. The field is the ring's cumulative day counter as + // of each record, and `.activityUpdate` is a per-day max ratchet — so the oldest record in a dump + // that spans midnight sets today's total to yesterday's count, and nothing can lower it again for + // the rest of the day. The `05 02` sport buckets and the live `06 00` counter are the sources + // that don't have this problem. + XCTAssertFalse( + events.contains { if case .activityUpdate = $0 { return true } else { return false } }, + "the combined record must not publish its stale cumulative step counter" + ) } /// Respiratory rate (@10) was decoded but silently dropped before A3. @@ -77,14 +80,12 @@ final class YCBTHealthRecordsTests: XCTestCase { 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() { + /// An unworn record (SpO₂ out of range, HRV 0, BP 0) carries nothing but the day's step count, and + /// that counter is no longer published from here — so the record decodes to nothing at all rather + /// than to a row asserting a measurement the ring never took. + func testUnwornCombinedVitalsRecordYieldsNothing() { let events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31080d4700000000000000000000000000")) - XCTAssertEqual(events.count, 1) - guard case .activityUpdate = events.first else { - return XCTFail("expected a lone activityUpdate, got \(events)") - } + XCTAssertTrue(events.isEmpty, "expected no events, got \(events)") } /// Records are sliced from the whole buffer, so a trailing partial record is dropped rather than @@ -288,6 +289,39 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) } + // MARK: Hostile input + + /// Segment durations are u24, so an all-ones field is 194 days. The timeline is expanded + /// *positionally* — one entry per minute from the session start — so a bogus duration doesn't + /// mis-size one stage, it allocates ~280 000 array entries for a single 8-byte record, and a buffer + /// of them exhausts memory before anything gets a chance to reject the reading. + func testSleepSegmentDurationIsClampedToOneDay() { + // header: recordLength 28 (20 header + 1 segment), then one DEEP segment of 0xFFFFFF seconds. + let record = bytes("00001c00" + "1cf0de31" + "a0f3de31" + "0000000000000000") + + bytes("02" + "1cf0de31" + "ffffff") + let events = YCBTHealthRecords.sleep(record) + + guard case let .sleepTimeline(_, stages) = events.first else { + return XCTFail("expected a sleep timeline, got \(events)") + } + XCTAssertEqual(stages.count, YCBTHealthRecords.maxSleepSessionMinutes) + XCTAssertEqual(stages.count, 24 * 60) + } + + /// A real night is nowhere near the cap, so the clamp must not touch it. + func testRealisticSleepSegmentsAreNotClamped() { + // Two segments: 30 min deep, 45 min light. + let record = bytes("00002400" + "1cf0de31" + "a0f3de31" + "0000000000000000") + + bytes("01" + "1cf0de31" + "080700") // tag 1 = deep, 1800 s + + bytes("02" + "44fdde31" + "8c0a00") // tag 2 = light, 2700 s + guard case let .sleepTimeline(_, stages) = YCBTHealthRecords.sleep(record).first else { + return XCTFail("expected a sleep timeline") + } + XCTAssertEqual(stages.count, 75) + XCTAssertEqual(stages.filter { $0 == .deep }.count, 30) + XCTAssertEqual(stages.filter { $0 == .light }.count, 45) + } + // MARK: Type table → decoder wiring /// `decode(_:type:)` is what the transfer machine actually calls; every catalog type must reach a @@ -304,9 +338,9 @@ final class YCBTHealthRecordsTests: XCTestCase { 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) + // 8 records × (systolic + diastolic + spo2 + respiratory rate + hrv); temp and blood sugar are + // the unmeasured fillers in this capture, and the cumulative step counter is not published. + XCTAssertEqual(YCBTHealthRecords.decode(capturedAllRecords, type: .all).count, 8 * 5) XCTAssertFalse(YCBTHealthRecords.decode(capturedNight, type: .sleep).isEmpty) XCTAssertFalse(YCBTHealthRecords.decode(capturedHeartRecords, type: .heart).isEmpty) } diff --git a/PulseLoopTests/YCBTHistoryTransferTests.swift b/PulseLoopTests/YCBTHistoryTransferTests.swift index 4317d8f..4a389c7 100644 --- a/PulseLoopTests/YCBTHistoryTransferTests.swift +++ b/PulseLoopTests/YCBTHistoryTransferTests.swift @@ -150,6 +150,100 @@ final class YCBTHistoryTransferTests: XCTestCase { XCTAssertTrue(writer.sent.isEmpty, "types the firmware rejected are not re-requested this session") } + // MARK: Terminal cross-check + + /// The CRC alone does not catch a **short** buffer. A dropped data frame leaves us holding fewer + /// bytes than the ring sent; cross-checking the terminal's packet and byte counts against both the + /// header's promise and the buffer we actually built turns "silently short" into a NAK and a retry. + func testTerminalWithMismatchedCountsIsNakedAndRetried() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart]) + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: Array(heartBuffer.prefix(6))) // one frame lost + writer.sent.removeAll() + + // The ring's terminal describes what it *sent* — 12 bytes — but we only hold 6. + let events = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + + XCTAssertEqual(writer.sent.first, ackCrcFailure, "a short buffer must be NAKed, not accepted") + XCTAssertEqual(writer.sent.last, heartQuery, "and the type re-requested") + XCTAssertTrue(events.isEmpty, "nothing may be decoded from a buffer we know is incomplete") + } + + /// A terminal whose counts disagree with the *header* is equally untrustworthy, even when its own + /// byte count happens to match the buffer. + func testTerminalDisagreeingWithTheHeaderIsNaked() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart]) + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 9, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: heartBuffer) + writer.sent.removeAll() + + _ = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + + XCTAssertEqual(writer.sent.first, ackCrcFailure) + } + + /// The happy path must be unaffected: matching counts and CRC still ACK and decode. + func testMatchingCountsStillAcceptTheTransfer() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart]) + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: heartBuffer) + writer.sent.removeAll() + + let events = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + + XCTAssertEqual(writer.sent.first, ackAccepted) + XCTAssertEqual(events.filter { if case .historyMeasurement = $0 { return true } else { return false } }.count, 2) + } + + // MARK: append + + /// The ring's `02 01` bitmap arrives *during* the startup walk, so the types it unlocks have to join + /// the queue without disturbing the block in flight — `start` refuses outright while active, which is + /// right for a second full pass and wrong for this. + func testAppendExtendsTheQueueWithoutDisturbingTheInFlightType() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart]) + _ = transfer.handle(cmd: 0x06, payload: header(records: 2, packets: 1, bytes: heartBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: heartBuffer) + writer.sent.removeAll() + + transfer.append(types: [.all]) + XCTAssertTrue(writer.sent.isEmpty, "append must not interrupt the block being received") + + _ = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + XCTAssertEqual(writer.sent.last, allQuery, "the appended type is requested when the queue reaches it") + } + + /// Appending while idle has to start the machine, or a bitmap that lands after a finished walk would + /// leave its types unfetched until the next sync. + func testAppendStartsTheMachineWhenIdle() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + + transfer.append(types: [.all]) + + XCTAssertEqual(writer.sent, [allQuery]) + } + + /// Asking twice would re-dump a log we already hold and re-upsert every record in it. + func testAppendIgnoresTypesAlreadyQueuedOrInFlight() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .all]) + writer.sent.removeAll() + + transfer.append(types: [.heart, .all]) + + XCTAssertTrue(writer.sent.isEmpty, "neither the in-flight type nor a queued one may be re-added") + } + // MARK: Queue composition (A3) /// The engine asks for the full nine-type catalog, in the SDK's ascending-key order @@ -160,7 +254,7 @@ final class YCBTHistoryTransferTests: XCTestCase { func testEngineRequestsEveryHistoryTypeInOrder() { let writer = FakeWriter() let transfer = YCBTHistoryTransfer(writer: writer) - let engine = YCBTSyncEngine(writer: writer, transfer: transfer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) engine.runStartup() var requested: [UInt8] = [] @@ -173,6 +267,104 @@ final class YCBTHistoryTransferTests: XCTestCase { XCTAssertEqual(requested, [0x02, 0x04, 0x06, 0x08, 0x09, 0x1a, 0x1e, 0x2f, 0x33]) } + // MARK: Capability-gated queue + + /// A ring that doesn't implement a type answers "no data" or `0xFC`, both handled — so this is not + /// about correctness, it is about not spending ten seconds of watchdog per absent type on every + /// single sync. For the R10M baseline that leaves exactly four queries. + func testR10MStartupAsksOnlyForTheLogsItKeeps() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: YCBTFamilyProfile( + baselineCapabilities: YCBTCoordinator().capabilities, + bitmapGatedCapabilities: YCBTCoordinator().bitmapGatedCapabilities, + queryChipSchemeAtStartup: false, + supportsBloodPressureMonitor: false + )) + engine.runStartup() + + var requested: [UInt8] = [] + while let query = writer.sent.last(where: { $0.count == 2 && $0[0] == 0x05 }) { + requested.append(query[1]) + writer.sent.removeAll() + _ = transfer.handle(cmd: query[1], payload: [0x00]) // "no data" → advance + } + XCTAssertEqual(requested, [0x02, 0x04, 0x06, 0x09], "sport, sleep, heart, all — and nothing else") + XCTAssertFalse(requested.contains(0x1a), "no dedicated SpO₂ log on this ring") + } + + /// A capability the bitmap unlocks mid-walk has to reach the queue, or its log waits for the next + /// sync. `.all` is re-queued alongside because BP is an *optional field* of a record we already ran — + /// so the run that just happened decoded it with that field dropped. + func testALateBitmapAppendsTheNewlyUnlockedTypes() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: YCBTFamilyProfile( + baselineCapabilities: [.heartRate], + bitmapGatedCapabilities: [.bloodPressure] + )) + // Baseline is heart-rate only, so the startup queue is heart + all (all is never gated). + engine.runStartup() + // The bitmap lands while `05 06` is still in flight — the case `start` cannot serve. + engine.handle(.supportFunctions([.bloodPressure])) + + var requested: [UInt8] = [] + while let query = writer.sent.last(where: { $0.count == 2 && $0[0] == 0x05 }) { + requested.append(query[1]) + writer.sent.removeAll() + _ = transfer.handle(cmd: query[1], payload: [0x00]) // "no data" → advance + } + XCTAssertEqual(requested, [0x06, 0x09, 0x08], "the blood-pressure log joins the queue behind the rest") + } + + /// A bitmap that lands *after* the walk finished has to restart the machine for its new types — the + /// `05 09` combined record it already ran was decoded with the BP fields dropped. + func testALateBitmapAfterTheWalkRerunsTheCombinedRecord() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: YCBTFamilyProfile( + baselineCapabilities: [.heartRate], + bitmapGatedCapabilities: [.bloodPressure] + )) + engine.runStartup() + while let query = writer.sent.last(where: { $0.count == 2 && $0[0] == 0x05 }) { + writer.sent.removeAll() + _ = transfer.handle(cmd: query[1], payload: [0x00]) + } + writer.sent.removeAll() + + engine.handle(.supportFunctions([.bloodPressure])) + + var requested: [UInt8] = [] + while let query = writer.sent.last(where: { $0.count == 2 && $0[0] == 0x05 }) { + requested.append(query[1]) + writer.sent.removeAll() + _ = transfer.handle(cmd: query[1], payload: [0x00]) + } + XCTAssertTrue(requested.contains(0x08), "the blood-pressure log is now worth asking for") + XCTAssertTrue(requested.contains(0x09), "and `all` is re-run for its optional BP fields") + } + + /// The R10M ACKs the handshake's `03 09` but doesn't always start publishing until its history dump + /// is done, so today's steps sit at whatever the last session left. One extra frame after the walk + /// fixes a reconnect that otherwise looks frozen — and it must fire exactly once. + func testLiveStatusIsReassertedOnceAfterTheStartupWalk() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) + let liveStatus = Data([0x03, 0x09, 0x01, 0x00, 0x02]) + + engine.runStartup() + writer.sent.removeAll() + + engine.handle(.historySyncFinished) + XCTAssertEqual(writer.sent, [liveStatus]) + + writer.sent.removeAll() + engine.handle(.historySyncFinished) + XCTAssertTrue(writer.sent.isEmpty, "only the startup walk earns the re-issue") + } + // MARK: Targeted passes + re-entrancy (A5) /// The post-workout backfill asks for exactly the three logs a workout can have added to — heart @@ -181,7 +373,7 @@ final class YCBTHistoryTransferTests: XCTestCase { func testSyncVitalsHistoryQueuesOnlyTheThreeVitalsTypes() { let writer = FakeWriter() let transfer = YCBTHistoryTransfer(writer: writer) - let engine = YCBTSyncEngine(writer: writer, transfer: transfer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) engine.syncVitalsHistory() @@ -198,12 +390,28 @@ final class YCBTHistoryTransferTests: XCTestCase { func testSyncHistoryRerunsTheFullCatalog() { let writer = FakeWriter() let transfer = YCBTHistoryTransfer(writer: writer) - let engine = YCBTSyncEngine(writer: writer, transfer: transfer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) + + engine.syncHistory() + + XCTAssertEqual( + writer.sent, + [Data([0x03, 0x09, 0x01, 0x00, 0x02]), Data([0x05, 0x02])], + "live status is re-asserted, then the queue starts at the first catalog type" + ) + } + + /// Pull-to-refresh is the gesture a user makes *because* today's steps look stale, and on a ring whose + /// `03 09` subscription went quiet the history walk alone won't move them: the cumulative counter + /// arrives on the `06 00` stream, not in any log. + func testSyncHistoryReassertsLiveStatusBeforeTheWalk() { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) engine.syncHistory() - XCTAssertEqual(writer.sent, [Data([0x05, 0x02])], "the queue starts at the first catalog type") - XCTAssertTrue(writer.sent.allSatisfy { $0[0] == 0x05 }, "no handshake frames — history only") + XCTAssertEqual(writer.sent.first, Data([0x03, 0x09, 0x01, 0x00, 0x02])) } /// Three callers can now ask for a transfer (connect, post-workout backfill, the 30-minute pass). A diff --git a/PulseLoopTests/YCBTSupportFunctionTests.swift b/PulseLoopTests/YCBTSupportFunctionTests.swift index be428d7..a7a8b16 100644 --- a/PulseLoopTests/YCBTSupportFunctionTests.swift +++ b/PulseLoopTests/YCBTSupportFunctionTests.swift @@ -177,7 +177,12 @@ final class YCBTSupportFunctionTests: XCTestCase { let capabilities: Set = [.heartRate, .steps, .battery] let bitmapGatedCapabilities: Set = [.temperature, .stress] let iconSystemName = "circle" - func makeDriver(writer: RingCommandWriter) -> WearableDriver { YCBTDriver(writer: writer) } + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + YCBTDriver(writer: writer, profile: YCBTFamilyProfile( + baselineCapabilities: capabilities, + bitmapGatedCapabilities: bitmapGatedCapabilities + )) + } } /// A gated capability the ring claims is added. diff --git a/README.md b/README.md index 8152785..0e336ee 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ 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** | `be940` (Yucheng YCBT) | `R99 54DC` and similar | $15–30 | +| R10M (**LittleMeatball**) | `be940` (Yucheng YCBT) | `R10M …` (e.g. `R10M FCF4`) | $15–30 | | TK5 ring — 🧪 **limited support** | `be940` (Yucheng YCBT) | `TK5 …` (e.g. `TK5 24AA`) | ❓ | > ⚠️ **A Colmi ring ships with *either* the QRing or the SmartHealth app** — two completely different @@ -149,6 +150,12 @@ declares exactly what it can do and the app shows only those features. > app" retry. Both are supported. See the > [Colmi page](https://saksham2001.github.io/PulseLoopiOS/hardware/colmi/#smarthealth-app-colmi-rings). > +> ✅ **The R10M is the best-tested YCBT ring.** It speaks the same protocol as the SmartHealth-app +> Colmi rings and the TK5, but it is a different vendor's ring, so it pairs on its own — no app-type +> question — and carries its own capability set. Pairing, sync, HR/SpO₂/BP, battery and sleep are all +> validated on firmware 2.32. See the +> [R10M page](https://saksham2001.github.io/PulseLoopiOS/hardware/r10m/). +> > 🧪 **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 diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 8c92fe8..bf1dbe5 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -163,7 +163,8 @@ The VC30F is the PPG bio-sensor used in R10, R11, and R12: 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. +[TK5](tk5.md)'s and the [R10M](r10m.md)'s protocol byte for byte, so all three run the same shared +driver. ### Which rings @@ -254,4 +255,4 @@ The manufacturer publishes firmware update images with no authenticity checks See the [hardware overview](index.md) for the full cross-manufacturer comparison 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, whose driver it shares. +SmartHealth app — the [TK5](tk5.md) and [R10M](r10m.md) pages, whose driver it shares. diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 05929ab..a11d7a5 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -15,7 +15,7 @@ section breaks the hardware down by manufacturer. This is a cross-platform reference shared by both the iOS and Android ports — they drive the same rings over the same reverse-engineered BLE protocols. Compiled from project documentation, web research, product pages, and - teardowns. *Last updated: 2026-06-25.* + teardowns. *Last updated: 2026-07-25.* !!! warning "No affiliation" PulseLoop has no affiliation with the sellers or manufacturers of any ring @@ -46,6 +46,16 @@ section breaks the hardware down by manufacturer. [:octicons-arrow-right-24: Colmi / Yawell](colmi.md) +- :material-ring: __R10M / LittleMeatball__ + + --- + + ✅ Supported. Yucheng **YCBT** protocol (SmartHealth app), **validated end to + end on hardware** — FW 2.32. Not a Colmi ring despite the shared protocol, so + it gets its own family and capability set. + + [:octicons-arrow-right-24: R10M / LittleMeatball](r10m.md) + - :material-flask-outline: __TK5 / SmartHealth__ --- @@ -103,59 +113,62 @@ section breaks the hardware down by manufacturer. 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 | 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** | ❌ | ❓ | ✅ | ✅ | ✅ | ✅ | -| **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) | ❓ | -| **Display** | ❌ | ❌ | ❌ | ✅ | ❌ | ❓ | -| **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² | 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) | ❓ | ❓ | ❓ | ❌³ | -| **Custom firmware** | ✅ (SR08 ref) | ✅ (RF03 ref) | ❓ | ❓ | ❓ | ❓ | -| **PulseLoop support** | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| | 56ff / Jring | Colmi R02/R03/etc | Colmi R10 | Colmi R12 | Colmi R11 | TK5 | R10M | +|---|---:|---:|---:|---:|---:|---:|---:| +| **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 | BLE | +| **PPG sensor** | Unknown (HR/SpO₂) | Unknown | Vcare VC30F | Vcare VC30F | Vcare VC30F | ❓ | ❓ (green + red/IR) | +| **PPG LEDs** | Unknown | Unknown | Red + green (dual) | Red + green (dual) | Red + green (dual) | Green + red/IR | Green + red/IR | +| **Accelerometer** | Yes | Unknown | STK8321 | ST LIS2DOC | STK8321 | Yes (❓ part) | Yes (❓ part) | +| **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 | ❓ | ~4–5 days⁹ | +| **Charging case** | ❌ | ❌ | ✅ (200 mAh) | ❌ | ✅ (200 mAh) | ❓ | ❌ | +| **Display** | ❌ | ❌ | ❌ | ✅ | ❌ | ❓ | ❌ | +| **Waterproof** | Varies by seller | IP68 / 3ATM | 5ATM | IP68 + 1ATM | IP68 + 5ATM | ❓ | IP68⁹ | +| **Weight** | Unknown | Unknown | Unknown | ~4 g | Unknown | ❓ | ~3.6–4.5 g | +| **Price** | $7–12 | $15–25 | $15–25 | ~$30 | ~$15–25 | ❓ | $15–30 | +| **Protocol** | Custom 56ff | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing | Nordic-UART QRing² | Yucheng YCBT (`be940`) | Yucheng YCBT (`be940`) | +| **Frame size** | Fixed 20 bytes | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | Variable (CRC16) | Variable (CRC16) | +| **Encryption** | None | None | None | None | None | None³ | None | +| **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. ³ 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. +⁹ Reseller-listed, not measured. The R10M's internals have not been identified beyond what its exposed PCB shows. ## Supported Rings — Capabilities 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 | ✅ | ✅ | ❓ | ❓ | ❓ | ❌⁵ | ❌⁵ | +own column — same hardware, different protocol, different driver. The last three columns are the three +[YCBT](r10m.md#not-the-only-ring-that-speaks-it) families, which share one driver: only the **R10M** has +been validated end to end on hardware. + +| Capability | 56ff / Jring | Colmi R02/etc | Colmi R10 | Colmi R12 | Colmi R11 | Colmi (SmartHealth)⁶ | TK5 | R10M | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 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. @@ -165,6 +178,7 @@ own column — same hardware, different protocol, different driver. ⁶ 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. The family is **verified on hardware** (an R99 runs against the driver daily — connect, battery, activity/sleep/HR/BP/vitals sync); 🧪 marks the capabilities not yet individually cross-checked against the vendor app. 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. +¹⁰ From the `05 09` combined record, not a dedicated log — the R10M has no `05 1A` all-day SpO₂ history, so PulseLoop never issues that query for it. 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. @@ -192,6 +206,7 @@ Multiple hardware platforms span from $7 commodity rings to $350 premium devices | **[Colmi / Yawell (SmartHealth)](colmi.md#smarthealth-app-colmi-rings)** | Realtek RTL8762 family | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | ✅ App (same protocol as the TK5; verified on an R99) | | **[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) | +| **[R10M / LittleMeatball](r10m.md)** | ❓ | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | ✅ App (validated on FW 2.32) | | **[LuckRing / TK18](luckring.md)** | ❓ (Coolwear/Kewo OEM) | Custom "K6" (`F618`) | LuckRing | ~$10 | 🧪 App (limited) | | **[SIMSONLAB](simsonlab.md)** | Phyplus PHY6222 | Unknown | SIMSONLAB app | ~$10–20 | ❌ | @@ -263,7 +278,8 @@ full detail, see each manufacturer's page. | **On-ring display** | [Colmi R12](colmi.md) | | **Best waterproofing** | [Colmi R10 or R11](colmi.md) (5ATM) | | **Best HR accuracy** | [Colmi R10/R12](colmi.md) (VC30F sensor, verified accuracy) | -| **Works with PulseLoop today** | [56ff Jring](jring.md) or [Colmi QRing family](colmi.md) | +| **Works with PulseLoop today** | [56ff Jring](jring.md), the [Colmi QRing family](colmi.md), or the [R10M](r10m.md) | +| **A YCBT ring that's actually been tested** | [R10M / LittleMeatball](r10m.md) — the only one validated on hardware | --- diff --git a/docs/hardware/r10m.md b/docs/hardware/r10m.md new file mode 100644 index 0000000..c3a52f8 --- /dev/null +++ b/docs/hardware/r10m.md @@ -0,0 +1,153 @@ +--- +title: R10M / LittleMeatball +description: >- + The LittleMeatball R10M ring — the Yucheng YCBT protocol, validated end to end + on firmware 2.32. +--- + +# R10M / LittleMeatball + +**PulseLoop support: ✅ Supported — validated on hardware (R10M FCF4, firmware 2.32)** + +The R10M is sold as the **LittleMeatball** smart ring (and unbranded by a long tail of resellers — +ORIWHIZ, JTLlink, and others list the same `R10M` hardware). It pairs with the **SmartHealth** app and +speaks the **Yucheng YCBT** protocol on a `be940` service, the same wire format as the +[TK5](tk5.md) and the [SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings). + +!!! success "This is the best-validated YCBT ring PulseLoop drives" + Pairing, the handshake, reconnect and day rollover; activity and history sync; HR, SpO₂ and blood + pressure both as history and as spot measurements; battery; sleep stages and REM — all confirmed on + a physical R10M running firmware 2.32. + + The support was [ported from the Android app](https://github.com/foureight84/PulseLoopAndroid/pull/31), + where that validation was done. The iOS port shares no code with it, but it does share the protocol + and the two firmware workarounds below. + +## Not the only ring that speaks it + +The R10M is one of **three** ring families PulseLoop drives over YCBT — the others are the +[TK5](tk5.md) and [Colmi rings that ship with SmartHealth](colmi.md#smarthealth-app-colmi-rings). The +protocol is byte-identical, so all three share the whole driver (the device-neutral `YCBT*` core); +each family adds only a coordinator with its advertisement matcher and capability set, plus a small +profile for firmware quirks. + +**It is deliberately not filed under Colmi.** The R10M is a different vendor's ring that happens to +speak the same protocol. Folding it into the Colmi family would have it inherit Colmi product art and +Colmi capability claims, and would put an "is this a QRing or a SmartHealth ring?" question in front of +an owner whose ring only ever shipped one firmware. + +| | R10M | TK5 | SmartHealth-Colmi | +|---|---|---|---| +| **Advertisement** | `R10M <4 hex>` (space or underscore) **and** the `be940000` service | `TK5 <4 hex>`; service not advertised | a Colmi-line name, which a *QRing* Colmi can also carry — so PulseLoop asks which app the ring came with | +| **Hardware validation** | ✅ full session, FW 2.32 | ❌ none — protocol proven on a sibling | ✅ an R99 runs it daily | +| **Chip scheme query** (`02 1b`) | ❌ suppressed — drops the link | ✅ answered | ✅ answered | +| **All-day BP monitor** (`01 1c`) | ❌ not implemented | ✅ sent | ✅ sent | +| **Dedicated SpO₂ log** (`05 1a`) | ❌ never queried | ✅ queried | ✅ queried | + +A fix to any `YCBT*` file fixes all three rings; a regression in one breaks all three. + +## At a glance + +| | Detail | +|---|---| +| **SoC** | ❓ Unknown | +| **Bluetooth** | BLE (version ❓) | +| **PPG sensor** | ❓ Unknown — green LED for HR, red/IR for SpO₂ (both visible in the ring's exposed PCB) | +| **Accelerometer** | Yes (steps and sport buckets decoded; part ❓) | +| **Battery / life** | ❓ Unknown (resellers claim ~4–5 days) | +| **Waterproof** | IP68 per the reseller listings | +| **Weight** | ~3.6–4.5 g | +| **Sizes** | 7 (17.3 mm) – 13 (22.2 mm) | +| **Price** | ~$15–30 depending on seller and finish | +| **Protocol** | Yucheng **YCBT** — variable-length frames, CRC16/CCITT-FALSE, cleartext | +| **App** | SmartHealth | +| **Advertised name** | `R10M <4 hex>` — e.g. `R10M FCF4`; `R10M_FCF4` also observed | +| **Firmware tested** | 2.32 | +| **Custom firmware** | ❓ Unknown | + +## Protocol + +Identical to the [TK5's](tk5.md#protocol) — same service, same framing, same history state machine: + +| Property | Value | +|---|---| +| **Service** | `be940000-7333-be46-b7ae-689e71722bd5` (advertised, unlike the TK5's) | +| **Command char** | `be940001` — write **and** indicate | +| **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) | +| **History** | `05 ` query → header → concatenated data frames → `05 80` terminal → **mandatory `05 80 {00}` ACK** | +| **Encryption** | None. No `AE00` / JieLi RCSP service on this ring | + +**Both indication channels must be live before the handshake starts.** Command replies — device info, +the capability bitmap, every ACK — arrive on `be940001`, while live and history data arrive on +`be940003`. PulseLoop waits for both, then writes the name read and the clock ahead of everything else. +A ring missing either channel fails the connect attempt with a visible error rather than hanging +half-connected. + +## Firmware quirks + +Two commands are suppressed for this family, and only this family: + +| Command | Why | +|---|---| +| `02 1b` **GetChipScheme** | The R10M closes an otherwise healthy connection with HCI `0x13` when asked. It is purely informational — nothing depends on the answer — so it is simply not sent. | +| `01 1c` **all-day BP monitor** | The ring has the blood-pressure *sensor* but does not implement the all-day monitor. The flag is separate from the capability for exactly this reason. | + +The TK5 and SmartHealth-Colmi families answer both fine and still send them. + +## Capabilities + +**Ring-declared vs. baseline.** The rows marked **🔓 ring-declared** are offered only if *this* unit's +`02 01` capability bitmap sets their bit. Everything else is a **baseline** promise — claimed +unconditionally, and the bitmap can only ever *add*, never remove. + +**Firmware 2.32 declares none of the gated bits**, so a stock R10M shows its baseline plus blood +pressure. They stay listed because "R10M" is a model, not a firmware, and the gate costs a ring that +*does* have them nothing. + +| Capability | Status | Notes | +|---|:---:|---| +| Heart rate — spot | ✅ | `03 2f` mode `00` → `06 01` stream | +| Heart rate — live | ✅ | `06 01` stream | +| Heart rate — history | ✅ | `05 06` query, 6-byte records | +| SpO₂ — spot | ✅ | red/IR LED, `03 2f` mode `02` | +| SpO₂ — history | ✅ | **from the `05 09` combined record only** — this ring has no dedicated `05 1a` log, so that query is never issued | +| Steps / distance / calories | ✅ | `05 02` history buckets + the live `06 00` counter | +| Sleep (light / deep / awake) | ✅ | `05 04` timeline; stage = `tag & 0x0F` | +| REM sleep | ✅ | stage tag `3` — a stage *inside* the timeline `ISHASSLEEP` grants, so no bit names it and it is not gated | +| Blood pressure — history | 🔓 | ring-declared (`ISHASBLOOD`, byte 0 bit 0). Dedicated `05 08` log + the combined record | +| Blood pressure — spot | 🔓 | ring-declared (`ISHASTESTBLOOD`, byte 15 bit 2). `03 2f` mode `01`; no cuff calibration, so treat as a trend, not a number | +| HRV | 🔓 | ring-declared (`ISHASHRV`, byte 1 bit 1). **Gated, unlike the TK5** — nobody has seen an R10M produce an HRV figure, and FW 2.32 does not declare the bit | +| Skin temperature | 🔓 | ring-declared (`ISHASTEMP`, byte 8 bit 0) | +| Stress | 🔓 | ring-declared (`IS_HAS_PRESSURE`, byte 22 bit 6), from the body-data record (`05 33`) | +| Fatigue | 🔓 | ring-declared on the **same bit as stress** — one record, one bit, two fields | +| Blood sugar | 🔓 | ring-declared (`ISHASBLOODSUGAR`, byte 17 bit 3) | +| Respiratory rate | ✅ | combined record @10 | +| Battery level | ✅ | in-band: `02 00` reply payload[5], plus the unprompted `06 15` push | +| Find device | 🔓 | ring-declared (`ISHASFINDDEVICE`, byte 6 bit 4) — not declared by FW 2.32 | +| Measurement intervals | ✅ | the `01 xx {enable, interval}` monitors, **filtered to the sensors this ring declares**; floored at the firmware's 30-min minimum | +| Periodic re-sync while connected | ✅ | every 30 min, plus a post-workout vitals pass | +| Continuous background sync | ❌ | the ring is only read while connected | +| FW update via app | ❌ | not implemented | + +## Known limitations + +- **No dedicated SpO₂ history log.** All-day SpO₂ comes from the `05 09` combined record instead. This + is why `spo2History` is claimed neither as baseline nor as a gated capability — that is precisely what + stops the `05 1a` query being issued. +- **Blood pressure is uncalibrated.** There is no cuff reference, so the number is a PPG-derived trend. +- **~8-day history horizon.** History samples, sleep sessions and activity timestamps outside + `now − 8 days … now + 1 hour` are dropped, because a ring's log can hold records stamped under a + *previous* clock and history rows upsert — one misdecoded record would re-persist on every sync. +- **No background sync while disconnected.** The ring keeps logging on its own schedule; PulseLoop + reads it on connect, every 30 minutes thereafter, and after a workout. +- **The ring's log is never deleted.** As with every YCBT ring, PulseLoop does not issue the + Health-Delete opcodes, so the ring replays its whole log on each sync and deduplication is app-side. + See [how PulseLoop diverges from SmartHealth](tk5.md#how-pulseloop-diverges-from-smarthealth). + +--- + +See the [TK5](tk5.md) and 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/docs/hardware/tk5.md b/docs/hardware/tk5.md index 837aafe..f1cf499 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -24,18 +24,20 @@ YCBT** protocol on a `be940` service — nothing in common at the wire level wit ## 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. -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: +The TK5 is one of **three** ring families PulseLoop drives over YCBT. The others are the +**[R10M / LittleMeatball](r10m.md)** and **[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, +plus a small profile for firmware quirks. They differ in three ways: -| | TK5 | SmartHealth-Colmi | -|---|---|---| -| **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 | +| | TK5 | R10M | SmartHealth-Colmi | +|---|---|---|---| +| **Advertisement** | `TK5 <4 hex>` — unambiguous, so it auto-detects | `R10M <4 hex>` **and** the `be940000` service | a Colmi-line name, which a *QRing* Colmi can also carry, so PulseLoop asks which app the ring came with | +| **Hardware validation** | none — the protocol is proven on a sibling, this ring isn't | ✅ full session on FW 2.32 | an R99 runs it daily | +| **SupportFunction bitmap** (`02 01`) | gates temperature, BP, stress, fatigue, blood sugar. HRV is **not** gated — it was observed working on this ring | gates all of those *and* HRV | gates those *and* HRV, which the tested R09 denies | -A fix to any `YCBT*` file fixes both rings; a regression in one breaks both. +A fix to any `YCBT*` file fixes all three rings; a regression in one breaks all three. The R10M also +suppresses two commands the other two send — see [its firmware quirks](r10m.md#firmware-quirks). ## At a glance @@ -166,8 +168,10 @@ replay, so a double-sync produces no duplicates. The cost is a longer sync as th 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. - **Timestamps are timezone-naive.** The ring stores local wall-clock seconds with no timezone byte, - so the decoder un-applies the device's UTC offset to recover the true instant. This is exact for - same-session syncs and can be an hour off across a DST transition. + so the decoder un-applies the device's UTC offset to recover the true instant. The offset is + resolved at each record's *own* date, so history read across a DST change decodes correctly; the + only residue is the ambiguous hour a fall-back repeats and the hour a spring-forward skips, which + the wire format simply cannot disambiguate. --- diff --git a/mkdocs.yml b/mkdocs.yml index 08d2301..949efe1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - Overview: hardware/index.md - Jring / 56ff: hardware/jring.md - Colmi / Yawell (QRing + SmartHealth): hardware/colmi.md + - R10M / LittleMeatball: hardware/r10m.md - TK5 / SmartHealth: hardware/tk5.md - LuckRing / TK18: hardware/luckring.md - SIMSONLAB: hardware/simsonlab.md