From d4dd82bb5f719edd190001bd5859495f4fe488c7 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Mon, 6 Jul 2026 13:58:04 -0400 Subject: [PATCH 01/11] feat: add TK5 ring driver (SmartHealth protocol) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse-engineered from Android btsnoop HCI captures of the SmartHealth app. New driver under PulseLoop/RingProtocol/TK5*.swift mirroring the jring/Colmi Coordinator/Driver/SyncEngine/Decoder/Encoder split, normalizing into the shared RingDecodedEvent: - Protocol: be940 service, length-prefixed CRC16/CCITT-FALSE framing, 2000-epoch timestamps. Command channel (be940001, write+indicate) and async stream (be940003) split; standard 180D/2A37 as an auth-independent fallback HR source. - Decoded + verified against captures: live HR, live SpO2, day steps (activity update), battery, activity history, and HRV (05 18 payload offset 11 + live 06 03 — matched the app's 48ms/79ms readings). Stress is intentionally not claimed: the ring doesn't store it (SmartHealth derives it from HRV). - Connect handshake replays the app's exact sequence and enables all-day monitoring (05 xx 02 burst) so HRV/sleep/etc. get recorded for later sync. Wiring: RingDeviceType.tk5, coordinator registered, WearableModel "TK" catalog entry, and a RingBLEClient discovery tweak so a characteristic that is both the write target and a notify source (be940001) gets subscribed. jring/Colmi unaffected. Tests: PulseLoopTests/TK5DecoderTests.swift (11) validate CRC/framing/decode against captured bytes. Protocol notes in docs/TK5-Protocol.md. Not yet: sleep (no overnight capture exists yet) and the AE00 encrypted login (untested whether it gates streaming/history on-device). Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/RingBLEClient.swift | 21 ++- PulseLoop/RingProtocol/TK5Coordinator.swift | 44 ++++++ PulseLoop/RingProtocol/TK5Decoder.swift | 94 ++++++++++++ PulseLoop/RingProtocol/TK5Driver.swift | 67 ++++++++ PulseLoop/RingProtocol/TK5Encoder.swift | 89 +++++++++++ PulseLoop/RingProtocol/TK5Protocol.swift | 143 ++++++++++++++++++ PulseLoop/RingProtocol/TK5SyncEngine.swift | 101 +++++++++++++ PulseLoop/Views/Settings/DeviceHeroCard.swift | 1 + PulseLoop/Wearables/WearableCoordinator.swift | 2 + PulseLoop/Wearables/WearableModel.swift | 8 + PulseLoopTests/TK5DecoderTests.swift | 128 ++++++++++++++++ docs/TK5-Protocol.md | 117 ++++++++++++++ 12 files changed, 807 insertions(+), 8 deletions(-) create mode 100644 PulseLoop/RingProtocol/TK5Coordinator.swift create mode 100644 PulseLoop/RingProtocol/TK5Decoder.swift create mode 100644 PulseLoop/RingProtocol/TK5Driver.swift create mode 100644 PulseLoop/RingProtocol/TK5Encoder.swift create mode 100644 PulseLoop/RingProtocol/TK5Protocol.swift create mode 100644 PulseLoop/RingProtocol/TK5SyncEngine.swift create mode 100644 PulseLoopTests/TK5DecoderTests.swift create mode 100644 docs/TK5-Protocol.md diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 0f571c7..14547c4 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 { @@ -554,17 +555,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..591b464 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -0,0 +1,44 @@ +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), and the in-band battery. Stress is *not* + /// claimed — the ring doesn't store it; SmartHealth derives it from HRV app-side. Sleep/temperature + /// are omitted until a capture contains them, so no empty cards appear. + let capabilities: Set = [ + .heartRate, .spo2, .steps, .battery, .hrv, + .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..2a795f7 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -0,0 +1,94 @@ +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₂ %. UNVERIFIED (capture-inferred): values sat in 95–98, i.e. plausible. + guard let spo2 = frame.payload.first else { return [.commandAck(commandId: frame.cmd)] } + return [.spo2Result(value: Int(spo2), timestamp: now)] + + case (TK5FrameType.stream, TK5Command.liveExtended): + // Live HRV appears at payload[3] on this frame (e.g. `00 00 00 4f` = 79 ms, matching the + // app's live reading); other sub-frames carry it as 0 and are ignored. hrvRange-gated. + let p = frame.payload + guard p.count >= 4, p[3] > 0 else { return [.commandAck(commandId: frame.cmd)] } + return [.hrvSample(value: Int(p[3]), timestamp: now)] + + // MARK: History records (be940003, type 0x05) + + case (TK5FrameType.register, TK5Command.historyRecordShort): + // `[ts:4][hr:1]` — the single metric byte tracked the live HR range in the capture. + // UNVERIFIED (capture-inferred). + let p = frame.payload + guard p.count >= 5 else { return [.commandAck(commandId: frame.cmd)] } + return [.historyMeasurement(kind: .heartRate, value: Double(p[4]), + timestamp: TK5Bytes.date(TK5Bytes.u32(p, 0)))] + + case (TK5FrameType.register, TK5Command.historyRecordLong): + // `[ts:4][steps:2]…[hrv:1 @offset 11]…`. steps verified (0x027b = 635 matched the live + // status frame); HRV verified against the app (payload[11] = 48 ms @1:00, 79 ms @1:32). + // Steps → additive bucket (Colmi-style, timestamp-keyed so re-syncs are idempotent); HRV → + // history measurement, both range-gated by RingEventBridge. + let p = frame.payload + guard p.count >= 6 else { return [.commandAck(commandId: frame.cmd)] } + let timestamp = TK5Bytes.date(TK5Bytes.u32(p, 0)) + var events: [RingDecodedEvent] = [ + .activityBucket(timestamp: timestamp, steps: TK5Bytes.u16(p, 4), distanceMeters: 0), + ] + if p.count >= 12, p[11] > 0 { + events.append(.historyMeasurement(kind: .hrv, value: Double(p[11]), timestamp: timestamp)) + } + return events + + // 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)] + } + } +} diff --git a/PulseLoop/RingProtocol/TK5Driver.swift b/PulseLoop/RingProtocol/TK5Driver.swift new file mode 100644 index 0000000..be79110 --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Driver.swift @@ -0,0 +1,67 @@ +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 also subscribed as an auth-independent fallback live-HR source. +/// +/// 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() + + private let streamUUID = CBUUID(string: TK5UUIDs.stream) + private let hrMeasurementUUID = CBUUID(string: TK5UUIDs.heartRateMeasurement) + + init(writer: RingCommandWriter) { + self.writer = writer + } + + // MARK: BLE topology + let serviceUUIDs: [CBUUID] = [CBUUID(string: TK5UUIDs.service), CBUUID(string: TK5UUIDs.heartRateService)] + 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 + CBUUID(string: TK5UUIDs.heartRateMeasurement), // standard-BLE fallback HR + ] + 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)) + } + + // MARK: Inbound decode + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + if characteristic == hrMeasurementUUID { + return decodeStandardHeartRate(data) + } + guard let frame = TK5Frame(validating: data) else { + return [.unknown(commandId: data.first ?? 0, raw: data)] + } + return decoder.decode(frame) + } + + /// Decode a standard BLE Heart Rate Measurement (`2A37`): flags byte, then 8- or 16-bit bpm per + /// the format bit. Auth-independent, so it works regardless of the proprietary stream's state. + private func decodeStandardHeartRate(_ data: Data) -> [RingDecodedEvent] { + let bytes = [UInt8](data) + guard bytes.count >= 2 else { return [] } + let is16Bit = (bytes[0] & 0x01) != 0 + let bpm = is16Bit ? (bytes.count >= 3 ? Int(bytes[1]) | (Int(bytes[2]) << 8) : 0) : Int(bytes[1]) + guard bpm > 0 else { return [] } + return [.heartRateSample(bpm: bpm, timestamp: Date())] + } + + func makeSyncEngine() -> RingSyncEngine { + TK5SyncEngine(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/TK5Encoder.swift b/PulseLoop/RingProtocol/TK5Encoder.swift new file mode 100644 index 0000000..c69906a --- /dev/null +++ b/PulseLoop/RingProtocol/TK5Encoder.swift @@ -0,0 +1,89 @@ +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()) + return seq + } + + /// 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 stream start/stop. The app toggles `03 2f` (`0100` on / `0000` off) around the live + /// HR/SpO₂ stream on be940003. UNVERIFIED (capture-inferred): exact semantics of the `03 09` pokes. + func liveStreamStart() -> [UInt8] { logical("03", "2f", "0100") } + 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`. + static func date(_ ringSeconds: Int) -> Date { + Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset) + } + + /// Convert a `Date` to ring seconds (2000-epoch). + static func ringSeconds(_ date: Date) -> Int { + Int(date.timeIntervalSince1970 - epochOffset) + } +} diff --git a/PulseLoop/RingProtocol/TK5SyncEngine.swift b/PulseLoop/RingProtocol/TK5SyncEngine.swift new file mode 100644 index 0000000..aa1b9db --- /dev/null +++ b/PulseLoop/RingProtocol/TK5SyncEngine.swift @@ -0,0 +1,101 @@ +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? + private let historyQuietTimeout: UInt64 = 4_000_000_000 // 4s of no records ⇒ dump complete + + 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() + historyWatchdog = Task { [weak self] in + try? await Task.sleep(nanoseconds: self?.historyQuietTimeout ?? 4_000_000_000) + 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 { + case .historyMeasurement, .activityBucket: + writer?.enqueue(Data(encoder.historyPage())) + armHistoryWatchdog() + default: + break + } + } + + // MARK: Live actions (proprietary 06-stream, toggled by 03 2f) + + func startHeartRate() { + writer?.enqueue(Data(encoder.liveStreamStart())) + } + + func stopHeartRate() { + writer?.enqueue(Data(encoder.liveStreamStop())) + } + + func startSpO2() { + // SpO₂ rides the same live stream as HR. + writer?.enqueue(Data(encoder.liveStreamStart())) + } + + func stopSpO2() { + 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/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index be11ead..31b49c8 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -222,6 +222,7 @@ struct DeviceHeroCard: View { switch type { case .jring: return "jring" case .colmiR02: return nil + case .tk5: return nil case nil: return nil } } 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/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index e9db18e..6f119b3 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -43,6 +43,13 @@ 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>". + static let tk5 = WearableModel( + id: "tk5", displayName: "TK5", brand: "TK", family: .tk5, + tint: PulseColors.spo2, blurb: "Heart rate · SpO₂ · Steps", + advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: nil + ) + // 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 +77,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/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift new file mode 100644 index 0000000..f948ccd --- /dev/null +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -0,0 +1,128 @@ +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 testHistoryActivityRecordDecodesTimestampAndSteps() { + // 05 18 record: ts (2000-epoch) + steps 0x027b = 635. + let frame = TK5Frame(validating: bytes("05181a00f05dde317b0200000000003600000f000000b457896a"))! + guard case let .activityBucket(timestamp, steps, _) = decoder.decode(frame).first else { + return XCTFail("expected activityBucket") + } + XCTAssertEqual(steps, 635) + // ts 0x31de5df0 in 2000-epoch seconds → 2026-07-06. + let comps = Calendar(identifier: .gregorian).dateComponents( + in: TimeZone(identifier: "UTC")!, from: timestamp) + XCTAssertEqual(comps.year, 2026) + XCTAssertEqual(comps.month, 7) + XCTAssertEqual(comps.day, 6) + } + + 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: 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)) + } +} diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md new file mode 100644 index 0000000..5a5fe01 --- /dev/null +++ b/docs/TK5-Protocol.md @@ -0,0 +1,117 @@ +# 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. + +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-BLE fallback live HR (auth-independent) | +| `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 UTC** (add 946684800 for Unix). Confirmed against the capture's +wall-clock time. + +## 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 2f` | live stream on/off | `0100` on / `0000` off | + +## 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) | + +## Not yet decoded + +- **Sleep** — no sleep record has appeared in *any* capture (ring never worn overnight). The enable + burst now records it once worn; the record format still needs a fresh overnight capture (or the + app's displayed sleep breakdown) to decode. Likely a `05 xx` shape with nighttime timestamps. +- **Temperature** — enable is sent; no data captured yet. Blood pressure: no sensor evidence. From efe22a10ce5507291fe29ed074b8e2b87c69e82d Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 07:40:09 -0400 Subject: [PATCH 02/11] feat: on-demand HR/SpO2/HRV measurements for TK5 Add manual spot readings for the TK5 and fix the live-measurement mode selection, verified on-device. - Live measurement mode: the 03 2f command's payload is [enable][mode]; the mode byte picks the sensor/LED (HR 0x00 green -> 06 01, SpO2 0x02 red/IR -> 06 02, HRV 0x0a -> 06 03). Previously all three used mode 0x00, so on-demand SpO2/HRV lit the wrong LED and failed. Now each uses its own mode. - Add .manualHrv capability + measureHRV() + startHRV/stopHRV on RingSyncEngine (no-op default for other rings); wire a "Measure HRV" button in Vitals and an HRV case through MeasurementSheet. Also declare .manualHeartRate/.manualSpo2 for the TK5 so those buttons appear. - Remove the standard 180D/2A37 subscription: it streamed a cached resting HR (~87 bpm) even off-finger, giving a constant false on-demand reading. Live HR now comes solely from the proprietary 06 01 stream (matches the app). - Gate the SpO2 decode to 70..100 so a warm-up 0 isn't surfaced as a reading. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Coordinator.swift | 6 +++ PulseLoop/RingProtocol/TK5Decoder.swift | 8 +++- PulseLoop/RingProtocol/TK5Driver.swift | 26 +++-------- PulseLoop/RingProtocol/TK5Encoder.swift | 11 +++-- PulseLoop/RingProtocol/TK5Protocol.swift | 5 +- PulseLoop/RingProtocol/TK5SyncEngine.swift | 19 ++++++-- PulseLoop/Services/RingSyncCoordinator.swift | 26 +++++++++++ PulseLoop/Views/MeasurementModal.swift | 48 ++++++++++++++++---- PulseLoop/Views/VitalsView.swift | 5 +- PulseLoop/Wearables/WearableCapability.swift | 1 + PulseLoop/Wearables/WearableDriver.swift | 8 ++++ PulseLoopTests/TK5DecoderTests.swift | 11 +++++ docs/TK5-Protocol.md | 4 +- 13 files changed, 136 insertions(+), 42 deletions(-) diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 591b464..504d906 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -30,8 +30,14 @@ final class TK5Coordinator: WearableCoordinator { /// live, verified against the app's displayed values), and the in-band battery. Stress is *not* /// claimed — the ring doesn't store it; SmartHealth derives it from HRV app-side. Sleep/temperature /// are omitted until a capture contains them, so no empty cards appear. + /// + /// `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, + .manualHeartRate, .manualSpo2, .manualHrv, .realtimeHeartRate, .realtimeSteps, .findDevice, ] diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift index 2a795f7..fbab609 100644 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -32,8 +32,12 @@ struct TK5Decoder { return [.heartRateSample(bpm: Int(bpm), timestamp: now)] case (TK5FrameType.stream, TK5Command.liveSpo2): - // 1-byte live SpO₂ %. UNVERIFIED (capture-inferred): values sat in 95–98, i.e. plausible. - guard let spo2 = frame.payload.first else { return [.commandAck(commandId: frame.cmd)] } + // 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): diff --git a/PulseLoop/RingProtocol/TK5Driver.swift b/PulseLoop/RingProtocol/TK5Driver.swift index be79110..e59d02d 100644 --- a/PulseLoop/RingProtocol/TK5Driver.swift +++ b/PulseLoop/RingProtocol/TK5Driver.swift @@ -15,20 +15,22 @@ final class TK5Driver: WearableDriver { private weak var writer: RingCommandWriter? private let decoder = TK5Decoder() - private let streamUUID = CBUUID(string: TK5UUIDs.stream) - private let hrMeasurementUUID = CBUUID(string: TK5UUIDs.heartRateMeasurement) - init(writer: RingCommandWriter) { self.writer = writer } // MARK: BLE topology - let serviceUUIDs: [CBUUID] = [CBUUID(string: TK5UUIDs.service), CBUUID(string: TK5UUIDs.heartRateService)] + // + // 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 - CBUUID(string: TK5UUIDs.heartRateMeasurement), // standard-BLE fallback HR ] let batteryServiceUUID: CBUUID? = nil // battery is in-band (0x02 0x00 status, payload[5]) let batteryCharUUID: CBUUID? = nil @@ -41,26 +43,12 @@ final class TK5Driver: WearableDriver { // MARK: Inbound decode func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { - if characteristic == hrMeasurementUUID { - return decodeStandardHeartRate(data) - } guard let frame = TK5Frame(validating: data) else { return [.unknown(commandId: data.first ?? 0, raw: data)] } return decoder.decode(frame) } - /// Decode a standard BLE Heart Rate Measurement (`2A37`): flags byte, then 8- or 16-bit bpm per - /// the format bit. Auth-independent, so it works regardless of the proprietary stream's state. - private func decodeStandardHeartRate(_ data: Data) -> [RingDecodedEvent] { - let bytes = [UInt8](data) - guard bytes.count >= 2 else { return [] } - let is16Bit = (bytes[0] & 0x01) != 0 - let bpm = is16Bit ? (bytes.count >= 3 ? Int(bytes[1]) | (Int(bytes[2]) << 8) : 0) : Int(bytes[1]) - guard bpm > 0 else { return [] } - return [.heartRateSample(bpm: bpm, timestamp: Date())] - } - func makeSyncEngine() -> RingSyncEngine { TK5SyncEngine(writer: writer) } diff --git a/PulseLoop/RingProtocol/TK5Encoder.swift b/PulseLoop/RingProtocol/TK5Encoder.swift index c69906a..0d119d2 100644 --- a/PulseLoop/RingProtocol/TK5Encoder.swift +++ b/PulseLoop/RingProtocol/TK5Encoder.swift @@ -64,9 +64,14 @@ struct TK5Encoder { // MARK: - Live actions - /// Live stream start/stop. The app toggles `03 2f` (`0100` on / `0000` off) around the live - /// HR/SpO₂ stream on be940003. UNVERIFIED (capture-inferred): exact semantics of the `03 09` pokes. - func liveStreamStart() -> [UInt8] { logical("03", "2f", "0100") } + /// 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 diff --git a/PulseLoop/RingProtocol/TK5Protocol.swift b/PulseLoop/RingProtocol/TK5Protocol.swift index acbddd0..f466256 100644 --- a/PulseLoop/RingProtocol/TK5Protocol.swift +++ b/PulseLoop/RingProtocol/TK5Protocol.swift @@ -29,8 +29,9 @@ enum TK5UUIDs { /// Async stream — live HR / steps / SpO₂ and downloaded history records (indicate). static let stream = "be940003-7333-be46-b7ae-689e71722bd5" - /// Standard BLE Heart Rate service + measurement char, present on the ring as an - /// auth-independent fallback live-HR source (the proprietary stream needs the ring "worn"). + /// Standard BLE Heart Rate service + measurement char. Present on the ring but **deliberately not + /// subscribed** (see `TK5Driver`): it emits a cached resting HR even off-finger (~87 bpm), which + /// would mask real on-demand readings. Kept here only to document the GATT layout. static let heartRateService = "180D" static let heartRateMeasurement = "2A37" } diff --git a/PulseLoop/RingProtocol/TK5SyncEngine.swift b/PulseLoop/RingProtocol/TK5SyncEngine.swift index aa1b9db..6464214 100644 --- a/PulseLoop/RingProtocol/TK5SyncEngine.swift +++ b/PulseLoop/RingProtocol/TK5SyncEngine.swift @@ -72,10 +72,14 @@ final class TK5SyncEngine: RingSyncEngine { } } - // MARK: Live actions (proprietary 06-stream, toggled by 03 2f) + // 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.liveStreamStart())) + writer?.enqueue(Data(encoder.heartRateStart())) } func stopHeartRate() { @@ -83,14 +87,21 @@ final class TK5SyncEngine: RingSyncEngine { } func startSpO2() { - // SpO₂ rides the same live stream as HR. - writer?.enqueue(Data(encoder.liveStreamStart())) + 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())) } diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 7532909..718f7e3 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -17,6 +17,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. @@ -32,6 +33,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. @@ -51,6 +53,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 @@ -262,6 +266,26 @@ final class RingSyncCoordinator { 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() + hrvState = result.map { .done($0) } ?? .failed + return result + } + // MARK: - Event handling private func handle(_ event: PulseEvent) { @@ -277,6 +301,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() case let .deviceStateChanged(state, _): diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 256588d..17a6a8f 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 { @@ -147,11 +167,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/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index d96949d..cfc888f 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -51,7 +51,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 } @@ -59,6 +59,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 a859f14..010250d 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/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 27e52e7..494c554 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) @@ -158,6 +162,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/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index f948ccd..399b394 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -108,6 +108,17 @@ final class TK5DecoderTests: XCTestCase { } } + // 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: Coordinator recognition @MainActor diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md index 5a5fe01..db918d7 100644 --- a/docs/TK5-Protocol.md +++ b/docs/TK5-Protocol.md @@ -16,7 +16,7 @@ which range-gates each metric, so a misdecoded byte is dropped rather than persi | `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-BLE fallback live HR (auth-independent) | +| `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 | @@ -51,7 +51,7 @@ wall-clock time. | `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 2f` | live stream on/off | `0100` on / `0000` off | +| `03 2f` | live measurement on/off | payload `[enable:1][mode:1]`; **mode picks the sensor**: `00`=HR (green LED)→`06 01`, `02`=SpO₂ (red/IR LED)→`06 02`, `0a`=HRV→`06 03`; stop = `00 00` | ## Async stream (be940003) From b24bc467b8733b08052816f684db5e9f454fef4d Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 08:07:49 -0400 Subject: [PATCH 03/11] feat: TK5 sleep decoding + multi-record periodic history - Multi-record history: history frames concatenate many fixed-size records; the decoder only read the first, hiding periodic data. Now walk all records. 05 15 = packed 6-byte HR records (hr at offset 5; fixes an off-by-one that read the flag byte). 05 18 = packed 20-byte combined-vitals records; emit periodic SpO2 (@9) + HRV (@11) per record (HR from 05 15). Steps here are a cumulative daily counter, not deltas, and sync live, so they're no longer re-emitted as buckets. - Sleep (05 13): reassemble the multi-frame record (header magic af fa, total length at bytes 2..3) and decode header + 8-byte stage segments into a per-minute sleep timeline. Stage tags verified against the app's on-screen breakdown (deep 93 / light 249 / rem 130 min): f1=deep, f2=light, f3=rem, f4=awake. Declare .sleep/.remSleep. Tests: multi-record HR/SpO2/HRV frames and full sleep decode vs the app's displayed minute totals. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Coordinator.swift | 1 + PulseLoop/RingProtocol/TK5Decoder.swift | 101 ++++++++++++++++---- PulseLoop/RingProtocol/TK5Driver.swift | 27 ++++++ PulseLoop/RingProtocol/TK5Protocol.swift | 7 +- PulseLoopTests/TK5DecoderTests.swift | 51 +++++++--- docs/TK5-Protocol.md | 28 +++++- 6 files changed, 177 insertions(+), 38 deletions(-) diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 504d906..316c7bb 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -37,6 +37,7 @@ final class TK5Coordinator: WearableCoordinator { /// one stream, so any of them can be measured on demand. let capabilities: Set = [ .heartRate, .spo2, .steps, .battery, .hrv, + .sleep, .remSleep, .manualHeartRate, .manualSpo2, .manualHrv, .realtimeHeartRate, .realtimeSteps, .findDevice, diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift index fbab609..b653ad7 100644 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -50,28 +50,36 @@ struct TK5Decoder { // MARK: History records (be940003, type 0x05) case (TK5FrameType.register, TK5Command.historyRecordShort): - // `[ts:4][hr:1]` — the single metric byte tracked the live HR range in the capture. - // UNVERIFIED (capture-inferred). - let p = frame.payload - guard p.count >= 5 else { return [.commandAck(commandId: frame.cmd)] } - return [.historyMeasurement(kind: .heartRate, value: Double(p[4]), - timestamp: TK5Bytes.date(TK5Bytes.u32(p, 0)))] + // 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): - // `[ts:4][steps:2]…[hrv:1 @offset 11]…`. steps verified (0x027b = 635 matched the live - // status frame); HRV verified against the app (payload[11] = 48 ms @1:00, 79 ms @1:32). - // Steps → additive bucket (Colmi-style, timestamp-keyed so re-syncs are idempotent); HRV → - // history measurement, both range-gated by RingEventBridge. - let p = frame.payload - guard p.count >= 6 else { return [.commandAck(commandId: frame.cmd)] } - let timestamp = TK5Bytes.date(TK5Bytes.u32(p, 0)) - var events: [RingDecodedEvent] = [ - .activityBucket(timestamp: timestamp, steps: TK5Bytes.u16(p, 4), distanceMeters: 0), - ] - if p.count >= 12, p[11] > 0 { - events.append(.historyMeasurement(kind: .hrv, value: Double(p[11]), timestamp: timestamp)) + // Packed **20-byte** combined-vitals records: `[ts:4][steps:2][hr@6][sys?@7][dia?@8] + // [spo2@9][?@10][hrv@11]…`. Emit periodic SpO₂ + HRV per record (HR comes from the paired + // `05 15` stream, so it isn't re-emitted here). HRV verified against the app (48/79 ms); + // SpO₂ inferred (95–98, plausible) and range-gated. Steps here are a *cumulative* daily + // counter (not per-record deltas) and already sync from the live `06 00` status, so they're + // deliberately not re-emitted as buckets. BP (offsets 7/8) is left out pending a verified + // reading. All samples are range-gated downstream. + var events: [RingDecodedEvent] = [] + for r in records(in: frame.payload, size: 20) { + let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) + 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 + return events.isEmpty ? [.commandAck(commandId: frame.cmd)] : events // MARK: Command channel (be940001) @@ -95,4 +103,59 @@ struct TK5Decoder { return [.commandAck(commandId: frame.cmd)] } } + + /// 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 index e59d02d..273c190 100644 --- a/PulseLoop/RingProtocol/TK5Driver.swift +++ b/PulseLoop/RingProtocol/TK5Driver.swift @@ -41,14 +41,41 @@ final class TK5Driver: WearableDriver { 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 func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { guard let frame = TK5Frame(validating: data) else { return [.unknown(commandId: data.first ?? 0, raw: data)] } + if frame.type == TK5FrameType.register, frame.cmd == TK5Command.sleepRecord { + return reassembleSleep(frame.payload) + } return decoder.decode(frame) } + 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 [] } + let record = sleepBuffer + sleepBuffer = [] + sleepTotal = 0 + return decoder.decodeSleep(record) + } + func makeSyncEngine() -> RingSyncEngine { TK5SyncEngine(writer: writer) } diff --git a/PulseLoop/RingProtocol/TK5Protocol.swift b/PulseLoop/RingProtocol/TK5Protocol.swift index f466256..14d9434 100644 --- a/PulseLoop/RingProtocol/TK5Protocol.swift +++ b/PulseLoop/RingProtocol/TK5Protocol.swift @@ -66,9 +66,10 @@ enum TK5Command { static let liveSpo2: UInt8 = 0x02 // 1-byte SpO₂ % static let liveExtended: UInt8 = 0x03 // extended live status - // type 0x05 (async stream: history activity records on be940003) - static let historyRecordShort: UInt8 = 0x15 // 6-byte activity record (ts + steps) - static let historyRecordLong: UInt8 = 0x18 // 20-byte activity record (ts + steps + metrics) + // type 0x05 (async stream: history records on be940003) + static let historyRecordShort: UInt8 = 0x15 // packed 6-byte HR records (ts + hr) + static let historyRecordLong: UInt8 = 0x18 // packed 20-byte combined-vitals records + static let sleepRecord: UInt8 = 0x13 // multi-frame sleep timeline (header + stage segments) } /// Frame parsing + building for the TK5's length-prefixed CRC16 protocol. diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 399b394..29197ae 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -78,19 +78,29 @@ final class TK5DecoderTests: XCTestCase { XCTAssertEqual(percent, 100) } - func testHistoryActivityRecordDecodesTimestampAndSteps() { - // 05 18 record: ts (2000-epoch) + steps 0x027b = 635. - let frame = TK5Frame(validating: bytes("05181a00f05dde317b0200000000003600000f000000b457896a"))! - guard case let .activityBucket(timestamp, steps, _) = decoder.decode(frame).first else { - return XCTFail("expected activityBucket") + 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(steps, 635) - // ts 0x31de5df0 in 2000-epoch seconds → 2026-07-06. - let comps = Calendar(identifier: .gregorian).dateComponents( - in: TimeZone(identifier: "UTC")!, from: timestamp) - XCTAssertEqual(comps.year, 2026) - XCTAssertEqual(comps.month, 7) - XCTAssertEqual(comps.day, 6) + 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( + "0518a6001cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf3100003f7049610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f000000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df3100003a6a465f0c8002000f000000d89dc5a3"))! + 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]) } func testHistoryRecordDecodesHRV() { @@ -108,6 +118,23 @@ final class TK5DecoderTests: XCTestCase { } } + // 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("affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c90400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f34518df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c10000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df31050100f15f53df311e0100f27d54df31400400") + 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() { diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md index db918d7..3255b47 100644 --- a/docs/TK5-Protocol.md +++ b/docs/TK5-Protocol.md @@ -109,9 +109,29 @@ from HRV rather than expecting a ring field. | `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)** and **HRV (@11)**; HR comes from `05 15`. Steps are + a *cumulative* daily counter (not deltas) and sync live, so not re-emitted. BP (@7/@8, plausible + 110/75-type values) left out pending a verified reading. + +## 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 -- **Sleep** — no sleep record has appeared in *any* capture (ring never worn overnight). The enable - burst now records it once worn; the record format still needs a fresh overnight capture (or the - app's displayed sleep breakdown) to decode. Likely a `05 xx` shape with nighttime timestamps. -- **Temperature** — enable is sent; no data captured yet. Blood pressure: no sensor evidence. +- **Temperature** — enable is sent; no data captured yet. +- **Blood pressure** — likely at `05 18` offsets 7/8 (systolic/diastolic; values look plausible) but + unverified against a displayed reading, so not emitted. From 075ae91fa1bf49e1fe529eaaf17fd53b73c617f8 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 08:22:06 -0400 Subject: [PATCH 04/11] feat: TK5 blood pressure decoding BP verified against the app's displayed readings (106/70, 111/74, 112/75). - Periodic BP from 05 18 records at offsets 7 (systolic) / 8 (diastolic). - Live BP from 06 03 in BP mode (03 2f 01 01): [sys][dia][hr?]. 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. - Declare .bloodPressure so the BP card shows; app-side calibration works. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Coordinator.swift | 2 +- PulseLoop/RingProtocol/TK5Decoder.swift | 19 +++++++++++++++---- PulseLoopTests/TK5DecoderTests.swift | 21 +++++++++++++++++++++ docs/TK5-Protocol.md | 13 ++++++++++--- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 316c7bb..3c52090 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -36,7 +36,7 @@ final class TK5Coordinator: WearableCoordinator { /// `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, + .heartRate, .spo2, .steps, .battery, .hrv, .bloodPressure, .sleep, .remSleep, .manualHeartRate, .manualSpo2, .manualHrv, .realtimeHeartRate, .realtimeSteps, diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift index b653ad7..91a686e 100644 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -41,11 +41,18 @@ struct TK5Decoder { return [.spo2Result(value: Int(spo2), timestamp: now)] case (TK5FrameType.stream, TK5Command.liveExtended): - // Live HRV appears at payload[3] on this frame (e.g. `00 00 00 4f` = 79 ms, matching the - // app's live reading); other sub-frames carry it as 0 and are ignored. hrvRange-gated. + // This live frame carries two different 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. let p = frame.payload - guard p.count >= 4, p[3] > 0 else { return [.commandAck(commandId: frame.cmd)] } - return [.hrvSample(value: Int(p[3]), timestamp: now)] + 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: frame.cmd)] // MARK: History records (be940003, type 0x05) @@ -72,6 +79,10 @@ struct TK5Decoder { var events: [RingDecodedEvent] = [] for r in records(in: frame.payload, size: 20) { let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) + if (60...250).contains(r[7]), (30...160).contains(r[8]) { + // Systolic @7 / diastolic @8 — verified against the app (106/70 @6:00). + 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)) } diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 29197ae..19c820b 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -101,6 +101,27 @@ final class TK5DecoderTests: XCTestCase { } 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 + } + + 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() { diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md index 3255b47..2e6a34d 100644 --- a/docs/TK5-Protocol.md +++ b/docs/TK5-Protocol.md @@ -51,7 +51,7 @@ wall-clock time. | `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 2f` | live measurement on/off | payload `[enable:1][mode:1]`; **mode picks the sensor**: `00`=HR (green LED)→`06 01`, `02`=SpO₂ (red/IR LED)→`06 02`, `0a`=HRV→`06 03`; stop = `00 00` | +| `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) @@ -133,5 +133,12 @@ window 22:33–06:27, awake 0): `0xf1`=deep, `0xf2`=light, `0xf3`=rem, `0xf4`=aw ## Not yet decoded - **Temperature** — enable is sent; no data captured yet. -- **Blood pressure** — likely at `05 18` offsets 7/8 (systolic/diastolic; values look plausible) but - unverified against a displayed reading, so not emitted. +## 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. From 8bbb49a39134a143b690ce206c6eda406a82cd5c Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 08:54:45 -0400 Subject: [PATCH 05/11] fix: sync per-day steps from TK5 history so today's count updates Steps were only coming from the live 06 00 status, which reports 0 right after a midnight reset, so today's ActivityDaily row often never got populated and the Today card (which shows the most recent row) kept displaying yesterday's total. Emit per-day steps from the 05 18 history records as an activityUpdate (per-day max, keyed by each record's timestamp). The step field is a cumulative daily counter (verified: rises through the day, resets to 0 at midnight), so max() is correct and can't overcount. Every sync now populates each day's row, including today's, so the count tracks the ring and resets daily. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Decoder.swift | 6 ++++++ PulseLoopTests/TK5DecoderTests.swift | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift index 91a686e..00aacce 100644 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -79,6 +79,12 @@ struct TK5Decoder { var events: [RingDecodedEvent] = [] for r in records(in: frame.payload, size: 20) { let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) + // Steps @4/5 are a *cumulative* per-day counter (rises through the day, resets to 0 at + // midnight). Emit as an `activityUpdate` (per-day max) — not an additive bucket — so + // each day's row (incl. today's) tracks the ring's running total and resets daily. + // distance/calories are 0 here (max() leaves any live-status values intact). + 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]) { // Systolic @7 / diastolic @8 — verified against the app (106/70 @6:00). events.append(.bloodPressureSample(systolic: Int(r[7]), diastolic: Int(r[8]), timestamp: ts)) diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 19c820b..19582d5 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -107,6 +107,12 @@ final class TK5DecoderTests: XCTestCase { } 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() { From aa2959236f427fd67c662299a25a9e8bc77b9dbb Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 09:50:50 -0400 Subject: [PATCH 06/11] style: fix SwiftLint violations for CI - Split the long hex test frames (line-length errors that failed the required SwiftLint check) into concatenated literals. - Reduce TK5Decoder.decode cyclomatic complexity by extracting the live-extended and combined-vitals cases into helpers. - Give the HRV measurement its own failure copy (was showing the SpO2 message) and drop the over-long line. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Decoder.swift | 82 +++++++++++++------------ PulseLoop/Views/MeasurementModal.swift | 11 +++- PulseLoopTests/TK5DecoderTests.swift | 16 ++++- 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/PulseLoop/RingProtocol/TK5Decoder.swift b/PulseLoop/RingProtocol/TK5Decoder.swift index 00aacce..70738da 100644 --- a/PulseLoop/RingProtocol/TK5Decoder.swift +++ b/PulseLoop/RingProtocol/TK5Decoder.swift @@ -41,18 +41,7 @@ struct TK5Decoder { return [.spo2Result(value: Int(spo2), timestamp: now)] case (TK5FrameType.stream, TK5Command.liveExtended): - // This live frame carries two different 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. - let p = frame.payload - 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: frame.cmd)] + return decodeLiveExtended(frame.payload, cmd: frame.cmd, now: now) // MARK: History records (be940003, type 0x05) @@ -69,34 +58,7 @@ struct TK5Decoder { } case (TK5FrameType.register, TK5Command.historyRecordLong): - // Packed **20-byte** combined-vitals records: `[ts:4][steps:2][hr@6][sys?@7][dia?@8] - // [spo2@9][?@10][hrv@11]…`. Emit periodic SpO₂ + HRV per record (HR comes from the paired - // `05 15` stream, so it isn't re-emitted here). HRV verified against the app (48/79 ms); - // SpO₂ inferred (95–98, plausible) and range-gated. Steps here are a *cumulative* daily - // counter (not per-record deltas) and already sync from the live `06 00` status, so they're - // deliberately not re-emitted as buckets. BP (offsets 7/8) is left out pending a verified - // reading. All samples are range-gated downstream. - var events: [RingDecodedEvent] = [] - for r in records(in: frame.payload, size: 20) { - let ts = TK5Bytes.date(TK5Bytes.u32(r, 0)) - // Steps @4/5 are a *cumulative* per-day counter (rises through the day, resets to 0 at - // midnight). Emit as an `activityUpdate` (per-day max) — not an additive bucket — so - // each day's row (incl. today's) tracks the ring's running total and resets daily. - // distance/calories are 0 here (max() leaves any live-status values intact). - 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]) { - // Systolic @7 / diastolic @8 — verified against the app (106/70 @6:00). - 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: frame.cmd)] : events + return decodeCombinedVitals(frame.payload, cmd: frame.cmd) // MARK: Command channel (be940001) @@ -121,6 +83,46 @@ struct TK5Decoder { } } + /// 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 diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 17a6a8f..d8cdc08 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -128,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 { diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 19582d5..81f3ba5 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -91,7 +91,10 @@ final class TK5DecoderTests: XCTestCase { func testHistoryLongFramePacksPeriodicSpo2AndHRV() { // One 05 18 frame carries eight 20-byte combined-vitals records; each yields SpO₂ + HRV. let frame = TK5Frame(validating: bytes( - "0518a6001cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf3100003f7049610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f000000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df3100003a6a465f0c8002000f000000d89dc5a3"))! + "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 } @@ -151,7 +154,16 @@ final class TK5DecoderTests: XCTestCase { // 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("affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c90400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f34518df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c10000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df31050100f15f53df311e0100f27d54df31400400") + 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") } From af6fdc9cf2759fa8ff53c874735442795d94afa4 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 7 Jul 2026 12:41:12 -0400 Subject: [PATCH 07/11] fix: enable TK5 live-status stream so steps update in real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring only auto-pushes 06 00 status frames (current step count) after it receives 03 09 01 00 02 ("enable live status auto-push"). The startup sequence was missing that command, so the app only ever saw the one-time history dump and today's live step count never updated — it stayed frozen while connected. Verified in both captures: the first 06 00 frame appears immediately after the first 03 09, then streams for the rest of the session. Add enableLiveStatus() to the startup sequence. Co-Authored-By: Claude Fable 5 --- PulseLoop/RingProtocol/TK5Encoder.swift | 8 ++++++++ PulseLoopTests/TK5DecoderTests.swift | 9 +++++++++ docs/TK5-Protocol.md | 1 + 3 files changed, 18 insertions(+) diff --git a/PulseLoop/RingProtocol/TK5Encoder.swift b/PulseLoop/RingProtocol/TK5Encoder.swift index 0d119d2..0d966a6 100644 --- a/PulseLoop/RingProtocol/TK5Encoder.swift +++ b/PulseLoop/RingProtocol/TK5Encoder.swift @@ -38,9 +38,17 @@ struct TK5Encoder { 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 diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 81f3ba5..7d90901 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -185,6 +185,15 @@ final class TK5DecoderTests: XCTestCase { 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 diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md index 2e6a34d..236491b 100644 --- a/docs/TK5-Protocol.md +++ b/docs/TK5-Protocol.md @@ -51,6 +51,7 @@ wall-clock time. | `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) From 9049ddb25d8f58f8d26fe80f561b0bb606018d9f Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Wed, 8 Jul 2026 09:19:54 -0400 Subject: [PATCH 08/11] docs: add design spec for TK5 timezone-decode fix Root-causes the sleep-page/home-page discrepancy to a timezone double-application bug in TK5Bytes.date, and scopes the fix separately from the general ring periodic-resync gap (tracked for its own branch). Co-Authored-By: Claude Sonnet 5 --- ...26-07-08-tk5-timezone-decode-fix-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-tk5-timezone-decode-fix-design.md diff --git a/docs/superpowers/specs/2026-07-08-tk5-timezone-decode-fix-design.md b/docs/superpowers/specs/2026-07-08-tk5-timezone-decode-fix-design.md new file mode 100644 index 0000000..4abd5d6 --- /dev/null +++ b/docs/superpowers/specs/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. From b6815152d4829ece35a07c3bd89674b8ab4010ad Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Wed, 8 Jul 2026 09:31:10 -0400 Subject: [PATCH 09/11] fix: compensate for device UTC offset when decoding TK5 timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TK5Encoder.setTime sends the ring local wall-clock fields with no timezone concept, but TK5Bytes.date decoded the ring's reported seconds as if they were UTC — for non-UTC timezones this silently shifted every decoded timestamp by a full UTC-offset, which was enough to flip a sleep session onto the wrong side of the app's 7 PM day boundary (visible on Home, missing from the Sleep page). Co-Authored-By: Claude Sonnet 5 --- PulseLoop/RingProtocol/TK5Protocol.swift | 21 +++++++++---- PulseLoopTests/TK5DecoderTests.swift | 40 ++++++++++++++++++++++++ docs/TK5-Protocol.md | 9 ++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/PulseLoop/RingProtocol/TK5Protocol.swift b/PulseLoop/RingProtocol/TK5Protocol.swift index 14d9434..fb7fb37 100644 --- a/PulseLoop/RingProtocol/TK5Protocol.swift +++ b/PulseLoop/RingProtocol/TK5Protocol.swift @@ -133,13 +133,22 @@ enum TK5Bytes { 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`. - static func date(_ ringSeconds: Int) -> Date { - Date(timeIntervalSince1970: TimeInterval(ringSeconds) + epochOffset) + /// 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). - static func ringSeconds(_ date: Date) -> Int { - Int(date.timeIntervalSince1970 - epochOffset) + /// 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/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 7d90901..193f630 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -148,6 +148,46 @@ final class TK5DecoderTests: XCTestCase { } } + // 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() { diff --git a/docs/TK5-Protocol.md b/docs/TK5-Protocol.md index 236491b..fc48a83 100644 --- a/docs/TK5-Protocol.md +++ b/docs/TK5-Protocol.md @@ -38,8 +38,13 @@ Example — set time: `01 00 0e00 ea0707060c220e00 26c7` → `2026-07-06 12:34:1 ## Timestamps -Seconds since **2000-01-01 UTC** (add 946684800 for Unix). Confirmed against the capture's -wall-clock time. +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) From 0737ef9a7ea331178b3507fc1d30f62ec7718790 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Thu, 9 Jul 2026 17:27:42 -0400 Subject: [PATCH 10/11] TK5: limited-support badge and ring art, history sync fixes, docs --- .../tk5.imageset/Contents.json | 12 ++ .../Assets.xcassets/tk5.imageset/tk5.png | Bin 0 -> 176931 bytes PulseLoop/RingProtocol/TK5Coordinator.swift | 7 +- PulseLoop/RingProtocol/TK5Driver.swift | 24 +++- PulseLoop/RingProtocol/TK5SyncEngine.swift | 20 +++- PulseLoop/Services/RingSyncCoordinator.swift | 5 + PulseLoop/Views/PairingComponents.swift | 26 +++++ PulseLoop/Views/PairingView.swift | 45 +++++--- PulseLoop/Views/Settings/DeviceHeroCard.swift | 23 +++- PulseLoop/Wearables/WearableModel.swift | 42 ++++++- PulseLoopTests/DeviceHeroStatusTests.swift | 14 +++ PulseLoopTests/PairingMatchingTests.swift | 35 ++++++ PulseLoopTests/TK5DecoderTests.swift | 105 ++++++++++++++++++ README.md | 6 + docs/hardware/index.md | 96 +++++++++------- .../tk5-protocol.md} | 19 +++- docs/hardware/tk5.md | 101 +++++++++++++++++ mkdocs.yml | 2 + ...26-07-08-tk5-timezone-decode-fix-design.md | 0 19 files changed, 498 insertions(+), 84 deletions(-) create mode 100644 PulseLoop/Assets.xcassets/tk5.imageset/Contents.json create mode 100644 PulseLoop/Assets.xcassets/tk5.imageset/tk5.png rename docs/{TK5-Protocol.md => hardware/tk5-protocol.md} (92%) create mode 100644 docs/hardware/tk5.md rename {docs/superpowers/specs => tasks}/2026-07-08-tk5-timezone-decode-fix-design.md (100%) 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 0000000000000000000000000000000000000000..7bc85aceeb77c786e8e27f8bb0f052e9279de07a GIT binary patch literal 176931 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

aC}a{6&GU<~yG3muvITEwRY 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�u zpBXf6T^PSEA3G=;^;B0Be}6*jdR_J-QNR9`V>3T;kNGzG#Pu5mg0U#*f`G%HV{>Vz zcFq^FVhWwPq@t(gz3naN;Ah(%bfTcE8~-%7hJx;A>CBlk5kEYJy{tB84T5$CHgAvC z4tiQWW*@W~((}2rw@m@tF-5(zZgX?Y-^fy>-o*c>14ouSEtLg`TnJRO(gzn>WLm$-+YZ#Gw*JN!;!IfYeB8 z*qmfJ;G7bfFiC-#o2CbMT)^RA896vwYBH6DXISjzK}=@Caa-s6$i* zT_0Tpq*3YjlM($Z)=rx4P?27sX5n4#`KB{Np)!yqBKi!>oqvAp*$~@(%hc}96mAQC z=*^H{2i7h#&ofFq$zJgq!}9+Bum2~$0nD+3ponzOZxUA@ze2zZht=08Q@08EKZ)y% A3IG5A literal 0 HcmV?d00001 diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index 3c52090..872a8b6 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -27,9 +27,10 @@ final class TK5Coordinator: WearableCoordinator { } /// Metrics decoded from the captures: live + history HR, live SpO₂, day steps, HRV (history + - /// live, verified against the app's displayed values), and the in-band battery. Stress is *not* - /// claimed — the ring doesn't store it; SmartHealth derives it from HRV app-side. Sleep/temperature - /// are omitted until a capture contains them, so no empty cards appear. + /// 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 diff --git a/PulseLoop/RingProtocol/TK5Driver.swift b/PulseLoop/RingProtocol/TK5Driver.swift index 273c190..09b034e 100644 --- a/PulseLoop/RingProtocol/TK5Driver.swift +++ b/PulseLoop/RingProtocol/TK5Driver.swift @@ -4,7 +4,7 @@ import Foundation /// 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 also subscribed as an auth-independent fallback live-HR source. +/// 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`. @@ -49,14 +49,26 @@ final class TK5Driver: WearableDriver { 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)] } - if frame.type == TK5FrameType.register, frame.cmd == TK5Command.sleepRecord { - return reassembleSleep(frame.payload) + 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 } - return decoder.decode(frame) } private func reassembleSleep(_ payload: [UInt8]) -> [RingDecodedEvent] { @@ -69,8 +81,8 @@ final class TK5Driver: WearableDriver { } else { return [] // continuation with no header seen (mid-stream connect) — ignore } - guard sleepTotal > 0, sleepBuffer.count >= sleepTotal else { return [] } - let record = sleepBuffer + guard sleepTotal > 0, sleepBuffer.count >= sleepTotal else { return [] } // still buffering + let record = Array(sleepBuffer.prefix(sleepTotal)) sleepBuffer = [] sleepTotal = 0 return decoder.decodeSleep(record) diff --git a/PulseLoop/RingProtocol/TK5SyncEngine.swift b/PulseLoop/RingProtocol/TK5SyncEngine.swift index 6464214..1096fb0 100644 --- a/PulseLoop/RingProtocol/TK5SyncEngine.swift +++ b/PulseLoop/RingProtocol/TK5SyncEngine.swift @@ -32,7 +32,10 @@ final class TK5SyncEngine: RingSyncEngine { private var historyActive = false private var historyWatchdog: Task? - private let historyQuietTimeout: UInt64 = 4_000_000_000 // 4s of no records ⇒ dump complete + /// 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 @@ -45,8 +48,9 @@ final class TK5SyncEngine: RingSyncEngine { private func armHistoryWatchdog() { historyWatchdog?.cancel() + let timeout = historyQuietTimeout historyWatchdog = Task { [weak self] in - try? await Task.sleep(nanoseconds: self?.historyQuietTimeout ?? 4_000_000_000) + try? await Task.sleep(nanoseconds: timeout) guard !Task.isCancelled, let self, self.historyActive else { return } self.finishHistorySync() } @@ -64,9 +68,19 @@ final class TK5SyncEngine: RingSyncEngine { // While a dump is in flight, each history record pulls the next page and resets the quiet timer. guard historyActive else { return } switch event { - case .historyMeasurement, .activityBucket: + // 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 } diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index b43f6e3..cd2d494 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -326,6 +326,9 @@ 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 } @@ -346,6 +349,8 @@ final class RingSyncCoordinator { 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 } 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 6ffd3f4..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,7 +235,7 @@ struct DeviceHeroCard: View { switch type { case .jring: return "jring" case .colmiR02: return nil - case .tk5: return nil + case .tk5: return "tk5" case nil: return nil } } diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index b4b6866..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 { @@ -44,10 +73,11 @@ 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: "Heart rate · SpO₂ · Steps", - advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: nil + 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. 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 index 193f630..0f156bf 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -251,4 +251,109 @@ final class TK5DecoderTests: XCTestCase { 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 { + 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/TK5-Protocol.md b/docs/hardware/tk5-protocol.md similarity index 92% rename from docs/TK5-Protocol.md rename to docs/hardware/tk5-protocol.md index fc48a83..5496cbd 100644 --- a/docs/TK5-Protocol.md +++ b/docs/hardware/tk5-protocol.md @@ -1,3 +1,10 @@ +--- +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 @@ -5,6 +12,10 @@ 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. @@ -119,10 +130,10 @@ from HRV rather than expecting a ring field. 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)** and **HRV (@11)**; HR comes from `05 15`. Steps are - a *cumulative* daily counter (not deltas) and sync live, so not re-emitted. BP (@7/@8, plausible - 110/75-type values) left out pending a verified reading. +- `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 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/docs/superpowers/specs/2026-07-08-tk5-timezone-decode-fix-design.md b/tasks/2026-07-08-tk5-timezone-decode-fix-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-08-tk5-timezone-decode-fix-design.md rename to tasks/2026-07-08-tk5-timezone-decode-fix-design.md From 0ef727325268a3593999d43e5a488f88d393a89a Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Thu, 9 Jul 2026 17:56:36 -0400 Subject: [PATCH 11/11] TK5 tests: nonisolated deinit on FakeWriter to fix iOS 18 test-runner crash --- PulseLoopTests/TK5DecoderTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PulseLoopTests/TK5DecoderTests.swift b/PulseLoopTests/TK5DecoderTests.swift index 0f156bf..cce8d72 100644 --- a/PulseLoopTests/TK5DecoderTests.swift +++ b/PulseLoopTests/TK5DecoderTests.swift @@ -273,6 +273,7 @@ final class TK5DecoderTests: XCTestCase { @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) } }