Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions PulseLoop/Assets.xcassets/r10m.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "r10m.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
}
}
36 changes: 34 additions & 2 deletions PulseLoop/RingProtocol/RingBLEClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
/// `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 `<MODEL> <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,
Expand Down Expand Up @@ -106,6 +111,10 @@
/// 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<CBUUID> = []
private var batteryCharacteristic: CBCharacteristic?

// MARK: Active driver / engine (selected per connection)
Expand Down Expand Up @@ -297,6 +306,19 @@
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)
Expand Down Expand Up @@ -358,6 +380,7 @@
central.cancelPeripheralConnection(old)
}
writeChar = nil; commandChar = nil; notifyChars = [:]; batteryCharacteristic = nil
subscribedNotifyUUIDs = []
writeInFlight = false; writeQueue = []
peripheral = target
target.delegate = self
Expand Down Expand Up @@ -732,6 +755,7 @@
writeChar = nil
commandChar = nil
notifyChars = [:]
subscribedNotifyUUIDs = []
batteryCharacteristic = nil
writeInFlight = false
writeQueue = []
Expand Down Expand Up @@ -817,8 +841,12 @@
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
Expand All @@ -844,6 +872,10 @@
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()
}
Expand Down Expand Up @@ -906,4 +938,4 @@
pumpWrites()
}
}
}

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

View workflow job for this annotation

GitHub Actions / SwiftLint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 622 (file_length)
10 changes: 9 additions & 1 deletion PulseLoop/RingProtocol/TK5Coordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
}
}
134 changes: 134 additions & 0 deletions PulseLoop/RingProtocol/YCBTCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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..<normalized.endIndex, in: normalized)
return regex.firstMatch(in: normalized, options: [], range: range) != nil
}

// MARK: - Capabilities

/// The floor, and every entry is backed by the R10M FW 2.32 hardware session: HR and SpO₂ (live and
/// history), day steps, the deep/light/REM sleep timeline, and the in-band battery.
///
/// **A baseline entry is an unconditional promise** — the refinement is additive-only
/// (`WearableCoordinator.refinedCapabilities`), so the ring's own bitmap can never take one back.
/// Everything sensor-dependent is therefore in `bitmapGatedCapabilities`, including HRV: unlike the
/// TK5, whose HRV was observed and cross-checked against the vendor app, nobody has seen an R10M
/// produce an HRV figure, and FW 2.32 does not declare the bit.
///
/// `.remSleep` is a stage tag (`3`) *inside* the `05 04` timeline that `ISHASSLEEP` already grants —
/// no bit names it, so gating it would make it permanently unreachable rather than deferred. Same
/// shape as the TK5's reasoning.
///
/// `.measurementInterval` surfaces the Measurement-settings screen. It is a settings screen, not a
/// sensor: the monitors are now capability-filtered before they are sent (`YCBTEncoder`), so a ring
/// only ever receives the `01 xx` writes for sensors it declares.
///
/// **`.spo2History` is deliberately in neither set.** The R10M has no dedicated `05 1a` SpO₂ log, and
/// `YCBTSyncEngine` gates that query on this capability — so leaving it out is what stops the query
/// being issued at all. Its all-day SpO₂ arrives in the `05 09` combined record instead.
let capabilities: Set<WearableCapability> = [
.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<WearableCapability> = [
.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
))
}
}
6 changes: 5 additions & 1 deletion PulseLoop/RingProtocol/YCBTDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))]
Expand Down
Loading
Loading