diff --git a/PulseLoop/Assets.xcassets/tk5.imageset/Contents.json b/PulseLoop/Assets.xcassets/tk5.imageset/Contents.json new file mode 100644 index 0000000..b3599b8 --- /dev/null +++ b/PulseLoop/Assets.xcassets/tk5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tk5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseLoop/Assets.xcassets/tk5.imageset/tk5.png b/PulseLoop/Assets.xcassets/tk5.imageset/tk5.png new file mode 100644 index 0000000..7bc85ac Binary files /dev/null and b/PulseLoop/Assets.xcassets/tk5.imageset/tk5.png differ diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index e84fc10..6e36988 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -30,6 +30,7 @@ final class RingBLEClient: NSObject { static let coordinators: [WearableCoordinator.Type] = [ JringCoordinator.self, ColmiCoordinator.self, + TK5Coordinator.self, ] struct DiscoveredRing: Identifiable, Equatable { @@ -598,17 +599,21 @@ extension RingBLEClient: CBPeripheralDelegate { MainActor.assumeIsolated { guard let driver = activeDriver else { return } for characteristic in service.characteristics ?? [] { - if characteristic.uuid == driver.writeUUID { - writeChar = characteristic - } else if characteristic.uuid == driver.commandUUID { - commandChar = characteristic - } else if driver.notifyUUIDs.contains(characteristic.uuid) { - notifyChars[characteristic.uuid] = characteristic + let uuid = characteristic.uuid + // Write / command / notify are checked independently (not mutually exclusive) because a + // device can expose one characteristic that is *both* the write target and a notify + // source — the TK5's `be940001` receives command replies on the same char it's written + // to. jring/Colmi keep these on distinct UUIDs, so their behavior is unchanged. + if uuid == driver.writeUUID { writeChar = characteristic } + if uuid == driver.commandUUID { commandChar = characteristic } + if driver.notifyUUIDs.contains(uuid) { + notifyChars[uuid] = characteristic peripheral.setNotifyValue(true, for: characteristic) - } else if characteristic.uuid == driver.batteryCharUUID { + } + if uuid == driver.batteryCharUUID { batteryCharacteristic = characteristic peripheral.readValue(for: characteristic) - } else if firmwareCharUUIDs.contains(characteristic.uuid) { + } else if firmwareCharUUIDs.contains(uuid) { peripheral.readValue(for: characteristic) } } diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift new file mode 100644 index 0000000..872a8b6 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -0,0 +1,52 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for the TK5 ring (SmartHealth app). Declares the capabilities we can actually decode +/// and recognizes the device from its advertisement. +/// +/// Recognition is name-first: the TK5's proprietary `be940000` service is **not advertised** (only +/// standard Heart Rate + a generic `FEE7` service are), so the reliable signal is the `TK5 …` local +/// name, backed up by the manufacturer-data prefix observed in the nRF capture. +@MainActor +final class TK5Coordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .tk5 + + /// Manufacturer-data prefix from the capture (`10786501…`, company 0x7810). The trailing bytes are + /// device-specific (they echo the name suffix), so only the prefix is matched. + private static let manufacturerHexPrefix = "10786501" + + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + if let name, name.uppercased().hasPrefix("TK5") { return true } + if WearableModel.model(advertisedName: name)?.family == .tk5 { return true } + if let mfg = advertisement.manufacturerData, mfg.hexString.hasPrefix(manufacturerHexPrefix) { + return true + } + return false + } + + /// Metrics decoded from the captures: live + history HR, live SpO₂, day steps, HRV (history + + /// live, verified against the app's displayed values), blood pressure, the deep/light/REM sleep + /// timeline, and the in-band battery. Stress is *not* claimed — the ring doesn't store it; + /// SmartHealth derives it from HRV app-side. Temperature is omitted until a capture contains it, + /// so no empty card appears. + /// + /// `manualHeartRate` / `manualSpo2` / `manualHrv` surface the "Measure now" buttons in Vitals: a + /// spot reading toggles the live `03 2f` stream on, collects the first good sample from the + /// `06 01` (HR) / `06 02` (SpO₂) / `06 03` (HRV) frames, then toggles it off. All three ride the + /// one stream, so any of them can be measured on demand. + let capabilities: Set = [ + .heartRate, .spo2, .steps, .battery, .hrv, .bloodPressure, + .sleep, .remSleep, + .manualHeartRate, .manualSpo2, .manualHrv, + .realtimeHeartRate, .realtimeSteps, + .findDevice, + ] + + let iconSystemName = "circle.circle.fill" + + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + TK5Driver(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift new file mode 100644 index 0000000..70738da --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -0,0 +1,180 @@ +import Foundation + +/// Decodes inbound TK5 frames into the shared `RingDecodedEvent`. Frames arrive already +/// CRC-validated (see `TK5Frame`), from either the command channel (be940001) or the async stream +/// (be940003); this decoder dispatches on `(type, cmd)` regardless of channel. +/// +/// Confidence tags mirror the codebase convention: fields read straight out of the capture are +/// trusted; guessed offsets are tagged `// UNVERIFIED (capture-inferred)`. Everything routes through +/// `RingEventBridge`, which range-gates each metric, so a misdecoded byte is dropped rather than +/// persisted as garbage. +struct TK5Decoder { + /// Decode one validated frame into the events it carries (usually one; unknown frames → `.unknown`). + func decode(_ frame: TK5Frame, now: Date = Date()) -> [RingDecodedEvent] { + switch (frame.type, frame.cmd) { + + // MARK: Async live stream (be940003, type 0x06) + + case (TK5FrameType.stream, TK5Command.liveStatus): + // Cumulative day totals. steps verified against capture (0x027b = 635); distance/calories + // are the adjacent u16s — UNVERIFIED (capture-inferred), but `applyActivityUpdate` uses + // max() so an over-read can't corrupt the day. + let p = frame.payload + guard p.count >= 2 else { return [.commandAck(commandId: frame.cmd)] } + let steps = TK5Bytes.u16(p, 0) + let distance = Double(TK5Bytes.u16(p, 2)) + let calories = Double(TK5Bytes.u16(p, 4)) + return [.activityUpdate(timestamp: now, steps: steps, distanceMeters: distance, calories: calories)] + + case (TK5FrameType.stream, TK5Command.liveHeartRate): + // 1-byte live bpm. Verified (climbed 82→86 across the capture). + guard let bpm = frame.payload.first else { return [.commandAck(commandId: frame.cmd)] } + return [.heartRateSample(bpm: Int(bpm), timestamp: now)] + + case (TK5FrameType.stream, TK5Command.liveSpo2): + // 1-byte live SpO₂ % from the mode-0x02 (red-LED) stream; values sat in 95–98 in the + // capture. Gate to a plausible range so a warm-up 0 isn't surfaced as a reading (the event + // bridge doesn't range-gate SpO₂). + guard let spo2 = frame.payload.first, (70...100).contains(spo2) else { + return [.commandAck(commandId: frame.cmd)] + } + return [.spo2Result(value: Int(spo2), timestamp: now)] + + case (TK5FrameType.stream, TK5Command.liveExtended): + return decodeLiveExtended(frame.payload, cmd: frame.cmd, now: now) + + // MARK: History records (be940003, type 0x05) + + case (TK5FrameType.register, TK5Command.historyRecordShort): + // Packed **6-byte** HR history records: `[ts:4][flag:1][hr:1]`. One frame carries many — + // e.g. eight hourly overnight samples (71,66,63,62,66,60,65,58 bpm @23:00–06:00, matched + // to wall-clock) — so decode *every* record, not just the first (the earlier bug that hid + // periodic data). hr is at offset 5. + return records(in: frame.payload, size: 6).compactMap { r in + let hr = r[5] + guard hr > 0 else { return nil } + return .historyMeasurement(kind: .heartRate, value: Double(hr), + timestamp: TK5Bytes.date(TK5Bytes.u32(r, 0))) + } + + case (TK5FrameType.register, TK5Command.historyRecordLong): + return decodeCombinedVitals(frame.payload, cmd: frame.cmd) + + // MARK: Command channel (be940001) + + case (TK5FrameType.device, TK5Command.status): + // 30-byte status. Battery at payload[5] (0x64 = 100 in the capture). UNVERIFIED + // (capture-inferred); guarded to 0…100 downstream. + let p = frame.payload + var events: [RingDecodedEvent] = [.status(address: nil)] + if p.count >= 6 { events.append(.battery(percent: Int(p[5]))) } + return events + + case (TK5FrameType.device, TK5Command.deviceInfo): + // 66-byte device/firmware block. A readable version string couldn't be pinned to a fixed + // offset from one capture, so we just re-assert connected state here. + return [.status(address: nil)] + + case (TK5FrameType.config, TK5Command.setTime): + return [.timeSyncAck(timestamp: now)] + + default: + return [.commandAck(commandId: frame.cmd)] + } + } + + /// The `06 03` live frame carries two shapes depending on the active measurement mode: + /// BP (mode 0x01): `[sys][dia][hr?]…` — verified against the app (111/74, 112/75) + /// HRV (mode 0x0a): `[0 0 0][hrv]…` — verified against the app (79, 177 ms) + /// Distinguish by the leading bytes; both are range-gated downstream. + private func decodeLiveExtended(_ p: [UInt8], cmd: UInt8, now: Date) -> [RingDecodedEvent] { + if p.count >= 2, (60...250).contains(p[0]), (30...160).contains(p[1]) { + return [.bloodPressureSample(systolic: Int(p[0]), diastolic: Int(p[1]), timestamp: now)] + } + if p.count >= 4, p[3] > 0 { + return [.hrvSample(value: Int(p[3]), timestamp: now)] + } + return [.commandAck(commandId: cmd)] + } + + /// Packed **20-byte** combined-vitals history records: `[ts:4][steps:2][hr@6][sys?@7][dia?@8] + /// [spo2@9][?@10][hrv@11]…`, many per frame. Emit periodic SpO₂ + HRV (HR comes from the paired + /// `05 15` stream, so it isn't re-emitted here) plus per-day steps. HRV verified against the app + /// (48/79 ms); SpO₂ inferred (95–98) and range-gated; BP verified (106/70 @6:00). Steps are a + /// *cumulative* daily counter (rises through the day, resets to 0 at midnight), so they're emitted + /// as an `activityUpdate` (per-day max) — not an additive bucket — with distance/calories 0 so + /// `max()` leaves any live-status values intact. + private func decodeCombinedVitals(_ payload: [UInt8], cmd: UInt8) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for r in records(in: payload, size: 20) { + let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) + events.append(.activityUpdate(timestamp: ts, steps: TK5Bytes.u16(r, 4), + distanceMeters: 0, calories: 0)) + if (60...250).contains(r[7]), (30...160).contains(r[8]) { + events.append(.bloodPressureSample(systolic: Int(r[7]), diastolic: Int(r[8]), timestamp: ts)) + } + if (70...100).contains(r[9]) { + events.append(.historyMeasurement(kind: .spo2, value: Double(r[9]), timestamp: ts)) + } + if r[11] > 0 { + events.append(.historyMeasurement(kind: .hrv, value: Double(r[11]), timestamp: ts)) + } + } + return events.isEmpty ? [.commandAck(commandId: cmd)] : events + } + + /// Decode a fully-reassembled sleep record (see `TK5Driver` for the multi-frame reassembly). + /// Layout: a 20-byte header `[magic:2][totalLen:2 LE][startTs:4][endTs:4]…` followed by 8-byte + /// stage segments `[stage:1][startTs:4 LE][durationSec:2 LE][pad:1]`. Segments are contiguous, so + /// we expand each into per-minute `SleepStage`s and emit one `.sleepTimeline` anchored at the first + /// segment. Stage mapping verified against the app's on-screen breakdown (deep 93 / light 249 / + /// rem 130 min): `0xf1`=deep, `0xf2`=light, `0xf3`=rem, `0xf4`=awake. + func decodeSleep(_ record: [UInt8]) -> [RingDecodedEvent] { + let headerLen = 20 + let segmentLen = 8 + guard record.count >= headerLen + segmentLen else { return [] } + + var stages: [SleepStage] = [] + var startDate: Date? + var i = headerLen + while i + segmentLen <= record.count { + let tag = record[i] + guard let stage = sleepStage(tag) else { break } // stop at padding / unknown tail + let segStart = TK5Bytes.u32(Array(record[i...]), 1) + let durationSec = TK5Bytes.u16(Array(record[i...]), 5) + if startDate == nil { startDate = TK5Bytes.date(segStart) } + let minutes = Int((Double(durationSec) / 60.0).rounded()) + stages.append(contentsOf: Array(repeating: stage, count: max(1, minutes))) + i += segmentLen + } + + guard let start = startDate, !stages.isEmpty else { return [] } + return [.sleepTimeline(timestamp: start, stages: stages)] + } + + /// Map a TK5 sleep stage tag to the shared `SleepStage`. Verified against the app's displayed + /// deep/light/REM minutes; `nil` signals a non-stage byte (end of segment list). + private func sleepStage(_ tag: UInt8) -> SleepStage? { + switch tag { + case 0xf1: return .deep + case 0xf2: return .light + case 0xf3: return .rem + case 0xf4: return .awake + default: return nil + } + } + + /// Split a packed history payload into fixed-size records, dropping any short trailing remainder. + /// TK5 history frames concatenate many equal-size records (e.g. an hour's worth of samples), so a + /// decoder must walk them all rather than reading only the first. + private func records(in payload: [UInt8], size: Int) -> [[UInt8]] { + guard size > 0 else { return [] } + var out: [[UInt8]] = [] + var i = 0 + while i + size <= payload.count { + out.append(Array(payload[i..<(i + size)])) + i += size + } + return out + } +} diff --git a/PulseLoop/RingProtocol/TK5Driver.swift b/PulseLoop/RingProtocol/TK5Driver.swift new file mode 100644 index 0000000..09b034e --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Driver.swift @@ -0,0 +1,94 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// TK5 driver. Owns the length-prefixed CRC16 framing and the split-channel topology: the command +/// characteristic `be940001` is *both* the write target and a notify source (command replies), while +/// `be940003` carries the async live/history stream. The standard `180D`/`2A37` Heart Rate +/// characteristic is deliberately left unsubscribed — see the BLE-topology note below. +/// +/// Because `be940001` is simultaneously the write and a notify characteristic, `RingBLEClient`'s +/// discovery subscribes any `notifyUUIDs` entry even when it also matches `writeUUID`. +@MainActor +final class TK5Driver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let decoder = TK5Decoder() + + init(writer: RingCommandWriter) { + self.writer = writer + } + + // MARK: BLE topology + // + // Only the proprietary `be940000` service is used. The standard `180D`/`2A37` Heart Rate + // characteristic is intentionally NOT subscribed: on the TK5 it emits a cached resting HR + // periodically even when the ring is off the finger (observed as a constant ~87 bpm), which would + // override a real on-demand measurement. The official app never subscribes it either — live HR + // comes solely from the proprietary `06 01` stream, which reflects actual finger contact. + let serviceUUIDs: [CBUUID] = [CBUUID(string: TK5UUIDs.service)] + let writeUUID = CBUUID(string: TK5UUIDs.command) + let notifyUUIDs: [CBUUID] = [ + CBUUID(string: TK5UUIDs.command), // command replies (also the write char) + CBUUID(string: TK5UUIDs.stream), // async live + history stream + ] + let batteryServiceUUID: CBUUID? = nil // battery is in-band (0x02 0x00 status, payload[5]) + let batteryCharUUID: CBUUID? = nil + + // MARK: Framing + func frame(_ command: Data) -> Data { + // Logical command is `[type, cmd, payload…]`; insert the total-length field and append CRC16. + TK5Frame.frame([UInt8](command)) + } + + // Sleep-record reassembly. The sleep timeline (`05 13`) is one logical record split across several + // be940003 frames, with stage segments straddling frame boundaries. The header frame starts with + // the `af fa` magic and carries the total concatenated payload length at bytes [2..3]; we buffer + // until that many bytes arrive, then decode. + private var sleepBuffer: [UInt8] = [] + private var sleepTotal = 0 + + // MARK: Inbound decode + // + // Every history frame is announced with a leading `.historySyncProgress` before its decoded + // records. The sync engine's watchdog needs to know *a history frame arrived*, and it can't infer + // that from the events alone: a sleep continuation frame decodes to nothing until the record is + // complete, an all-unworn `05 18` page yields only `.activityUpdate`, and `.activityUpdate` is + // also what the ring's continuous live `06 00` status pushes. Only the frame type distinguishes + // them, and only the driver sees it. + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + guard let frame = TK5Frame(validating: data) else { + return [.unknown(commandId: data.first ?? 0, raw: data)] + } + guard frame.type == TK5FrameType.register else { return decoder.decode(frame) } + switch frame.cmd { + case TK5Command.sleepRecord: + return [.historySyncProgress(stage: "Syncing sleep…")] + reassembleSleep(frame.payload) + case TK5Command.historyRecordShort, TK5Command.historyRecordLong: + return [.historySyncProgress(stage: "Syncing history…")] + decoder.decode(frame) + default: + return decoder.decode(frame) // register reads / pref-write acks + } + } + + private func reassembleSleep(_ payload: [UInt8]) -> [RingDecodedEvent] { + if payload.count >= 4, payload[0] == 0xaf, payload[1] == 0xfa { + // Header frame — start a fresh buffer and read the total length. + sleepBuffer = payload + sleepTotal = Int(payload[2]) | (Int(payload[3]) << 8) + } else if !sleepBuffer.isEmpty { + sleepBuffer.append(contentsOf: payload) // continuation + } else { + return [] // continuation with no header seen (mid-stream connect) — ignore + } + guard sleepTotal > 0, sleepBuffer.count >= sleepTotal else { return [] } // still buffering + let record = Array(sleepBuffer.prefix(sleepTotal)) + sleepBuffer = [] + sleepTotal = 0 + return decoder.decodeSleep(record) + } + + func makeSyncEngine() -> RingSyncEngine { + TK5SyncEngine(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/TK5Encoder.swift b/PulseLoop/RingProtocol/TK5Encoder.swift new file mode 100644 index 0000000..0d966a6 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Encoder.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Builds *logical* TK5 commands — `[type, cmd, payload…]` without the length field or CRC, which +/// `TK5Driver.frame(_:)` appends. The connect handshake is a faithful replay of the exact byte +/// sequence the SmartHealth app sent on connect (only the time command is regenerated), which is the +/// safest way to reproduce known-good ring behavior for the fields we can't independently decode. +struct TK5Encoder { + /// Set the ring clock: `01 00` + `[year:2 LE][month][day][hour][min][sec][00]`. + /// Verified against the capture (`ea07 07 06 0c 22 0e 00` = 2026-07-06 12:34:14). + func setTime(_ date: Date = Date(), calendar: Calendar = .current) -> [UInt8] { + let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let year = UInt16(c.year ?? 2000) + return [TK5FrameType.config, TK5Command.setTime, + UInt8(year & 0xff), UInt8((year >> 8) & 0xff), + UInt8(c.month ?? 1), UInt8(c.day ?? 1), + UInt8(c.hour ?? 0), UInt8(c.minute ?? 0), UInt8(c.second ?? 0), 0x00] + } + + /// The connect handshake, in order, as the SmartHealth app issued it (time regenerated). These are + /// replayed verbatim because the ring expects this exact sequence; the 2-byte tokens on the `0x02` + /// commands are constants observed in the capture. + /// UNVERIFIED (capture-inferred): whether those tokens are session-stable across reconnects. + func startupSequence(date: Date = Date()) -> [[UInt8]] { + var seq: [[UInt8]] = [] + seq.append(setTime(date)) + seq.append(logical("02", "01", "4746")) // device info + seq.append(logical("02", "1b", "")) // (post-info probe) + seq.append(logical("02", "00", "4743")) // status (battery lives here) + seq.append(logical("02", "07", "4346")) + seq.append(logical("02", "03", "4750")) + // Calibration/preference register reads — harmless, mirror the app's connect probe. + for cmd in ["02", "04", "06", "08", "09", "33"] { seq.append(logical("05", cmd, "")) } + // Config writes the app pushes every connect (exact bytes; meanings not independently decoded). + seq.append(logical("01", "12", "00")) + seq.append(logical("01", "04", "010100010000")) + seq.append(logical("01", "0c", "013c")) + seq.append(logical("01", "09", "003132")) + seq.append(logical("01", "03", "aa40002b")) + seq.append(logical("04", "0e", "00")) // bond nudge + seq.append(contentsOf: enableAllMonitoring()) + seq.append(enableLiveStatus()) + return seq + } + + /// Enable the ring's **live status auto-push** (`03 09 01 00 02`). Once sent, the ring streams + /// `06 00` status frames (current step count / distance / calories) on be940003 continuously while + /// connected — verified in the captures, where the first `06 00` appears immediately after this + /// command and then repeats for the rest of the session. Without it the app only sees the one-time + /// history dump, so today's live step count never updates. (`03 09 00 00 02` disables it.) + func enableLiveStatus() -> [UInt8] { logical("03", "09", "010002") } + + /// Enable the ring's all-day background monitoring for the extra metrics. Each `05 02` + /// write turns one metric's auto-sampling on (`0x02` = enable, the Colmi-family pref-write + /// convention); the second capture showed the SmartHealth app send exactly this burst when the + /// user toggled HRV/stress/temperature/SpO₂/sleep monitoring on, and the `05 09` config read-back + /// then reported the enabled state. Sending them on connect makes the ring *record* these metrics + /// so a later history dump has something to return (notably: sleep, once worn overnight). + /// UNVERIFIED (capture-inferred): the exact metric↔cmd mapping. We enable the whole observed set so + /// nothing is missed; each is an idempotent, individually-acked pref-write. + func enableAllMonitoring() -> [[UInt8]] { + ["40", "42", "43", "44", "4e"].map { logical("05", $0, "02") } + } + + // MARK: - History dump + + /// Begin the history dump. `02 24` + `f0` (header marker); records then stream on be940003. + func historyStart() -> [UInt8] { logical("02", "24", "f0") } + /// Request the next history page. `02 26` (no payload). + func historyPage() -> [UInt8] { [TK5FrameType.device, TK5Command.historyPage] } + /// Acknowledge / finish the history dump. `02 28` + token. + func historyAck() -> [UInt8] { logical("02", "28", "4746") } + + // MARK: - Live actions + + /// Live measurement start/stop via `03 2f` with a `[enable:1][mode:1]` payload. The **mode byte + /// selects the sensor**, verified against the capture: mode 0x00 = heart rate (green LED) → `06 01` + /// stream, 0x02 = SpO₂ (red/IR LED) → `06 02`, 0x0a = HRV → `06 03`. Stop is mode-agnostic + /// (`00 00`). Using the wrong mode lights the wrong LED and yields no reading, so each metric must + /// use its own start. + func heartRateStart() -> [UInt8] { logical("03", "2f", "0100") } + func spo2Start() -> [UInt8] { logical("03", "2f", "0102") } + func hrvStart() -> [UInt8] { logical("03", "2f", "010a") } + func liveStreamStop() -> [UInt8] { logical("03", "2f", "0000") } + + /// Find-device / bond nudge (`04 0e`). Best-effort; the TK5 has no confirmed vibrate command in + /// the capture, so this is the closest observed "poke the ring" frame. + func findDevice() -> [UInt8] { logical("04", "0e", "00") } + + // MARK: - Helpers + + /// Assemble a logical command from hex strings for `type`, `cmd`, and `payload`. + private func logical(_ type: String, _ cmd: String, _ payload: String) -> [UInt8] { + var out: [UInt8] = [UInt8(type, radix: 16) ?? 0, UInt8(cmd, radix: 16) ?? 0] + var i = payload.startIndex + while i < payload.endIndex { + let next = payload.index(i, offsetBy: 2) + if let b = UInt8(payload[i..= 6 else { return nil } + let declared = Int(bytes[2]) | (Int(bytes[3]) << 8) + guard declared == bytes.count else { return nil } + let crcGiven = UInt16(bytes[bytes.count - 2]) | (UInt16(bytes[bytes.count - 1]) << 8) + guard TK5Frame.crc16(bytes[0..<(bytes.count - 2)]) == crcGiven else { return nil } + self.type = bytes[0] + self.cmd = bytes[1] + self.payload = Array(bytes[4..<(bytes.count - 2)]) + } + + /// Build a framed packet from a logical command `[type, cmd, payload…]`: insert the total-length + /// field after the two header bytes and append the little-endian CRC16. + static func frame(_ logical: [UInt8]) -> Data { + guard logical.count >= 2 else { return Data(logical) } + let total = logical.count + 4 // + 2-byte length field + 2-byte CRC + var out: [UInt8] = [logical[0], logical[1], UInt8(total & 0xff), UInt8((total >> 8) & 0xff)] + out.append(contentsOf: logical[2...]) + let crc = crc16(out[0..> 8) & 0xff)) + return Data(out) + } + + /// CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no input/output reflection, no final xor). + static func crc16(_ bytes: S) -> UInt16 where S.Element == UInt8 { + var crc: UInt16 = 0xFFFF + for b in bytes { + crc ^= UInt16(b) << 8 + for _ in 0..<8 { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : (crc << 1) + } + } + return crc + } +} + +/// Little-endian + epoch helpers. TK5 timestamps are **seconds since 2000-01-01 UTC**, not the Unix +/// epoch (confirmed against the capture's wall-clock time). +enum TK5Bytes { + /// Seconds between 1970-01-01 and 2000-01-01 (the TK5 epoch offset). + static let epochOffset: TimeInterval = 946_684_800 + + static func u16(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 2 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) + } + + static func u32(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 4 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) | (Int(b[i + 2]) << 16) | (Int(b[i + 3]) << 24) + } + + /// Convert a ring timestamp (2000-epoch seconds) to a `Date`. The ring has no timezone concept — + /// its clock is set from local wall-clock fields (see `TK5Encoder.setTime`) and ticks in local + /// time, so decoding must un-apply the device's UTC offset to recover the true absolute instant. + /// Without this, `Calendar.current` re-applies that same offset when a caller later extracts + /// local components (e.g. `Calendar.wakingDay(forSleepStart:)`'s hour check), doubling it instead + /// of cancelling it. Uses the *current* offset as an approximation of the offset in effect when + /// the timestamp was recorded — correct for same-session syncs, only wrong across a DST + /// transition that happens between recording and decoding. + static func date(_ ringSeconds: Int, timeZone: TimeZone = .current) -> Date { + let offset = TimeInterval(timeZone.secondsFromGMT()) + return Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset - offset) + } + + /// Convert a `Date` to ring seconds (2000-epoch), the inverse of `date(_:timeZone:)`. + static func ringSeconds(_ date: Date, timeZone: TimeZone = .current) -> Int { + let offset = TimeInterval(timeZone.secondsFromGMT(for: date)) + return Int(date.timeIntervalSince1970 - epochOffset + offset) + } +} diff --git a/PulseLoop/RingProtocol/TK5SyncEngine.swift b/PulseLoop/RingProtocol/TK5SyncEngine.swift new file mode 100644 index 0000000..1096fb0 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5SyncEngine.swift @@ -0,0 +1,126 @@ +import Foundation + +/// TK5 sync engine. Connect is a faithful replay of the SmartHealth app's handshake, followed by an +/// on-connect history dump. Like the jring it's largely fire-and-forget; the one response-driven flow +/// is history paging, which mirrors the Colmi engine's watchdog pattern (page on each record, finish +/// when the record stream goes quiet). +/// +/// The TK5 has no confirmed on-device profile / goal / measurement-interval commands in the capture, +/// so those `RingSyncEngine` hooks are intentional no-ops (defaults inherited from the protocol +/// extension). Live HR **and** SpO₂ share one proprietary stream toggled by `03 2f`. +@MainActor +final class TK5SyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let encoder = TK5Encoder() + + init(writer: RingCommandWriter?) { + self.writer = writer + } + + // MARK: Startup + + func runStartup() { + for command in encoder.startupSequence() { + writer?.enqueue(Data(command)) + } + startHistorySync() + } + + // MARK: History dump (watchdog-driven paging) + + private var historyActive = false + private var historyWatchdog: Task? + /// Quiet period after the last inbound record before we call the dump complete. Matches the Colmi + /// engine's per-stage timeout: a 4s window let a slow multi-frame sleep transfer time out and get + /// acked away mid-record. + private let historyQuietTimeout: UInt64 = 10_000_000_000 + + private func startHistorySync() { + historyActive = true + // Zero the ring-history activity days we're about to re-sum, so re-syncs stay idempotent + // (same guard the Colmi engine uses before summing history buckets). + Task { await PulseEventBus.shared.publish(.activitySyncReset(sinceDaysAgo: 7)) } + writer?.enqueue(Data(encoder.historyStart())) + armHistoryWatchdog() + } + + private func armHistoryWatchdog() { + historyWatchdog?.cancel() + let timeout = historyQuietTimeout + historyWatchdog = Task { [weak self] in + try? await Task.sleep(nanoseconds: timeout) + guard !Task.isCancelled, let self, self.historyActive else { return } + self.finishHistorySync() + } + } + + private func finishHistorySync() { + historyActive = false + historyWatchdog?.cancel() + historyWatchdog = nil + writer?.enqueue(Data(encoder.historyAck())) + Task { await PulseEventBus.shared.publish(.syncProgress(stage: "done")) } + } + + func handle(_ event: RingDecodedEvent) { + // While a dump is in flight, each history record pulls the next page and resets the quiet timer. + guard historyActive else { return } + switch event { + // Every record a history frame can decode to. `.sleepTimeline` and `.bloodPressureSample` used + // to fall through, so a night of sleep or a BP-only page never asked for the next page. + // Deliberately *not* `.activityUpdate`: the ring's continuous live `06 00` status decodes to + // that too, and treating it as history would keep the watchdog alive forever, so the dump + // would never finish and `historyAck` would never be sent. + case .historyMeasurement, .activityBucket, .bloodPressureSample, .sleepTimeline: + writer?.enqueue(Data(encoder.historyPage())) + armHistoryWatchdog() + case .historySyncProgress: + // `TK5Driver` announces every history frame with this, including a sleep continuation that + // decodes to nothing and an all-unworn page that decodes to no records. Keep the dump + // alive, but don't ask for the next page — the ring may still be streaming this one. + armHistoryWatchdog() + default: + break + } + } + + // MARK: Live actions (proprietary 06-stream on be940003, mode-selected by 03 2f) + // + // The `03 2f` payload is [enable, mode]; the mode byte picks the sensor/LED (HR 0x00 → 06 01, + // SpO₂ 0x02 → 06 02, HRV 0x0a → 06 03). Each metric must start its own mode — the shared stop is + // mode-agnostic. Only one mode runs at a time, so start-then-stop per measurement is correct. + + func startHeartRate() { + writer?.enqueue(Data(encoder.heartRateStart())) + } + + func stopHeartRate() { + writer?.enqueue(Data(encoder.liveStreamStop())) + } + + func startSpO2() { + writer?.enqueue(Data(encoder.spo2Start())) + } + + func stopSpO2() { + writer?.enqueue(Data(encoder.liveStreamStop())) + } + + func startHRV() { + writer?.enqueue(Data(encoder.hrvStart())) + } + + func stopHRV() { + writer?.enqueue(Data(encoder.liveStreamStop())) + } + + func findDevice() { + writer?.enqueue(Data(encoder.findDevice())) + } + + func setGoal(steps: Int) { + // No confirmed goal-write command in the capture; PulseLoop persists the goal regardless. + } +} diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index c6d4b73..cd2d494 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -32,6 +32,7 @@ final class RingSyncCoordinator { private(set) var hrState: MeasureState = .idle private(set) var spo2State: MeasureState = .idle + private(set) var hrvState: MeasureState = .idle private(set) var lastSyncAt: Date? /// The latest history-sync stage label (e.g. "Syncing sleep…"), or nil when not syncing. @@ -55,6 +56,7 @@ final class RingSyncCoordinator { /// Latest live values, mirrored for UI (e.g. the live workout screen) without a query. private(set) var latestHRValue: Int? private(set) var latestSpO2Value: Int? + private(set) var latestHRVValue: Int? private(set) var workoutHRActive = false /// Set when the ring reports a completed HR measurement with no usable reading (not worn), so a /// spot measurement can fail fast instead of waiting out the full window. @@ -74,6 +76,8 @@ final class RingSyncCoordinator { /// first sample. private let hrSettleSeconds: Int = 4 private let spo2MeasureSeconds: UInt64 = 40 + /// HRV needs a stretch of beats to stabilize, so give it a longer on-finger window. + private let hrvMeasureSeconds: UInt64 = 45 private let client: RingBLEClient private let context: ModelContext @@ -322,10 +326,35 @@ final class RingSyncCoordinator { abort: { false } ) engine?.stopSpO2() + // On rings whose live metrics share one stream (TK5: every metric rides `03 2f`, and its stop + // is mode-agnostic), stopping SpO2 also tears down a running workout HR stream. Bring it back. + restartWorkoutHeartRateIfActive() spo2State = result.map { .done($0) } ?? .failed return result } + /// Spot HRV reading. The engine starts the device's dedicated HRV mode (TK5: `03 2f 010a`), we + /// wait for the first live HRV sample, then stop. Capability-gated to `.manualHrv`, so only rings + /// whose live protocol has an HRV mode reach this. + @discardableResult + func measureHRV() async -> Int? { + guard hrvState != .measuring else { return nil } + guard client.state == .connected else { hrvState = .failed; return nil } + hrvState = .measuring + latestHRVValue = nil + engine?.startHRV() + let result = await pollForValue( + window: hrvMeasureSeconds, + value: { self.latestHRVValue }, + abort: { false } + ) + engine?.stopHRV() + // Same shared-stream teardown as SpO2 above: restore the workout's live HR if one was running. + restartWorkoutHeartRateIfActive() + hrvState = result.map { .done($0) } ?? .failed + return result + } + // MARK: - Event handling private func handle(_ event: PulseEvent) { @@ -341,6 +370,8 @@ final class RingSyncCoordinator { latestSpO2Value = value case let .spo2Progress(percent, _): if let percent { latestSpO2Value = percent } + case let .hrvSample(value, _): + latestHRVValue = value case .deviceStateChanged(.connected, _): lastSyncAt = Date() // Ring came back mid-workout: the new connection's engine doesn't know a stream was diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 08d697a..5792315 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -5,7 +5,7 @@ import SwiftData /// Drives the existing `RingSyncCoordinator` measure flow when the ring is connected; otherwise /// simulates a reading and saves a mock `Measurement` so the demo charts update. struct MeasurementSheet: View { - enum Kind { case hr, spo2 } + enum Kind { case hr, spo2, hrv } enum Phase { case preparing, measuring, result, error } let kind: Kind @@ -18,13 +18,33 @@ struct MeasurementSheet: View { @State private var value: Int? @State private var animate = false - private var color: Color { kind == .hr ? PulseColors.heartRate : PulseColors.spo2 } - private var name: String { kind == .hr ? "Heart Rate" : "Blood Oxygen" } - private var unit: String { kind == .hr ? "bpm" : "%" } + private var color: Color { + switch kind { + case .hr: return PulseColors.heartRate + case .spo2: return PulseColors.spo2 + case .hrv: return PulseColors.hrv + } + } + private var name: String { + switch kind { + case .hr: return "Heart Rate" + case .spo2: return "Blood Oxygen" + case .hrv: return "Heart Rate Variability" + } + } + private var unit: String { + switch kind { + case .hr: return "bpm" + case .spo2: return "%" + case .hrv: return "ms" + } + } private var instruction: String { - kind == .hr - ? "Keep your hand still and rest your wrist on a flat surface." - : "Breathe normally. Keep the sensor pressed firmly to your skin." + switch kind { + case .hr: return "Keep your hand still and rest your wrist on a flat surface." + case .spo2: return "Breathe normally. Keep the sensor pressed firmly to your skin." + case .hrv: return "Sit still and breathe normally — HRV needs a steady stretch of beats." + } } var body: some View { @@ -108,9 +128,14 @@ struct MeasurementSheet: View { guard ble.state == .connected else { return "Your ring isn't connected. Reconnect it and try again." } - return kind == .hr - ? "Couldn't get a heart-rate reading. Make sure the ring is snug and worn on your finger, then try again." - : "Couldn't get a blood-oxygen reading. Wear the ring snugly and keep still, then try again. SpO₂ also builds up over a while of wear." + switch kind { + case .hr: + return "Couldn't get a heart-rate reading. Make sure the ring is snug and worn on your finger, then try again." + case .spo2: + return "Couldn't get a blood-oxygen reading. Wear the ring snugly and keep still, then try again." + case .hrv: + return "Couldn't get an HRV reading. Wear the ring snugly, keep still, and try again." + } } private var phaseCopy: String { @@ -147,11 +172,21 @@ struct MeasurementSheet: View { phase = .measuring let result: Int? if ble.state == .connected { - result = kind == .hr ? await coordinator.measureHR() : await coordinator.measureSpO2() + switch kind { + case .hr: result = await coordinator.measureHR() + case .spo2: result = await coordinator.measureSpO2() + case .hrv: result = await coordinator.measureHRV() + } } else { // Demo mode: simulate the measurement window, then persist a mock reading. try? await Task.sleep(for: .seconds(kind == .hr ? 2.2 : 3.0)) - let measurementKind: MeasurementKind = kind == .hr ? .heartRate : .spo2 + let measurementKind: MeasurementKind = { + switch kind { + case .hr: return .heartRate + case .spo2: return .spo2 + case .hrv: return .hrv + } + }() MetricsService.insertMockMeasurement(kind: measurementKind, context: modelContext) result = MetricsService.fetchMeasurements(modelContext) .first(where: { $0.kind == measurementKind }) diff --git a/PulseLoop/Views/PairingComponents.swift b/PulseLoop/Views/PairingComponents.swift index ded4f11..d09d5ff 100644 --- a/PulseLoop/Views/PairingComponents.swift +++ b/PulseLoop/Views/PairingComponents.swift @@ -32,6 +32,32 @@ struct CapabilityChips: View { } } +// MARK: - SupportBadge + +/// A "Limited support" pill for experimental ring families. Warn-toned rather than glass, because a +/// tinted glass capsule reads as a *selected control* everywhere else in this app. Renders nothing +/// for `.full` families, so call sites can place it unconditionally. +struct SupportBadge: View { + let level: WearableSupportLevel + + var body: some View { + if let label = level.badgeLabel { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + Text(label) + } + .font(PulseFont.caption2.weight(.semibold)) + .foregroundStyle(ChipTone.warn.foreground) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(ChipTone.warn.background, in: Capsule()) + .overlay(Capsule().stroke(ChipTone.warn.border, lineWidth: 1)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(label) + } + } +} + // MARK: - SignalStrengthDots /// Three small circles indicating BLE signal strength for a discovered ring. diff --git a/PulseLoop/Views/PairingView.swift b/PulseLoop/Views/PairingView.swift index 06131c6..ad42034 100644 --- a/PulseLoop/Views/PairingView.swift +++ b/PulseLoop/Views/PairingView.swift @@ -1,9 +1,10 @@ import SwiftUI import UIKit -/// The dedicated, modern ring-pairing screen. Swipe a carousel of supported ring models (stylized -/// vector art + name), pick yours, then scan and connect to a matching nearby device. Reused in two -/// contexts: the onboarding pair step (with a "Skip for now") and pushed from Settings → "Add a ring". +/// The dedicated, modern ring-pairing screen. Swipe a carousel of supported ring models (product art, +/// name, capability chips, and a "Limited support" badge for experimental families), pick yours, then +/// scan and connect to a matching nearby device. Reused in two contexts: the onboarding pair step +/// (with a "Skip for now") and pushed from Settings → "Add a ring". /// /// All scan/discover/connect UI lives here so Settings stays a clean device card. Pairing logic is /// just orchestration over `RingBLEClient`; the chosen model's `family` biases which discovered @@ -130,10 +131,13 @@ struct PairingView: View { private var scrollContent: some View { ScrollView { - VStack(spacing: 24) { + // Tight vertical rhythm so the whole picker — carousel *and* its scrub bar — clears the + // pinned footer without scrolling. When it didn't, the scrub bar sat behind the footer, + // whose glass sampled the accent thumb as a purple smear under "Connect ring". + VStack(spacing: 16) { OnboardingHeader( title: "Add your ring", - subtitle: "Swipe to find your model, then tap to connect.\nYou can also explore first and pair later." + subtitle: "Swipe to find your model, then tap to connect." ) .frame(maxWidth: .infinity) // full-width anchor: forces the column full so the button // never hugs to the (variable-width) dot row @@ -158,7 +162,8 @@ struct PairingView: View { } } - .padding(24) + .padding(.horizontal, 24) + .padding(.vertical, 16) .containerRelativeFrame(.horizontal) // size the column to the screen exactly (not to its // content), so the button is full-width on every tab } @@ -174,16 +179,17 @@ struct PairingView: View { // MARK: - Carousel private var carousel: some View { - VStack(spacing: 12) { + VStack(spacing: 6) { brandTabs TabView(selection: $selectedIndex) { ForEach(Array(models.enumerated()), id: \.element.id) { index, model in - VStack(spacing: 16) { + VStack(spacing: 10) { RingArtView(tint: model.tint, imageName: model.imageName) Text(model.displayName) .font(PulseFont.numberL) .foregroundStyle(PulseColors.textPrimary) + SupportBadge(level: model.supportLevel) // nothing for fully-supported models CapabilityChips(blurb: model.blurb) // §2 replaces blurb Text } .frame(maxWidth: .infinity) // constant page width so content doesn't drive reflow @@ -192,7 +198,9 @@ struct PairingView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) // §2 Fix #2 — dots moved to modelDotRow - .frame(height: 300) // §2 Fix #2 + // Hugs the tallest page (art + name + support badge + chips). Slack here shows up as dead + // space between the chips and the scrub bar below, so keep it tight. + .frame(height: 292) .id(selectedBrand) // recreate on brand change so pages swap instantly (no page-slide) modelDotRow // §2 fixed-height dot area keeps layout stable across tabs @@ -217,8 +225,7 @@ struct PairingView: View { } } .frame(maxWidth: .infinity) // center the bar; keep it from driving column width - .frame(height: 44) // constant 44pt area across every brand tab - .padding(.vertical, 4) + .frame(height: 20) // constant area across every brand tab; the bar centres inside it } /// Brand filter: centered when the pills fit on screen, horizontally scrollable when they don't. @@ -342,15 +349,21 @@ struct PairingView: View { let signalLevel: String = ring.rssi >= -65 ? "Strong signal" : ring.rssi >= -80 ? "Medium signal" : "Weak signal" + // Identity wins when the advertisement resolved a model; otherwise fall back to the family. + let support = WearableModel.model(id: ring.wearableModelID)?.supportLevel + ?? ring.deviceType?.supportLevel ?? .full return HStack { Image(systemName: ring.isLikelyRing ? "circle.hexagongrid.circle.fill" : "dot.radiowaves.left.and.right") .foregroundStyle(ring.isLikelyRing ? PulseColors.accent : PulseColors.textMuted) VStack(alignment: .leading, spacing: 2) { Text(ring.name).font(.subheadline.weight(.medium)).foregroundStyle(PulseColors.textPrimary) if let type = ring.deviceType { - Text(WearableModel.model(id: ring.wearableModelID)?.displayName ?? type.displayName) - .font(.caption2) - .foregroundStyle(PulseColors.accent) + HStack(spacing: 6) { + Text(WearableModel.model(id: ring.wearableModelID)?.displayName ?? type.displayName) + .font(.caption2) + .foregroundStyle(PulseColors.accent) + SupportBadge(level: support) // renders nothing for fully-supported families + } } } Spacer() @@ -373,7 +386,9 @@ struct PairingView: View { .contentShape(Rectangle()) .accessibilityElement(children: .combine) // §5 .accessibilityLabel(Text( // §5 spoken label with signal level - "\(ring.name)\(ring.deviceType.map { ", \($0.displayName)" } ?? ""), \(signalLevel)" + "\(ring.name)\(ring.deviceType.map { ", \($0.displayName)" } ?? "")" + + (support == .limited ? ", limited support" : "") + + ", \(signalLevel)" )) } diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index 182a471..be205b9 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -16,6 +16,8 @@ struct DeviceHeroStatus: Equatable { let batteryText: String? let syncText: String? let action: Action + /// Maturity of the connected/known ring's driver, surfaced as a badge. `.full` renders no badge. + var supportLevel: WearableSupportLevel = .full var actionTitle: String { switch action { @@ -39,7 +41,8 @@ struct DeviceHeroStatus: Equatable { knownName: String?, batteryPercent: Int?, lastSync: Date?, - now: Date + now: Date, + supportLevel: WearableSupportLevel = .full ) -> DeviceHeroStatus { let title = connectedName ?? knownName ?? "No ring connected" @@ -82,7 +85,8 @@ struct DeviceHeroStatus: Equatable { return DeviceHeroStatus( title: title, statusLine: statusLine, statusTint: statusTint, - batteryText: batteryText, syncText: syncText, action: action + batteryText: batteryText, syncText: syncText, action: action, + supportLevel: supportLevel ) } } @@ -108,7 +112,8 @@ struct DeviceHeroCard: View { knownName: wearableModel?.displayName ?? deviceType?.displayName, batteryPercent: battery, lastSync: coordinator.lastSyncAt, - now: Date() + now: Date(), + supportLevel: deviceType?.supportLevel ?? .full ) // The connected card is purely informational and opens Wearable settings, where Disconnect @@ -135,6 +140,8 @@ struct DeviceHeroCard: View { .foregroundStyle(status.statusTint) .lineLimit(1) + SupportBadge(level: status.supportLevel) // nothing for fully-supported rings + if let syncText = status.syncText { Text(syncText) .font(PulseFont.caption.weight(.regular)) @@ -202,7 +209,13 @@ struct DeviceHeroCard: View { } private func cardAccessibilityLabel(_ status: DeviceHeroStatus) -> String { - [status.title, status.statusLine, status.batteryText.map { "Battery \($0)" }, status.syncText] + [ + status.title, + status.statusLine, + status.supportLevel.badgeLabel, + status.batteryText.map { "Battery \($0)" }, + status.syncText, + ] .compactMap { $0 } .joined(separator: ", ") } @@ -222,6 +235,7 @@ struct DeviceHeroCard: View { switch type { case .jring: return "jring" case .colmiR02: return nil + case .tk5: return "tk5" case nil: return nil } } diff --git a/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index 98daaca..eefcccd 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -91,7 +91,7 @@ struct VitalsView: View { // TODO(combined-measure): replace these with a single "Measure now" pill once the combined // measurement command lands. let caps = store.capabilities - if caps.contains(.manualHeartRate) || caps.contains(.manualSpo2) { + if caps.contains(.manualHeartRate) || caps.contains(.manualSpo2) || caps.contains(.manualHrv) { HStack(spacing: 8) { if caps.contains(.manualHeartRate) { QuickActionButton(label: "Measure HR", accent: true) { measuring = .hr } @@ -99,6 +99,9 @@ struct VitalsView: View { if caps.contains(.manualSpo2) { QuickActionButton(label: "Measure SpO₂") { measuring = .spo2 } } + if caps.contains(.manualHrv) { + QuickActionButton(label: "Measure HRV") { measuring = .hrv } + } } } } diff --git a/PulseLoop/Wearables/WearableCapability.swift b/PulseLoop/Wearables/WearableCapability.swift index fafce59..aac9c93 100644 --- a/PulseLoop/Wearables/WearableCapability.swift +++ b/PulseLoop/Wearables/WearableCapability.swift @@ -32,6 +32,7 @@ enum WearableCapability: String, CaseIterable, Codable, Sendable { // Interaction capabilities case manualHeartRate // single-shot on-demand HR case manualSpo2 // single-shot on-demand SpO2 (jring; Colmi has no instant SpO2) + case manualHrv // single-shot on-demand HRV (TK5: rides the same live stream as HR) case realtimeHeartRate // live HR stream case realtimeSteps // live activity stream case findDevice diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index ca041eb..c9f51df 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -6,12 +6,14 @@ import Foundation enum RingDeviceType: String, Codable, CaseIterable, Sendable { case jring case colmiR02 + case tk5 /// Human-facing default name when no advertised name is available. var displayName: String { switch self { case .jring: return "SMART_RING" case .colmiR02: return "Colmi / Yawell ring" + case .tk5: return "TK5 ring" } } } diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 9c6091f..be4db59 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -122,6 +122,10 @@ protocol RingSyncEngine: AnyObject { func measureHeartRateSpot() func startSpO2() func stopSpO2() + /// Start/stop an on-demand HRV stream. Only devices whose live protocol has a dedicated HRV mode + /// (TK5) implement these; the default is a no-op (see the extension below). + func startHRV() + func stopHRV() func findDevice() func setGoal(steps: Int) @@ -168,6 +172,10 @@ extension RingSyncEngine { /// Default: a spot measurement is just the live start (jring has no separate manual command). func measureHeartRateSpot() { startHeartRate() } + /// Default: devices without a dedicated HRV mode (jring/Colmi) don't stream on-demand HRV. + func startHRV() {} + func stopHRV() {} + /// Default: devices that don't expose a configurable interval (e.g. the generic jring) ignore /// measurement settings entirely. func setMeasurementSettings(_ settings: MeasurementSettings) {} diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 2c10fd2..7caafc2 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -5,8 +5,8 @@ import SwiftUI /// `family`/driver; this catalog just gives each a name, tint, and one-line capability blurb so the /// user can swipe and say "this is my ring." /// -/// `family` decides which coordinator/driver the scan should accept. Swapping the stylized vector art -/// for real product photos later only needs an added `imageName` consumed by `RingArtView`. +/// `family` decides which coordinator/driver the scan should accept, and — via `supportLevel` — how +/// mature that driver is, which the pairing screen surfaces as a "Limited support" badge. struct WearableModel: Identifiable { let id: String let displayName: String @@ -18,9 +18,38 @@ struct WearableModel: Identifiable { /// Bluetooth local-name patterns that identify this exact product model. Protocol-family /// matching remains the coordinator's job; these patterns are only for user-facing identity. let advertisedNamePatterns: [String] - /// Asset-catalog image name for this ring's product art. When nil, `RingArtView` falls back to - /// the stylized vector torus. Set this per model once real ring images are added to the catalog. + /// Asset-catalog image name for this ring's product art. When nil, `RingArtView` falls back to a + /// generic ring photo. Set this per model once real ring images are added to the catalog. var imageName: String? = nil + + /// Maturity of this model's driver, mirrored from its `family` (the real source of truth). + var supportLevel: WearableSupportLevel { family.supportLevel } +} + +/// How proven PulseLoop's driver for a wearable *family* is. Drives the "Limited support" badge in +/// pairing/onboarding/Settings. Families we've shipped and tested are `.full` and render no badge at +/// all; a family reconstructed from thin evidence is `.limited`. +enum WearableSupportLevel: Equatable { + case full + case limited + + /// Short pill text, or nil for `.full` so no badge renders. + var badgeLabel: String? { + switch self { + case .full: return nil + case .limited: return "Limited support" + } + } +} + +extension RingDeviceType { + /// How proven this family's driver is. Exhaustive on purpose: a new family must state its level. + var supportLevel: WearableSupportLevel { + switch self { + case .jring, .colmiR02: return .full + case .tk5: return .limited + } + } } extension WearableModel { @@ -43,6 +72,14 @@ extension WearableModel { ) static let colmiR12 = colmiFamily("colmi-r12", "Colmi R12", brand: "Colmi", pattern: "^COLMI R12_.*") + // TK5 — its own protocol (be940 service, SmartHealth app). Advertises as "TK5 <4 hex>". + // Blurb mirrors `TK5Coordinator.capabilities`; the driver is `.limited` (see `supportLevel`). + static let tk5 = WearableModel( + id: "tk5", displayName: "TK5", brand: "TK", family: .tk5, + tint: PulseColors.spo2, blurb: "HR · SpO₂ · HRV · BP · Sleep", + advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: "tk5" + ) + // Yawell-branded variants of the same hardware. static let yawellR05 = colmiFamily("yawell-r05", "Yawell R05", brand: "Yawell", pattern: "^R05_[0-9A-F]{4}$") static let yawellR10 = colmiFamily("yawell-r10", "Yawell R10", brand: "Yawell", pattern: "^R10_[0-9A-F]{4}$") @@ -70,6 +107,7 @@ extension WearableModel { jring, colmiR02, colmiR03, colmiR06, colmiR07, colmiR09, colmiR10, colmiR11, colmiR12, yawellR05, yawellR10, yawellR11, h59, + tk5, ] static func model(id: String?) -> WearableModel? { diff --git a/PulseLoopTests/DeviceHeroStatusTests.swift b/PulseLoopTests/DeviceHeroStatusTests.swift index ebce3e1..de669f9 100644 --- a/PulseLoopTests/DeviceHeroStatusTests.swift +++ b/PulseLoopTests/DeviceHeroStatusTests.swift @@ -48,4 +48,18 @@ final class DeviceHeroStatusTests: XCTestCase { lastSync: now.addingTimeInterval(-120), now: now) XCTAssertEqual(s.syncText?.hasPrefix("Synced") == true, true) } + + /// Support level defaults to `.full` (so the badge never shows for a shipped ring) and is carried + /// through `make` when the caller knows the family is experimental. + func testSupportLevelDefaultsToFullAndPropagates() { + let full = DeviceHeroStatus.make(state: .connected, connectedName: "Colmi R11", + knownName: "Colmi R11", batteryPercent: 50, lastSync: nil, now: now) + XCTAssertEqual(full.supportLevel, .full) + XCTAssertNil(full.supportLevel.badgeLabel) + + let limited = DeviceHeroStatus.make(state: .connected, connectedName: "TK5", + knownName: "TK5", batteryPercent: 50, lastSync: nil, now: now, supportLevel: .limited) + XCTAssertEqual(limited.supportLevel, .limited) + XCTAssertEqual(limited.supportLevel.badgeLabel, "Limited support") + } } diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 7d089fa..229a1cb 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -1,5 +1,6 @@ import XCTest import CoreBluetooth +import UIKit @testable import PulseLoop /// The pairing flow must recognize the whole Colmi/Yawell ring family by advertised name (they all @@ -94,4 +95,38 @@ final class PairingMatchingTests: XCTestCase { func testColmiR11ReusesYawellR11Image() { XCTAssertEqual(WearableModel.colmiR11.imageName, WearableModel.yawellR11.imageName) } + + // MARK: - Support level + + func testSupportLevelIsPerFamily() { + XCTAssertEqual(RingDeviceType.jring.supportLevel, .full) + XCTAssertEqual(RingDeviceType.colmiR02.supportLevel, .full) + XCTAssertEqual(RingDeviceType.tk5.supportLevel, .limited) + } + + /// The TK5 is the only limited-support family, and only limited families get a badge. + func testOnlyTK5CarriesTheLimitedSupportBadge() { + XCTAssertEqual(WearableModel.tk5.supportLevel, .limited) + XCTAssertEqual(WearableModel.tk5.supportLevel.badgeLabel, "Limited support") + + for model in WearableModel.catalog where model.family != .tk5 { + XCTAssertEqual(model.supportLevel, .full, model.displayName) + XCTAssertNil(model.supportLevel.badgeLabel, model.displayName) + } + } + + /// Every catalog model must name an imageset that exists, or `RingArtView` renders an empty + /// platter (a non-nil `imageName` has no fallback path). Resolve against the app bundle rather + /// than `.main` so this holds whether or not the test target is hosted. + func testEveryCatalogImageNameResolvesToAnAsset() { + let appBundle = Bundle(for: RingBLEClient.self) + for model in WearableModel.catalog { + guard let imageName = model.imageName else { continue } + XCTAssertNotNil( + UIImage(named: imageName, in: appBundle, compatibleWith: nil), + "missing imageset '\(imageName)' for \(model.displayName)" + ) + } + XCTAssertEqual(WearableModel.tk5.imageName, "tk5") + } } diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift new file mode 100644 index 0000000..cce8d72 --- /dev/null +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -0,0 +1,360 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// TK5 protocol parity against the SmartHealth-app btsnoop capture (see docs/TK5-Protocol.md). Pure — +/// no hardware. Frame bytes are copied verbatim from the capture, so these exercise the CRC16/CCITT +/// round-trip, the decoder's field offsets, and coordinator recognition. +final class TK5DecoderTests: XCTestCase { + private let decoder = TK5Decoder() + + private func bytes(_ hex: String) -> Data { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i... + let body = bytes("01000e00ea0707060c220e00") + let crc = TK5Frame.crc16([UInt8](body)) + XCTAssertEqual(crc, 0xc726) // little-endian on the wire = 26 c7 + } + + func testFrameBuildsLengthAndCRC() { + // Logical [type, cmd, payload…] → full framed packet identical to the capture. + let logical: [UInt8] = [0x01, 0x00, 0xea, 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00] + let framed = TK5Frame.frame(logical) + XCTAssertEqual(framed.hexString, "01000e00ea0707060c220e0026c7") + } + + func testValidatingRejectsBadCRC() { + var raw = [UInt8](bytes("01000e00ea0707060c220e0026c7")) + raw[raw.count - 1] ^= 0xff + XCTAssertNil(TK5Frame(validating: Data(raw))) + } + + func testValidatingRejectsWrongDeclaredLength() { + // Declared length (0x00ff) doesn't match actual byte count. + XCTAssertNil(TK5Frame(validating: bytes("0100ff00ea0707060c220e0026c7"))) + } + + // MARK: Decode — verified fields + + func testLiveHeartRateDecodes() { + // 06 01 0700 — captured live HR of 0x52 = 82 bpm. + let frame = TK5Frame(validating: bytes("06010700521a55"))! + guard case let .heartRateSample(bpm, _) = decoder.decode(frame).first else { + return XCTFail("expected heartRateSample") + } + XCTAssertEqual(bpm, 82) + } + + func testLiveStatusDecodesSteps() { + // 06 00 0c00 7b02 9701 1a00 — steps 0x027b = 635. + let frame = TK5Frame(validating: bytes("06000c007b0297011a00b60d"))! + guard case let .activityUpdate(_, steps, distance, calories) = decoder.decode(frame).first else { + return XCTFail("expected activityUpdate") + } + XCTAssertEqual(steps, 635) + XCTAssertEqual(distance, 407) // 0x0197 (capture-inferred) + XCTAssertEqual(calories, 26) // 0x001a (capture-inferred) + } + + func testStatusDecodesBattery() { + // 02 00 status; battery at payload[5] = 0x64 = 100. + let frame = TK5Frame(validating: bytes("02001e00a30012010064000100030000000001000000010000000000ef10"))! + let events = decoder.decode(frame) + guard case let .battery(percent) = events.last else { + return XCTFail("expected battery event") + } + XCTAssertEqual(percent, 100) + } + + func testHistoryShortFramePacksEightHourlyHR() { + // One 05 15 frame carries eight 6-byte HR records (overnight hourly samples); all must decode. + let frame = TK5Frame(validating: bytes( + "051536001cf0de3100471afede310042260cdf31003f3b1adf31003e4328df3100425136df31003c6444df3100419852df31003aa951"))! + let hr = decoder.decode(frame).compactMap { event -> Double? in + if case let .historyMeasurement(.heartRate, value, _) = event { return value } else { return nil } + } + XCTAssertEqual(hr, [71, 66, 63, 62, 66, 60, 65, 58]) + } + + func testHistoryLongFramePacksPeriodicSpo2AndHRV() { + // One 05 18 frame carries eight 20-byte combined-vitals records; each yields SpO₂ + HRV. + let frame = TK5Frame(validating: bytes( + "0518a6001cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf310000" + + "3f7049610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f00" + + "0000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df310000" + + "3a6a465f0c8002000f000000d89dc5a3"))! + let events = decoder.decode(frame) + let spo2 = events.compactMap { e -> Double? in + if case let .historyMeasurement(.spo2, v, _) = e { return v } else { return nil } + } + let hrv = events.compactMap { e -> Double? in + if case let .historyMeasurement(.hrv, v, _) = e { return v } else { return nil } + } + XCTAssertEqual(spo2, [98, 97, 97, 96, 97, 96, 97, 95]) + XCTAssertEqual(hrv, [52, 43, 177, 95, 33, 33, 61, 128]) + // Periodic BP at offsets 7/8 — last record (06:00) verified 106/70 against the app. + let bp = events.compactMap { e -> (Int, Int)? in + if case let .bloodPressureSample(s, d, _) = e { return (s, d) } else { return nil } + } + XCTAssertEqual(bp.map(\.0), [115, 112, 112, 109, 111, 111, 110, 106]) // systolic + XCTAssertEqual(bp.last?.1, 70) // 06:00 diastolic + // Cumulative per-day steps emitted as activityUpdate (max per day); this frame's first record + // carries the 23:00 daily total of 3336. + let steps = events.compactMap { e -> Int? in + if case let .activityUpdate(_, s, _, _) = e { return s } else { return nil } + } + XCTAssertEqual(steps.max(), 3336) + } + + func testLiveExtendedDisambiguatesBPvsHRV() { + // 06 03 in BP mode: [sys][dia]… → 111/74 (verified against the app's 6:52 reading). + let bpFrame = TK5Frame(validating: bytes("060314006f4a44000000000000000000000074f1"))! + guard case let .bloodPressureSample(sys, dia, _) = decoder.decode(bpFrame).first else { + return XCTFail("expected bloodPressureSample") + } + XCTAssertEqual([sys, dia], [111, 74]) + // 06 03 in HRV mode: [0,0,0,hrv]… → HRV, not misread as BP. + let hrvFrame = TK5Frame(validating: bytes("06031400000000b1000000000000000000001579"))! + guard case let .hrvSample(value, _) = decoder.decode(hrvFrame).first else { + return XCTFail("expected hrvSample") + } + XCTAssertEqual(value, 177) + } + + func testHistoryRecordDecodesHRV() { + // Two 05 18 records copied verbatim from the capture; payload[11] = HRV, verified against the + // app's displayed values (48 ms @1:00, 79 ms @1:32). + for (hex, expected) in [ + ("05181a005f63de317b02446e4b620e3006000f000000d9ebc397", 48.0), + ("05181a00ee6ade31de0200000000004f00000f000000b5e74ff0", 79.0), + ] { + let frame = TK5Frame(validating: bytes(hex))! + let hrv = decoder.decode(frame).compactMap { event -> Double? in + if case let .historyMeasurement(.hrv, value, _) = event { return value } else { return nil } + } + XCTAssertEqual(hrv, [expected], "HRV decode for \(hex)") + } + } + + // MARK: Timestamp decode + + func testDateDecodeRecoversTrueInstantAcrossTimeZone() { + // The ring has no timezone concept: TK5Encoder.setTime sends *local* wall-clock fields, and + // the ring stores them naively as if they were UTC (no timezone byte in the wire format — + // see docs/TK5-Protocol.md). Simulate that here for a non-UTC zone and confirm decode still + // recovers the true absolute instant, rather than shifting it by a full UTC-offset (which is + // what put last night's sleep session on the wrong side of the app's 7 PM day boundary). + let tz = TimeZone(identifier: "America/New_York")! + var localCalendar = Calendar(identifier: .gregorian) + localCalendar.timeZone = tz + + // Snap to the second so DateComponents round-trips exactly. + let trueInstant = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) + let localFields = localCalendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: trueInstant + ) + + // What the ring's own naive clock would store: the local fields, reinterpreted as if they + // were UTC (no timezone concept on-device). + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(identifier: "UTC")! + let ringNaiveInstant = utcCalendar.date(from: localFields)! + let ringSecondsFromRing = Int(ringNaiveInstant.timeIntervalSince1970 - TK5Bytes.epochOffset) + + let decoded = TK5Bytes.date(ringSecondsFromRing, timeZone: tz) + + XCTAssertEqual(decoded.timeIntervalSince1970, trueInstant.timeIntervalSince1970, accuracy: 1) + } + + func testRingSecondsAndDateRoundTrip() { + let tz = TimeZone(identifier: "America/New_York")! + let date = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) + + let ringSeconds = TK5Bytes.ringSeconds(date, timeZone: tz) + let decoded = TK5Bytes.date(ringSeconds, timeZone: tz) + + XCTAssertEqual(decoded.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1) + } + + // MARK: Sleep + + func testSleepDecodeMatchesAppBreakdown() { + // Reassembled 05 13 sleep record (3 frames concatenated). Stage tags f1=deep/f2=light/f3=rem, + // verified against the app's on-screen breakdown (deep 93 / light 249 / rem 130 min; the small + // deltas below are per-segment minute rounding). + let record = bytes( + "affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c9" + + "0400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200" + + "f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f345" + + "18df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf" + + "31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c1" + + "0000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000" + + "f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f" + + "47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df" + + "31050100f15f53df311e0100f27d54df31400400") + guard case let .sleepTimeline(_, stages) = decoder.decodeSleep([UInt8](record)).first else { + return XCTFail("expected sleepTimeline") + } + // Per-minute rounding means totals land within a couple minutes of the app's displayed values. + XCTAssertEqual(Double(stages.filter { $0 == .light }.count), 249, accuracy: 3) + XCTAssertEqual(Double(stages.filter { $0 == .deep }.count), 93, accuracy: 3) + XCTAssertEqual(Double(stages.filter { $0 == .rem }.count), 130, accuracy: 3) + XCTAssertFalse(stages.contains(.awake)) + } + + // MARK: Live-measurement mode selection + + func testLiveMeasurementModesUseDistinctSensors() { + // The 03 2f payload's mode byte selects the sensor/LED; each metric must use its own. + let e = TK5Encoder() + XCTAssertEqual(e.heartRateStart(), [0x03, 0x2f, 0x01, 0x00]) // HR (green) + XCTAssertEqual(e.spo2Start(), [0x03, 0x2f, 0x01, 0x02]) // SpO₂ (red/IR) + XCTAssertEqual(e.hrvStart(), [0x03, 0x2f, 0x01, 0x0a]) // HRV + XCTAssertEqual(e.liveStreamStop(), [0x03, 0x2f, 0x00, 0x00]) // stop (mode-agnostic) + } + + // MARK: Startup + + func testStartupEnablesLiveStatusStream() { + // Without `03 09 01 00 02` the ring never auto-pushes 06 00 status, so live steps freeze. + let seq = TK5Encoder().startupSequence() + XCTAssertTrue(seq.contains([0x03, 0x09, 0x01, 0x00, 0x02]), + "startup must enable the live status auto-push") + } + + // MARK: Coordinator recognition + + @MainActor + func testCoordinatorMatchesTK5Name() { + let noAdv = AdvertisementInfo(serviceUUIDs: [], manufacturerData: nil) + XCTAssertTrue(TK5Coordinator.matches(name: "TK5 24AA", advertisement: noAdv)) + XCTAssertFalse(TK5Coordinator.matches(name: "SMART_RING", advertisement: noAdv)) + XCTAssertFalse(JringCoordinator.matches(name: "TK5 24AA", advertisement: noAdv)) + } + + @MainActor + func testCoordinatorMatchesManufacturerPrefix() { + let adv = AdvertisementInfo( + serviceUUIDs: [], + manufacturerData: bytes("10786501000101120000000000")) + XCTAssertTrue(TK5Coordinator.matches(name: "Unlabeled", advertisement: adv)) + } + + // MARK: History paging + + /// An unworn `05 18` record (SpO₂ out of range, HRV 0, BP 0) decodes to nothing but the day's + /// cumulative step count — which is indistinguishable from a live `06 00` status push. + func testUnwornCombinedVitalsRecordDecodesToStepsOnly() { + let frame = TK5Frame(validating: bytes("05181a001cf0de31080d4700000000000000000000000000c362"))! + let events = decoder.decode(frame) + XCTAssertEqual(events.count, 1) + guard case .activityUpdate = events.first else { + return XCTFail("expected activityUpdate, got \(events)") + } + } +} + +/// `RingBLEClient` only calls the sync engine's `handle` once per *decoded event*, so a frame that +/// decodes to nothing — a sleep continuation, an all-unworn history page — is invisible to the +/// history watchdog. That is how a slow overnight transfer used to get acked away mid-record. The +/// driver therefore announces every history frame, since only it can see the frame type. +@MainActor +final class TK5HistorySyncTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + private func bytes(_ hex: String) -> Data { + var out = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let n = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i.. 🧪 **TK5 support is experimental.** Its driver was reverse-engineered from a single packet +> capture: some readings are unverified and may be missing or wrong, skin temperature and stress +> aren't decoded, and the ring's encrypted login isn't implemented. The app labels it "Limited +> support" when you pair it. See the [TK5 page](https://saksham2001.github.io/PulseLoopiOS/hardware/tk5/). > 📚 **Full hardware specs, per-model capability matrix, and buying guidance: > [Supported hardware docs](https://saksham2001.github.io/PulseLoopiOS/hardware/).** diff --git a/docs/hardware/index.md b/docs/hardware/index.md index da8c07f..0c0f703 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -45,6 +45,15 @@ section breaks the hardware down by manufacturer. [:octicons-arrow-right-24: Colmi / Yawell](colmi.md) +- :material-flask-outline: __TK5 / SmartHealth__ + + --- + + 🧪 Experimental. Custom `be940` protocol (SmartHealth app), reverse-engineered + from a single capture — some metrics unverified, no encrypted login yet. + + [:octicons-arrow-right-24: TK5 / SmartHealth](tk5.md) + - :material-help-circle-outline: __SIMSONLAB__ --- @@ -70,60 +79,63 @@ section breaks the hardware down by manufacturer. !!! info "Legend" - ✅ — yes / supported - ❌ — no / not supported + - 🧪 — implemented, needs testing / experimental - ❓ — unknown -| | 56ff / Jring | Colmi R02/R03/etc | Colmi R10 | Colmi R12 | Colmi R11 | -|---|---:|---:|---:|---:|---:| -| **SoC** | Renesas DA14531 | Realtek RTL8762 | RTL8762 ESF | Realtek RTL8762 | Realtek AB2026 | -| **Architecture** | ARM Cortex-M0 | ARM | ARM | ARM | ARM | -| **Bluetooth** | BLE 5.x | BLE 5.0 | BLE 5.0 | BLE 5.0 | BLE 5.0 | -| **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) | -| **Accelerometer** | Yes | Unknown | STK8321 | ST LIS2DOC | STK8321 | -| **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² | -| **Frame size** | Fixed 20 bytes | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | 16 bytes (checksum) | -| **Encryption** | 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 | +|---|---:|---:|---:|---:|---:|---:| +| **SoC** | Renesas DA14531 | Realtek RTL8762 | RTL8762 ESF | Realtek RTL8762 | Realtek AB2026 | ❓ | +| **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² | Custom 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** | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | ¹ 15 mAh for sizes 8–9, 18 mAh for sizes 10–13. ² Works with the QRing app; also has a companion "Da Rings" app. Matched by Colmi driver. +³ TK5 data channels are cleartext. It also exposes a separate encrypted `AE00` login, which PulseLoop does not implement. ## Supported Rings — Capabilities -| Capability | 56ff / Jring | Colmi R02/etc | Colmi R10 | Colmi R12 | Colmi R11 | -|---|---:|---:|---:|---:|---:| -| 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 | ✅ | ✅ | ❓ | ❓ | ❓ | +| Capability | 56ff / Jring | Colmi R02/etc | Colmi R10 | Colmi R12 | Colmi R11 | TK5 | +|---|---:|---:|---:|---:|---:|---:| +| Heart rate — spot | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Heart rate — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Heart rate — live | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| SpO₂ — history | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| SpO₂ — spot | ✅ | ❌¹ | ❌¹ | ❌¹ | ❌¹ | 🧪 | +| Steps / distance / calories | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Sleep (light/deep/awake) | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| REM sleep | ❌ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Blood pressure | ✅² | ❌ | ❌ | ❌ | ❌ | 🧪 | +| Blood sugar | ✅³ | ❌ | ❌ | ❌ | ❌ | ❌ | +| HRV | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Stress | ✅ | ✅ | ✅ | ✅ | ✅ | ❌⁴ | +| Fatigue | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Skin temperature | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Battery level | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Find device | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | +| Continuous background sync | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | +| FW update via app | ✅ | ✅ | ❓ | ❓ | ❓ | ❓ | ¹ Colmi family has no on-demand SpO₂ reading; SpO₂ is all-day background only. ² Direct PPG sensor reading, no user profile required. ³ Profile-derived estimate from sex/age/height/weight, not a real glucometer reading. +⁴ The TK5 doesn't store stress — SmartHealth derives it from HRV app-side — so PulseLoop doesn't claim it. ## Not Supported by PulseLoop diff --git a/docs/hardware/tk5-protocol.md b/docs/hardware/tk5-protocol.md new file mode 100644 index 0000000..5496cbd --- /dev/null +++ b/docs/hardware/tk5-protocol.md @@ -0,0 +1,161 @@ +--- +title: TK5 protocol notes +description: >- + Byte-level teardown of the TK5's be940 protocol — GATT map, CRC16 framing, + commands, history records, and sleep decoding. +--- + +# TK5 Ring Protocol (SmartHealth) + +Developer notes for the TK5 ring driver (`PulseLoop/RingProtocol/TK5*.swift`). Reverse-engineered +from a single Android **btsnoop HCI** capture of the **SmartHealth** app +(`com.zhuoting.healthyucheng`, [Play Store](https://play.google.com/store/apps/details?id=com.zhuoting.healthyucheng)) +talking to a `TK5 24AA` ring on 2026-07-06. + +!!! warning "Experimental — limited support" + One capture is not a spec. See [TK5 / SmartHealth](tk5.md) for what this does and doesn't mean + for the readings you'll see in the app. + +Fields read straight out of the capture are **verified**; offsets inferred but not independently +confirmed are tagged **UNVERIFIED** here and in code. Every decode routes through `RingEventBridge`, +which range-gates each metric, so a misdecoded byte is dropped rather than persisted. + +## GATT map + +| Service | Char | Handle | Props | Role | +|---|---|---|---|---| +| `be940000-7333-be46-b7ae-689e71722bd5` | `be940001` | 0x000c | write + indicate | **Command channel** — app writes here and receives command replies here | +| | `be940002` | 0x000f | write-no-resp | unused in capture | +| | `be940003` | 0x0011 | indicate | **Async stream** — live HR/steps/SpO₂ + history records | +| `180D` (Heart Rate) | `2A37` | 0x006e | notify | standard HR — **not subscribed**: emits a cached resting HR (~87 bpm) even off-finger, which masks real readings. Live HR uses the proprietary `06 01` stream instead. | +| `FEE7` | FEC9/FEA1/FEA2 | | | present, unused | +| `AE00` | AE01/AE02 | 0x0082/0x0084 | write / notify | encrypted `fedcba`/"pass" login — see below | + +Note `be940001` is **both** the write target and a notify source; `RingBLEClient` discovery +subscribes a `notifyUUIDs` entry even when it also equals `writeUUID`. + +## Framing (both be940001 and be940003) + +``` +[type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE] +``` + +- `len` = **total** frame length (header + payload + crc). +- CRC = **CRC16/CCITT-FALSE** (poly `0x1021`, init `0xFFFF`, no reflection, no final xor), appended + **little-endian**. Verified against every frame in the capture. +- Replies echo the same `type`/`cmd`. + +Example — set time: `01 00 0e00 ea0707060c220e00 26c7` → `2026-07-06 12:34:14`. + +## Timestamps + +Seconds since **2000-01-01**, but the ring has **no timezone concept**: `TK5Encoder.setTime` sends +raw local wall-clock fields (see the set-time command above) with no timezone byte, and the ring's +clock just ticks forward in real seconds from whatever instant those fields describe. Decoding +(`TK5Bytes.date`) must therefore un-apply the device's current UTC offset to recover the true +absolute instant — treating the stored seconds as pure UTC (the original approach) silently shifts +every decoded timestamp by a full UTC-offset, which is enough to flip a sleep session onto the +wrong side of the app's 7 PM day boundary for any non-UTC timezone. + +## Commands (verified) + +| type cmd | meaning | notes | +|---|---|---| +| `01 00` | set time | `[year:2 LE][month][day][hour][min][sec][00]` | +| `02 00` | status | battery at payload[5] (`0x64` = 100%) — **UNVERIFIED** offset, guarded 0…100 | +| `02 01` | device info | 66-byte block; no fixed version offset found | +| `02 24` | history dump start | payload `f0` header marker; records then stream on be940003 | +| `02 26` | history page | pull next page | +| `02 28` | history ack/finish | | +| `03 09` | live status auto-push on/off | `01 00 02` enables, `00 00 02` disables. Once enabled the ring streams `06 00` (steps/distance/calories) continuously while connected — **required** for live step updates. | +| `03 2f` | live measurement on/off | payload `[enable:1][mode:1]`; **mode picks the sensor**: `00`=HR (green LED)→`06 01`, `01`=BP→`06 03`, `02`=SpO₂ (red/IR LED)→`06 02`, `0a`=HRV→`06 03`; stop = `00 `. (`0c` seen, unidentified.) | + +## Async stream (be940003) + +| type cmd | meaning | decode | confidence | +|---|---|---|---| +| `06 00` | live status | steps `u16[0]` (verified 635); distance `u16[2]`, calories `u16[4]` | steps verified; rest inferred (max()-safe) | +| `06 01` | live heart rate | `payload[0]` bpm | verified (82→86) | +| `06 02` | live SpO₂ | `payload[0]` % | inferred (values 95–98) | +| `05 15` | history HR record | `[ts:4][hr:1]` | inferred | +| `05 18` | history activity record | `[ts:4][steps:2]…` | steps verified | + +## The AE00 login (open question) + +The capture shows a separate encrypted handshake on `AE00` (`fedcba` magic frames + AES-128-sized +blocks + ASCII `"pass"`). It is **not implemented** — the AES key isn't recoverable from a single +capture. Two facts bound the risk: + +- **Basic connect, time sync, device info, and status returned in plaintext on be940001 *before* the + AE00 handshake began** — so those work without it. +- The **live stream and history dump happened *after* the handshake** in the capture, because the app + always does AE00 first. Whether the ring *gates* streaming/history behind AE00 can only be + confirmed on-device. + +If on-device testing shows live/history data never arrives, the AE00 login is the culprit and needs a +follow-up capture (ideally with the key derivation) to implement. Connect + standard-`2A37` HR + +today's steps/battery should work regardless. + +## Second capture (2026-07-06, HRV/stress) — partial + +A longer capture taken after enabling HRV/stress monitoring surfaced the **enable toggles** and +several **new history record shapes**, but no on-screen values were recorded so the metric↔field +mapping is not yet locked. + +**Enable-monitoring writes** — `05 02` (payload `0x02` = enable, Colmi-family pref-write +convention), sent as a burst when the user toggled monitoring on; the `05 09` config read-back then +reported the enabled state. Observed metric cmds: `0x40, 0x42, 0x43, 0x44, 0x4e`. The driver now +sends this whole burst on connect (`TK5Encoder.enableAllMonitoring`) so the ring *records* these +metrics — the precondition for sleep/HRV/stress showing up in a later history dump. + +**HRV — VERIFIED and decoded.** It rides inside the `05 18` activity record at **payload offset 11** +(one byte, ms), and lives on the `06 03` frame at payload[3]. Confirmed against the app's displayed +values: 48 ms @1:00 and 79 ms @1:32 both matched exactly. The driver now emits HRV history (from +`05 18`) and live HRV (from `06 03`), and `TK5Coordinator` claims `.hrv`. + +**Stress — NOT a ring value; app-derived.** The displayed stress scores (33 @1:00, 32 @1:32) appear +*nowhere* in the ring's data as a raw byte, and they track HRV inversely (HRV up ⇒ stress down), so +SmartHealth computes stress from HRV on-device. We don't claim `.stress`; if wanted later, compute it +from HRV rather than expecting a ring field. + +**Other new record shapes** (structure only; not yet needed): + +| record | shape | notes | +|---|---|---| +| `05 11` | `[start_ts:4][end_ts:4][u16][u16][u16]` | session/day summary window (e.g. 13:00–13:30 → 99, 62, 3) | +| `05 17` | `[ts:4][flag:1][a][b][c]` | co-varying triple (~110/73/66), also live on `06 03` | +| `05 34` | `[ts:4][series:16]` | intraday series; carries the same HRV byte (48) | + +## Multi-record history frames (fixed 2026-07-07) + +History frames **concatenate many fixed-size records**; decoding only the first hid periodic data. +- `05 15`: packed **6-byte** HR records `[ts:4][flag:1][hr:1]` (e.g. eight hourly overnight samples). +- `05 18`: packed **20-byte** combined-vitals records `[ts:4][steps:2][hr@6][sys@7][dia@8][spo2@9] + [?@10][hrv@11]…`. We emit periodic **SpO₂ (@9)**, **HRV (@11)**, and **BP (@7/@8** — later verified + against the app, see below); HR comes from `05 15`. Steps are a *cumulative* daily counter (not + deltas), emitted as a per-day max rather than an additive bucket. + +## Sleep (`05 13`) — VERIFIED + +One logical record split across several be940003 frames. **Reassembly:** the header frame starts with +magic `af fa`, total concatenated payload length at bytes [2..3]; buffer until that many bytes arrive. + +Layout: 20-byte header `[af fa][totalLen:2][startTs:4][endTs:4]…`, then 8-byte segments +`[stage:1][startTs:4][durationSec:2][pad:1]`. Segments are contiguous → expand to per-minute stages, +emit one `.sleepTimeline`. + +**Stage tags verified against the app's on-screen breakdown** (deep 1h33 / light 4h9 / rem 2h10, +window 22:33–06:27, awake 0): `0xf1`=deep, `0xf2`=light, `0xf3`=rem, `0xf4`=awake. + +## Not yet decoded + +- **Temperature** — enable is sent; no data captured yet. +## Blood pressure — VERIFIED + +Two sources, both emitted: +- **Periodic** in `05 18` at offsets 7 (systolic) / 8 (diastolic) — verified 106/70 @6:00. +- **Live** on `06 03` in BP mode (`03 2f 01 01`): `[sys][dia][hr?]…` — verified 111/74, 112/75. + +The `06 03` frame is shared between BP (mode 0x01) and HRV (mode 0x0a); the decoder distinguishes by +whether the leading bytes fall in BP range. `.bloodPressure` capability declared; app-side calibration +(offset on read) works even though there's no ring-side BP-calibration command. diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md new file mode 100644 index 0000000..4277922 --- /dev/null +++ b/docs/hardware/tk5.md @@ -0,0 +1,101 @@ +--- +title: TK5 / SmartHealth +description: >- + The TK5 ring (SmartHealth app) — experimental support on a custom be940 + protocol, reverse-engineered from a single capture. Several fields unverified. +--- + +# TK5 / SmartHealth + +**PulseLoop support: 🧪 Experimental — limited support, reverse-engineered from a single capture** + +The TK5 pairs with the **SmartHealth** app (`com.zhuoting.healthyucheng`) and speaks its own +`be940` BLE protocol — nothing in common at the wire level with the [56ff / Jring](jring.md) or +[Colmi / Yawell](colmi.md) families. PulseLoop's driver was reconstructed from **one** Android +btsnoop HCI capture, which is why it is the only ring here that isn't marked as supported. + +!!! warning "Experimental — limited support" + This driver came from a single packet capture. Some byte offsets are **inferred rather than + confirmed**, skin temperature and stress aren't decoded at all, and the ring's encrypted `AE00` + login isn't implemented. Every decoded metric is range-gated before it's stored, so a misdecode + is dropped rather than saved as garbage — but expect gaps, and treat TK5 readings as approximate. + + If you own one, a second capture (especially one containing temperature) would settle most of + the open questions. See [Contributing](../project/contributing.md). + +## At a glance + +| | Detail | +|---|---| +| **SoC** | ❓ Unknown | +| **Bluetooth** | BLE (version ❓) | +| **PPG sensor** | ❓ Unknown — green LED for HR, red/IR for SpO₂ | +| **Accelerometer** | Yes (steps decoded; part ❓) | +| **Battery / life** | ❓ Unknown | +| **Waterproof** | ❓ Unknown | +| **Price** | ❓ Unknown | +| **Protocol** | Custom `be940`, variable-length frames, CRC16/CCITT-FALSE, cleartext | +| **App** | SmartHealth (`com.zhuoting.healthyucheng`) | +| **Advertised name** | `TK5 <4 hex>` — e.g. `TK5 24AA` | +| **Custom firmware** | ❓ Unknown | + +## Protocol + +| Property | Value | +|---|---| +| **Service** | `be940000-7333-be46-b7ae-689e71722bd5` | +| **Command char** | `be940001` — write **and** indicate (the app writes here and gets replies here) | +| **Stream char** | `be940003` — indicate: live HR / SpO₂ / steps and downloaded history records | +| **Frame** | `[type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE]`, `len` = total frame length | +| **CRC** | CRC16/CCITT-FALSE (poly `0x1021`, init `0xFFFF`, no reflection), little-endian | +| **Epoch** | Seconds since 2000-01-01, in the ring's **local wall-clock** (no timezone concept) | +| **Encryption** | Data channels are cleartext; a separate `AE00` login exists but is **not implemented** | + +Full byte-level teardown: [TK5 protocol notes](tk5-protocol.md). + +## Capabilities + +Everything PulseLoop reads from the TK5 is 🧪 — implemented from one capture, needs testing on +more hardware. + +| Capability | Status | Notes | +|---|:---:|---| +| Heart rate — spot | 🧪 | proprietary `06 01` stream; the standard `180D` char is ignored (see below) | +| Heart rate — live | 🧪 | | +| Heart rate — history | 🧪 | hourly, from `05 15` records | +| SpO₂ — spot | 🧪 | red/IR LED, `03 2f` mode `02` | +| SpO₂ — history | 🧪 | hourly, from `05 18` records | +| Steps / distance / calories | 🧪 | live push while connected; distance/calories offsets unverified | +| Sleep (light/deep/awake) | 🧪 | `05 13` timeline, matched to the app's own breakdown | +| REM sleep | 🧪 | | +| HRV | 🧪 | spot (`03 2f` mode `0a`) and hourly history | +| Blood pressure | 🧪 | periodic + live; no cuff calibration, so treat as a trend, not a number | +| Battery level | 🧪 | in-band; byte offset unverified, clamped to 0–100 | +| Find device | 🧪 | best-effort — no confirmed vibrate command in the capture | +| Stress | ❌¹ | | +| Skin temperature | ❌ | monitoring is enabled on connect, but no capture contains the data | +| Blood sugar | ❌ | | +| Continuous background sync | ❌ | live stream only while connected | +| FW update via app | ❓ | | + +¹ The ring doesn't store stress — SmartHealth derives it from HRV app-side — so PulseLoop doesn't +claim it rather than show an empty card. + +## Known limitations + +- **Single-capture provenance.** Offsets tagged `UNVERIFIED` in the driver source are inferred from + one session. They're range-gated, but they haven't been confirmed against a second device. +- **No encrypted login.** The ring exposes a separate `AE00` service with a `fedcba`/`"pass"` + handshake that PulseLoop doesn't implement. Live data and history flowed in plaintext without it + in the capture; if a unit turns out to gate them behind that login, this is why nothing arrives. +- **Standard heart-rate service is deliberately ignored.** The TK5 exposes `180D`/`2A37`, but it + emits a cached resting HR (~87 bpm) even off-finger, which would mask real readings. Live HR comes + solely from the proprietary `06 01` stream, as in the official app. +- **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. + +--- + +See the [hardware overview](index.md) for the cross-manufacturer comparison, or the +[TK5 protocol notes](tk5-protocol.md) for framing, commands, and GATT handles. diff --git a/mkdocs.yml b/mkdocs.yml index 7be5454..24b4f05 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,8 @@ nav: - Overview: hardware/index.md - Jring / 56ff: hardware/jring.md - Colmi / Yawell: hardware/colmi.md + - TK5 / SmartHealth: hardware/tk5.md + - TK5 protocol notes: hardware/tk5-protocol.md - SIMSONLAB: hardware/simsonlab.md - Premium Rings: hardware/premium.md - Platforms: diff --git a/tasks/2026-07-08-tk5-timezone-decode-fix-design.md b/tasks/2026-07-08-tk5-timezone-decode-fix-design.md new file mode 100644 index 0000000..4abd5d6 --- /dev/null +++ b/tasks/2026-07-08-tk5-timezone-decode-fix-design.md @@ -0,0 +1,94 @@ +# TK5 timestamp timezone-decode fix + +## Problem + +The TK5 ring has no timezone concept. `TK5Encoder.setTime` (`TK5Encoder.swift:10-17`) sets the +ring's clock by sending raw **local** wall-clock fields (year/month/day/hour/minute/second, via +`Calendar.current.dateComponents`) with no timezone byte — the ring just starts ticking forward in +real seconds from whatever instant those fields describe. + +`TK5Bytes.date(_:)` (`TK5Protocol.swift:136-139`) decodes the ring's reported seconds by adding a +fixed **UTC**-anchored 2000-01-01 epoch offset and nothing else: + +```swift +static func date(_ ringSeconds: Int) -> Date { + Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset) +} +``` + +Tracing a full encode → tick → decode round trip (with `off` = the device's UTC offset in +seconds, e.g. `-14400` for Eastern Daylight Time): + +- Encode at true absolute instant `T0`: the ring is handed local wall-clock fields equal to the + components of `T0` in local time. Internally the ring stores this as a naive "seconds since + 2000" counter: `ringCounter(T0) = (T0 + off) - epoch2000`. +- The ring's counter ticks in real seconds, so at a later true instant `T1`: + `ringCounter(T1) = T1 + off - epoch2000`. +- Current decode: `Date(timeIntervalSince1970: ringCounter(T1) + epoch2000) = T1 + off` — an + absolute instant that is off by a full UTC-offset from the true `T1`. +- When the app then asks `Calendar.current` for local components of that decoded `Date`, the + offset is applied **again**, so the displayed/derived local time is `trueLocalTime + off` — + double-counting the offset instead of cancelling it. + +Concretely, for a device in Eastern time (`off = -4h` in EDT), a true ~10pm local bedtime decodes +as if it were 6pm. That lands on the wrong side of `Calendar.wakingDay(forSleepStart:)`'s 7pm +day-boundary check (`PulseServices.swift:552-556`), filing the sleep session under *yesterday's* +date instead of today's. The Sleep page's day view requires an exact date match +(`SleepService.sleepRange`, anchor via `dayReferenceNight`) and shows nothing; the Home page's +`SleepService.latestSleep` tolerates sessions up to a day stale and still shows it — which matches +the reported symptom (visible on Home, missing from the Sleep page for that night). + +The same `TK5Bytes.date` helper decodes every other TK5 history timestamp too (HR history, BP, +per-day step totals), so the same shift silently affects those, just less visibly than the sharp +day-boundary cutoff sleep sessions hit. + +## Fix + +Compensate for the offset in `TK5Bytes.date(_:)` by **subtracting** the timezone offset after +adding the epoch (the derivation above shows subtracting — not adding — recovers the true +instant). Add a `timeZone: TimeZone = .current` parameter for testability, matching the existing +`TK5Encoder.setTime(_:calendar:)` convention elsewhere in this codebase: + +```swift +static func date(_ ringSeconds: Int, timeZone: TimeZone = .current) -> Date { + let offset = TimeInterval(timeZone.secondsFromGMT()) + return Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset - offset) +} +``` + +Fix the symmetric (currently unused) `ringSeconds(_:)` the same way, for consistency: + +```swift +static func ringSeconds(_ date: Date, timeZone: TimeZone = .current) -> Int { + let offset = TimeInterval(timeZone.secondsFromGMT(for: date)) + return Int(date.timeIntervalSince1970 - epochOffset + offset) +} +``` + +**Out of scope / untouched:** +- `TK5Encoder.setTime` — it's a faithful capture replay of the ring's expected format; the bug is + purely in how we decode the ring's response, not in what we send. +- `Calendar.wakingDay(forSleepStart:)` / `SleepService.dayReferenceNight` — already correct; they + were being fed a mis-decoded `Date`. +- Any change to Colmi/jring decoding — they don't use `TK5Bytes.date` and aren't affected. +- The periodic-resync / `RingSyncCoordinator` gap discussed alongside this bug — tracked + separately for a distinct branch aimed at an upstream PR, since it applies to all three ring + types rather than being TK5-specific. + +## Testing + +Add a unit test in `TK5DecoderTests.swift` that decodes a known `ringSeconds` value under an +explicit non-UTC `TimeZone` (e.g. `America/New_York`, not `.current`, so the test is deterministic +regardless of the CI runner's local timezone) and asserts the recovered local wall-clock time +matches what `TK5Encoder.setTime` would have sent for that same instant — i.e. an encode → decode +round trip through both fixed helpers. + +Existing `TK5DecoderTests` are unaffected: none of them currently assert on the decoded `timestamp` +field of a `RingDecodedEvent`, only on the accompanying value (bpm, steps, SpO2, etc.), so this +fix changes no existing test's expected output. + +## Non-goals + +This spec does not cover the general ring periodic-resync gap (Colmi/jring/TK5 all only refresh +history on connect / manual sync / pull-to-refresh) — that's being designed separately for a +branch intended as an upstream contribution, kept distinct from this TK5-specific correctness fix.