diff --git a/PulseLoop/Assets.xcassets/luckring-tk18.imageset/Contents.json b/PulseLoop/Assets.xcassets/luckring-tk18.imageset/Contents.json new file mode 100644 index 0000000..22d438c --- /dev/null +++ b/PulseLoop/Assets.xcassets/luckring-tk18.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "luckring-tk18.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseLoop/Assets.xcassets/luckring-tk18.imageset/luckring-tk18.png b/PulseLoop/Assets.xcassets/luckring-tk18.imageset/luckring-tk18.png new file mode 100644 index 0000000..ead233a Binary files /dev/null and b/PulseLoop/Assets.xcassets/luckring-tk18.imageset/luckring-tk18.png differ diff --git a/PulseLoop/RingProtocol/LuckRingCoordinator.swift b/PulseLoop/RingProtocol/LuckRingCoordinator.swift new file mode 100644 index 0000000..ddcbad3 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingCoordinator.swift @@ -0,0 +1,73 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for the LuckRing / TK18 family (the "K6" vendor SDK). Declares what the driver can decode +/// and recognizes the device from its advertisement. The *protocol* is not TK18-specific — the whole +/// 0xFF64 LuckRing family speaks it — so the driver / encoder / decoder / sync engine it builds are the +/// shared `LuckRing*` types. This file is the whole of what makes a LuckRing a LuckRing: its advertised +/// identity and its capability set. +/// +/// Recognition is by **strong, family-exclusive signals**: the advertised `F618` service, or the +/// `0xFF64` manufacturer company ID (little-endian `64 FF` prefix), or a catalog name pattern (TK18). +/// The Android SDK matches on the company ID alone with no name whitelist, so a TK18 sibling that renames +/// itself is still claimed. +@MainActor +final class LuckRingCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .luckRing + + /// The manufacturer-data prefix: company ID `0xFF64` in the little-endian slot ⇒ `64 FF`. This is the + /// single signal the vendor app itself matches on, so it is authoritative. + static let manufacturerHexPrefix = "64ff" + + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + if advertisesService(advertisement) { return true } + if let mfg = advertisement.manufacturerData, mfg.hexString.hasPrefix(manufacturerHexPrefix) { + return true + } + if WearableModel.model(advertisedName: name)?.family == .luckRing { return true } + return false + } + + /// True when the advertisement carries the `F618` service, in either its 16-bit or full 128-bit form. + private static func advertisesService(_ advertisement: AdvertisementInfo) -> Bool { + advertisement.serviceUUIDs.contains { uuid in + let value = uuid.uuidString.uppercased() + return value == "F618" || value == "0000F618-0000-1000-8000-00805F9B34FB" + } + } + + /// The baseline the LuckRing driver can decode: live + history HR, live + history SpO₂ (with the all-day + /// log), day steps, sleep, HRV, temperature, blood pressure, stress, and the in-band battery, plus the + /// find-device buzz. All are unconditional promises — every metric maps onto a `LuckRing*` decoder path. + /// + /// **`bitmapGatedCapabilities` is empty on purpose.** The K6 `FUNCTION_CONTROL` (dataType 22) bitmap is + /// obfuscated in the decompile, so no capability can yet be deferred to the connected unit; the whole + /// baseline stands as a family promise. TK18 is the only hardware-tested unit — a `.limited` family — + /// so capabilities the physical ring refuses should be pruned here once on-device testing confirms them + /// (see `docs/hardware/luckring.md`). + let capabilities: Set = [ + .heartRate, .realtimeHeartRate, .manualHeartRate, + .spo2, .manualSpo2, .spo2History, + .steps, .realtimeSteps, + .sleep, .battery, + .hrv, .manualHrv, + .temperature, + .bloodPressure, .manualBloodPressure, + .stress, + .findDevice, + // The K6 auto-monitoring config (opcode 128: auto-HR on/off, interval, auto-SpO₂) is a real + // device knob — the firmware ships with it *off*, so exposing the interval UI is what lets the + // ring log history at all. + .measurementInterval, + ] + + let bitmapGatedCapabilities: Set = [] + + let iconSystemName = "circle.circle.fill" + + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + LuckRingDriver(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/LuckRingDecoder.swift b/PulseLoop/RingProtocol/LuckRingDecoder.swift new file mode 100644 index 0000000..02b759c --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingDecoder.swift @@ -0,0 +1,271 @@ +import Foundation + +/// Decodes reassembled LuckRing frames into the shared `RingDecodedEvent`. Frames arrive whole from +/// `LuckRingFrameAssembler`; this decoder dispatches on `dataType` and slices each record type per its +/// `ProcessDATA_TYPE_*` parser. +/// +/// **Timestamps are true UTC Unix seconds** (`TimeUtil.s2CForDev(sec, true) == sec*1000`), so — unlike +/// the jring/YCBT clocks, which store local wall-clock — decoding is a plain `Date(timeIntervalSince1970:)` +/// with no offset to un-apply. Range gating lives in `RingEventBridge`, so a misdecoded byte is dropped +/// rather than persisted; the decoder only cuts records and drops the ring's "no sample" fillers. +/// +/// **Every metric record uses one envelope**: `[total u16 LE][items u8]` then `items` fixed-stride +/// records. HR/HRV history and the live HR/HRV streams share the identical 5-byte record via the vendor's +/// `dealHeart` (a 3-byte sub-header + `[time u32][value u8]` records), so live vs. history is decided by +/// `dataType`, not by record shape. +struct LuckRingDecoder { + // A flat opcode router — each dataType is an independent record layout, not branching logic. + // swiftlint:disable:next cyclomatic_complexity + func decode(_ frame: LuckRingFrame, now: Date = Date()) -> [RingDecodedEvent] { + // A device ACK is a verdict on a command, not data. + if frame.cmdType == .ack { + return [.commandAck(commandId: frame.dataType)] + } + + let p = frame.payload + switch frame.dataType { + case LuckRingDataType.devInfo: + return decodeDeviceInfo(p) + case LuckRingDataType.battery: + guard let percent = p.first else { return [.commandAck(commandId: frame.dataType)] } + return [.battery(percent: Int(percent))] + + case LuckRingDataType.realSport, LuckRingDataType.historySport: + return decodeSport(p, dataType: frame.dataType) + case LuckRingDataType.sleep: + return decodeSleep(p, now: now) + + case LuckRingDataType.realHeart, LuckRingDataType.exerciseHeart: + return decodeLiveHeart(p, now: now) + case LuckRingDataType.historyHeart: + return decodeHistory(p, kind: .heartRate) + + case LuckRingDataType.realO2: + return decodeLiveSpO2(p, now: now) + case LuckRingDataType.historyO2: + return decodeHistory(p, kind: .spo2) + + case LuckRingDataType.realBP: + return decodeLiveBP(p, now: now) + case LuckRingDataType.historyBP: + return decodeHistoryBP(p) + + case LuckRingDataType.realHRV: + return decodeLive(p, now: now) { .hrvSample(value: Int($0), timestamp: $1) } + case LuckRingDataType.historyHRV: + return decodeHistory(p, kind: .hrv) + + case LuckRingDataType.realTemp: + return decodeTemperature(p, now: now, history: false) + case LuckRingDataType.historyTemp: + return decodeTemperature(p, now: now, history: true) + + case LuckRingDataType.stress: + return decodeLive(p, now: now) { .stressSample(value: Int($0), timestamp: $1) } + case LuckRingDataType.stressHistory: + return decodeHistory(p, kind: .stress) + + case LuckRingDataType.pairFinish, LuckRingDataType.findDevice, LuckRingDataType.devSync, + LuckRingDataType.functionControl, LuckRingDataType.unbind: + // Handshake/echo frames with no metric to persist — surfaced as acks (the raw-packet feed + // still shows them). `devSync` (9) carries a MixInfo TLV of settings/function bits, but the + // capability bitmap is obfuscated in the decompile, so nothing maps it yet. + return [.commandAck(commandId: frame.dataType)] + + default: + return [.unknown(commandId: frame.dataType, raw: Data(p))] + } + } + + // MARK: - Envelope helpers + + /// `[total u16 LE][items u8]` header, then `items` records. Returns each record's bytes, cut at the + /// declared item count. When `stride` is nil it is derived from the payload — needed for temperature, + /// whose 5-byte and 8-byte record variants share the same opcode (`K6_TempStruct.parse` vs + /// `parseFloat`). + private func records(_ payload: [UInt8], stride: Int?) -> [[UInt8]] { + guard payload.count >= 3 else { return [] } + let items = Int(payload[2]) + guard items > 0 else { return [] } + let body = Array(payload[3...]) + let step = stride ?? (body.count / items) + guard step > 0 else { return [] } + var out: [[UInt8]] = [] + var offset = 0 + for _ in 0.. Date { + Date(timeIntervalSince1970: TimeInterval(LuckRingBytes.u32(record, 0))) + } + + // MARK: - Records + + /// `K6_DevInfoStruct.getSoftwareVer()`: bytes `[1..5]` (customer.hardware.code.picture.font) joined by + /// dots. Byte `[0]` is the item count and is not part of the version. + private func decodeDeviceInfo(_ p: [UInt8]) -> [RingDecodedEvent] { + guard p.count >= 6 else { return [.commandAck(commandId: LuckRingDataType.devInfo)] } + let version = p[1...5].map { String(Int($0)) }.joined(separator: ".") + return [.firmware(version: version)] + } + + /// Sport records — 20 bytes each (`K6_Sport`): `[start u32][steps u32][distance u24(+pad)]` + /// `[calories u24(+pad)][duration u24(+pad)]`. Emitted as `.activityBucket` (per-interval, summed into + /// the day and upserted by timestamp) for both the live (4) and history (5) streams, so a re-sync is + /// idempotent. Calories are intentionally dropped (`.activityBucket` carries none). + private func decodeSport(_ p: [UInt8], dataType: UInt8) -> [RingDecodedEvent] { + let recs = records(p, stride: 20) + guard !recs.isEmpty else { return [.commandAck(commandId: dataType)] } + return recs.map { r in + .activityBucket(timestamp: ringDate(r), + steps: Int(LuckRingBytes.u32(r, 4)), + distanceMeters: Double(LuckRingBytes.u24(r, 8))) + } + } + + /// Live HR — 5-byte records `[time u32][bpm u8]` (`dealHeart`). An envelope with zero items is the + /// ring signalling the measurement ended, surfaced as `.heartRateComplete`. + private func decodeLiveHeart(_ p: [UInt8], now: Date) -> [RingDecodedEvent] { + let recs = records(p, stride: 5) + guard !recs.isEmpty else { return [.heartRateComplete(timestamp: now)] } + return recs.map { .heartRateSample(bpm: Int($0[4]), timestamp: ringDate($0)) } + } + + /// Live SpO₂ — 5-byte records `[time u32][spo2 u8]` (`K6_DATA_TYPE_REAL_O2`). + private func decodeLiveSpO2(_ p: [UInt8], now: Date) -> [RingDecodedEvent] { + let recs = records(p, stride: 5) + guard !recs.isEmpty else { return [.spo2Complete(timestamp: now)] } + return recs.map { .spo2Result(value: Int($0[4]), timestamp: ringDate($0)) } + } + + /// Live blood pressure — 6-byte records `[time u32][sys u8][dia u8]` (`K6_DATA_TYPE_REAL_BP`). + private func decodeLiveBP(_ p: [UInt8], now: Date) -> [RingDecodedEvent] { + let recs = records(p, stride: 6) + guard !recs.isEmpty else { return [.commandAck(commandId: LuckRingDataType.realBP)] } + return recs.map { + .bloodPressureSample(systolic: Int($0[4]), diastolic: Int($0[5]), timestamp: ringDate($0)) + } + } + + /// A generic single-value live stream — 5-byte records `[time u32][value u8]` — mapped through a + /// builder (HRV, stress). Empty envelope acks (nothing to complete). + private func decodeLive(_ p: [UInt8], now: Date, + _ make: (UInt8, Date) -> RingDecodedEvent) -> [RingDecodedEvent] { + let recs = records(p, stride: 5) + guard !recs.isEmpty else { return [.commandAck(commandId: 0)] } + return recs.map { make($0[4], ringDate($0)) } + } + + /// History for a 5-byte `[time u32][value u8]` type → `.historyMeasurement`. + private func decodeHistory(_ p: [UInt8], kind: MeasurementKind) -> [RingDecodedEvent] { + let recs = records(p, stride: 5) + guard !recs.isEmpty else { return [] } + return recs.map { .historyMeasurement(kind: kind, value: Double($0[4]), timestamp: ringDate($0)) } + } + + /// History blood pressure — 6-byte records fanned to a systolic and a diastolic row (each trends + /// independently), mirroring `EventPersistenceSubscriber`'s two-row storage. + private func decodeHistoryBP(_ p: [UInt8]) -> [RingDecodedEvent] { + let recs = records(p, stride: 6) + guard !recs.isEmpty else { return [] } + return recs.flatMap { r -> [RingDecodedEvent] in + let date = ringDate(r) + return [ + .historyMeasurement(kind: .bloodPressureSystolic, value: Double(r[4]), timestamp: date), + .historyMeasurement(kind: .bloodPressureDiastolic, value: Double(r[5]), timestamp: date), + ] + } + } + + /// Temperature — `K6_TempStruct`: `[time u32][value u16 LE]/10`. The stride is derived from the + /// envelope so both the 5-byte (`parse`) and 8-byte (`parseFloat`) record variants decode; the value + /// is read as a u16 when the record is wide enough, else a single byte, and scaled by 10. + private func decodeTemperature(_ p: [UInt8], now: Date, history: Bool) -> [RingDecodedEvent] { + let recs = records(p, stride: nil) + guard !recs.isEmpty else { + return history ? [] : [.commandAck(commandId: LuckRingDataType.realTemp)] + } + return recs.map { r in + let raw = r.count >= 6 ? LuckRingBytes.u16(r, 4) : Int(r.count > 4 ? r[4] : 0) + let celsius = Double(raw) / 10.0 + let date = ringDate(r) + return history + ? .historyMeasurement(kind: .temperature, value: celsius, timestamp: date) + : .temperatureSample(celsius: celsius, timestamp: date) + } + } + + // MARK: - Sleep + + /// Sleep timeline (`ProcessDATA_TYPE_SLEEP`): `[total u16][pageCount u8]`, then `pageCount` pages of + /// `[validCount u8]` + 15 × `[type u8][time u32 LE]` (76 B/page, only `validCount` entries valid). + /// + /// Types (`CEBC.SLEEPSTATUS`): 1 start, 2 deep, 3 light, 4 wake (ends a session), 5 movement. Each + /// entry's duration is the gap to the next entry; the segment's stage is the *earlier* entry's type, + /// mapped 1/3/5→light, 2→deep. A session runs from a start (or the first entry) to a wake, and is + /// emitted as a per-minute stage array stamped at the session start — the exact shape + /// `EventPersistenceSubscriber.persistSleepTimeline` groups back into stage blocks. + private func decodeSleep(_ p: [UInt8], now: Date) -> [RingDecodedEvent] { + guard p.count >= 3 else { return [.commandAck(commandId: LuckRingDataType.sleep)] } + let pageCount = Int(p[2]) + + var entries: [(type: UInt8, time: UInt32)] = [] + var offset = 3 + for _ in 0.. [RingDecodedEvent] { + var sessions: [RingDecodedEvent] = [] + var sessionStart: UInt32? + var stages: [SleepStage] = [] + + func flush() { + if let start = sessionStart, !stages.isEmpty { + sessions.append(.sleepTimeline(timestamp: Date(timeIntervalSince1970: TimeInterval(start)), + stages: stages)) + } + sessionStart = nil + stages = [] + } + + for i in entries.indices { + let entry = entries[i] + if entry.type == 1 { flush() } // explicit session start + if sessionStart == nil { sessionStart = entry.time } // implicit start + if entry.type == 4 { flush(); continue } // wake ends the session + + guard i + 1 < entries.count else { continue } // last entry has no duration + let delta = Int(entries[i + 1].time) - Int(entry.time) + let minutes = max(0, delta / 60) + let stage = sleepStage(entry.type) + stages.append(contentsOf: repeatElement(stage, count: minutes)) + } + flush() + return sessions + } + + private func sleepStage(_ type: UInt8) -> SleepStage { + switch type { + case 2: return .deep + case 4: return .awake + default: return .light // 1 start, 3 light, 5 movement all render as light sleep + } + } +} diff --git a/PulseLoop/RingProtocol/LuckRingDriver.swift b/PulseLoop/RingProtocol/LuckRingDriver.swift new file mode 100644 index 0000000..1c734c8 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingDriver.swift @@ -0,0 +1,85 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// LuckRing / TK18 driver. Topology is a single service `F618` with a write char `B002` and a notify char +/// `B001`; the standard `180D` Heart Rate characteristic is deliberately left unsubscribed (mirror the +/// YCBT rationale — the proprietary `07` stream reflects real finger contact). +/// +/// **Framing is identity.** The encoder / sync engine / history pager already emit fully-framed 20-byte +/// packets (a logical frame is split into head + continuation packets and enqueued individually — the +/// jring pattern), so `frame(_:)` returns its input untouched. `RingBLEClient`'s serialized `withResponse` +/// write queue with its 4 s ACK timeout handles pacing between packets. +/// +/// **Inbound: ACK-before-decode.** The ring retransmits a device-initiated **SEND** until the app answers +/// with a matching ACK (`queue/b.java`'s app-ACK rule), so `ingest` enqueues the protocol ACK *before* it +/// decodes — a slow decode must never be able to stall the ring. A device ACK or a SEND_NO_ACK is never +/// ACKed (the former is not data; the latter, by definition, expects no reply). +@MainActor +final class LuckRingDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let decoder = LuckRingDecoder() + private let packetizer = LuckRingPacketizer() + /// Notify packets → whole logical frames. A multi-packet frame (e.g. the ring's history streams) is + /// split across notifications, so it must be reassembled before decode. + private let assembler = LuckRingFrameAssembler() + /// The history pager. The driver owns it because only the driver sees frames (`noteReceived`), and it + /// is handed to the engine so `runStartup` can seed the catalog pass. + private let historySync: LuckRingHistorySync + + init(writer: RingCommandWriter) { + self.writer = writer + self.historySync = LuckRingHistorySync(writer: writer) + } + + // MARK: BLE topology + let serviceUUIDs: [CBUUID] = [CBUUID(string: LuckRingUUIDs.service)] + let writeUUID = CBUUID(string: LuckRingUUIDs.write) + let notifyUUIDs: [CBUUID] = [CBUUID(string: LuckRingUUIDs.notify)] + let batteryServiceUUID: CBUUID? = nil // battery is in-band (dataType 3) + let batteryCharUUID: CBUUID? = nil + + // MARK: Framing + /// Identity — the engine/encoder emit fully-framed 20-byte packets, enqueued one per packet. + func frame(_ command: Data) -> Data { command } + + // MARK: Lifecycle + + /// Auto-reconnect reuses this driver, so a frame half-assembled when the old link dropped would be + /// completed with bytes from the new one, and a history pass would still think it was mid-type. + func connectionDidStart() { + assembler.reset() + historySync.cancel() + } + + /// The history pager's settle/stall watchdogs are timers; a ring that drops mid-pass leaves them + /// stepping through the rest of the catalog into a write queue the reconnect will flush before its + /// handshake. Kill it at disconnect — the one place that can't race the reconnect. + func connectionDidEnd() { + assembler.reset() + historySync.cancel() + } + + // MARK: Inbound decode + + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + guard let frame = assembler.append(data) else { return [] } + + // ACK a device-initiated SEND before decoding — the ring retransmits until we do. + if frame.cmdType == .send { + writer?.enqueue(packetizer.ack(dataType: frame.dataType, seq: frame.seq, devType: frame.devType)) + } + + // Let the history pager settle/advance on this type's data frames. + if frame.cmdType == .send || frame.cmdType == .sendNoAck { + historySync.noteReceived(dataType: frame.dataType) + } + + return decoder.decode(frame) + } + + func makeSyncEngine() -> RingSyncEngine { + LuckRingSyncEngine(writer: writer, historySync: historySync) + } +} diff --git a/PulseLoop/RingProtocol/LuckRingEncoder.swift b/PulseLoop/RingProtocol/LuckRingEncoder.swift new file mode 100644 index 0000000..e997d19 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingEncoder.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Builds *logical* LuckRing frames — the connect bundle, the clock, history requests, the real-time +/// toggles, and the device actions. It owns the rolling `seq` counter (the head packet's `[3]`), so each +/// frame it hands out carries the next sequence number; `LuckRingPacketizer` stamps it into the wire +/// bytes. +/// +/// The connect bundle is **parameterized**, not a captured replay: it reproduces the exact property set +/// and order of `K6SendDataManager.sendAynInfoDetail()` — the method the vendor app runs on connect — +/// with every field built from the SDK's own struct byte layouts (`K6_SendUserInfo`, `K6_CESyncTime`, +/// `K6_SendGoal`, `K6_MixInfoStruct`). Wire spec: `tasks/luckring-protocol.md` §MixInfo. +struct LuckRingEncoder { + /// The rolling per-frame sequence. `queue/b.java` writes it to head `[3]` and the ring echoes it in + /// its ACK; only monotonic-ish uniqueness matters, so a plain wrapping `UInt8` is enough. + private var seq: UInt8 = 0 + + private mutating func nextSeq() -> UInt8 { + let value = seq + seq = seq &+ 1 + return value + } + + private mutating func frame(_ cmdType: LuckRingCmdType, _ dataType: UInt8, _ payload: [UInt8]) -> LuckRingFrame { + LuckRingFrame(cmdType: cmdType, dataType: dataType, payload: payload, seq: nextSeq()) + } + + // MARK: - Struct byte layouts (each matches its `K6_*` entity exactly) + + /// `K6_SendUserInfo.getBytes()` — 9 bytes: `[userId u32 LE][sex][age][height cm][weight kg][reserved]`. + /// + /// **Sex is inverted on the wire.** `sendAynInfoDetail()` sends `(appSex == 1) ? 0 : 1`, where the + /// app's `1` is male — so the ring's sex byte is `0 = male, 1 = otherwise`. `UserProfileValues.gender` + /// is `1 = male`, hence `gender == 1 ? 0 : 1`. Age floors at the vendor's default of 20 when unset. + static func userInfoBytes(_ profile: UserProfileValues, userId: UInt32 = 0) -> [UInt8] { + var bytes = LuckRingBytes.le32(userId) + bytes.append(profile.gender == 1 ? 0 : 1) + bytes.append(profile.age < 1 ? 20 : profile.age) + bytes.append(profile.heightCm) + bytes.append(profile.weightKg) + bytes.append(0) // reserved + return bytes + } + + /// `K6_CESyncTime.getBytes()` — 9 bytes: `[absSeconds u32 LE][utcOffsetSeconds u32 LE][formatByte]`. + /// + /// `absSeconds` is the **true UTC** Unix epoch (`TimeUtil.now()/1000`), not local wall-clock — so + /// unlike the jring/YCBT clocks, every ring-stamped record decodes with no offset to un-apply + /// (`TimeUtil.s2CForDev(sec, true) == sec*1000`). The format byte is `(timeDisplay ^ (dateDisplay<<1))`; + /// both displays default to 0, so it is 0. + static func timeBytes(date: Date = Date(), timeZone: TimeZone = .current) -> [UInt8] { + var bytes = LuckRingBytes.le32(UInt32(date.timeIntervalSince1970)) + let offset = UInt32(bitPattern: Int32(timeZone.secondsFromGMT(for: date))) + bytes.append(contentsOf: LuckRingBytes.le32(offset)) + bytes.append(0) // format byte (12/24h ^ date order << 1); both default 0 + return bytes + } + + /// `K6_SendGoal.getBytes()` — 16 bytes: `[step u32][distance u32][calories u32][sleep u16][duration u16]`, + /// all LE. PulseLoop only tracks a step goal; the other four are 0. + static func goalBytes(steps: Int) -> [UInt8] { + var bytes = LuckRingBytes.le32(UInt32(max(0, steps))) + bytes.append(contentsOf: LuckRingBytes.le32(0)) // distance + bytes.append(contentsOf: LuckRingBytes.le32(0)) // calories + bytes.append(contentsOf: LuckRingBytes.le16(0)) // sleep + bytes.append(contentsOf: LuckRingBytes.le16(0)) // duration + return bytes + } + + // MARK: - Connect bundle (MixInfo 110) + + /// The binding / startup bundle, in `sendAynInfoDetail()`'s exact property order: + /// 102 user info → 104 time → 124 call-alarm → 103 language → 109 data-switch → 111 goals → 120 pair. + /// + /// - `109 data-switch = 1` is what enables the ring's real-time pushes. + /// - `120 pair = {firstPair ? 1 : 0, 0}`: the leading 1 asks the ring to run its pairing animation and + /// is sent only on the very first bind; every later connect sends `{0,0}`, or the ring re-pairs. + /// - `124 call-alarm = {1, 0xFF, 0xFF, 0, 0}` is the vendor's literal constant. + mutating func startupBundle( + profile: UserProfileValues, + goalSteps: Int, + firstPair: Bool, + date: Date = Date(), + languageCode: UInt8 = 0 + ) -> LuckRingFrame { + let properties: [LuckRingMixInfoTLV.Property] = [ + .init(type: LuckRingDataType.userInfo, data: Self.userInfoBytes(profile)), + .init(type: LuckRingDataType.time, data: Self.timeBytes(date: date)), + .init(type: LuckRingDataType.callAlarm, data: [1, 0xFF, 0xFF, 0, 0]), + .init(type: LuckRingDataType.language, data: [languageCode]), + .init(type: LuckRingDataType.dataSwitch, data: [1]), + .init(type: LuckRingDataType.goals, data: Self.goalBytes(steps: goalSteps)), + .init(type: LuckRingDataType.pairFinish, data: [firstPair ? 1 : 0, 0]), + ] + return frame(.send, LuckRingDataType.mixInfo, LuckRingMixInfoTLV.encode(properties)) + } + + // MARK: - Standalone settings + + /// Push the ring clock on its own (`104`, the live timezone-change path). + mutating func setTime(date: Date = Date()) -> LuckRingFrame { + frame(.send, LuckRingDataType.time, Self.timeBytes(date: date)) + } + + /// Push the user's profile on its own (`102`). + mutating func userInfo(_ profile: UserProfileValues) -> LuckRingFrame { + frame(.send, LuckRingDataType.userInfo, Self.userInfoBytes(profile)) + } + + /// Set the step goal on its own (`111`). + mutating func setGoal(steps: Int) -> LuckRingFrame { + frame(.send, LuckRingDataType.goals, Self.goalBytes(steps: steps)) + } + + /// Enable/disable the real-time push data-switch (`109`). + mutating func dataSwitch(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.dataSwitch, [on ? 1 : 0]) + } + + /// Auto-monitoring config (`128`, `K6_DATA_TYPE_HEART_AUTO_SWITCH` — 8 bytes: + /// `[autoHR][hr24h][interval min][autoO2][0×4]`). This is what makes the ring log HR/SpO₂ history + /// **on its own**: the firmware default is *off* (`new K6_DATA_TYPE_HEART_AUTO_SWITCH(0, 0, 30)`), + /// so a ring that never visited the vendor app's monitoring screen records nothing between syncs + /// until this is sent. `hr24h` (continuous mode) stays 0, matching the vendor default. + mutating func autoMonitoring(_ settings: MeasurementSettings) -> LuckRingFrame { + frame(.send, LuckRingDataType.heartAutoSwitch, [ + settings.hrEnabled ? 1 : 0, + 0, + UInt8(clamping: settings.hrIntervalMinutes), + settings.spo2Enabled ? 1 : 0, + 0, 0, 0, 0, + ]) + } + + // MARK: - Requests (cmdType REQUEST, empty payload) + + /// Ask the ring for a data type: a bare `REQUEST` with no payload (`new CEDevData(3, dataType)`). + /// Used for device info (2), battery (3), settings-sync (9), and every history stream. + mutating func request(_ dataType: UInt8) -> LuckRingFrame { + frame(.request, dataType, []) + } + + // MARK: - Real-time toggles (each is its own `K6_DATA_TYPE_REAL_*` send) + + /// Real HR toggle (`24`, `K6_DATA_TYPE_REAL_HR` — 1 payload byte). The stream itself comes back on + /// dataType 7. + mutating func realHeartRate(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.realHR, [on ? 1 : 0]) + } + + /// Real SpO₂ toggle (`20`, `K6_DATA_TYPE_REAL_O2` — `[on,0,0,0,0]`). + mutating func realSpO2(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.realO2, [on ? 1 : 0, 0, 0, 0, 0]) + } + + /// Real HRV toggle (`45`, `K6_DATA_TYPE_REAL_HRV` — 1 payload byte). + mutating func realHRV(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.realHRV, [on ? 1 : 0]) + } + + /// Real blood-pressure toggle (`18`, `K6_DATA_TYPE_REAL_BP` — `[on,0,0,0,0,0]`). + mutating func realBloodPressure(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.realBP, [on ? 1 : 0, 0, 0, 0, 0, 0]) + } + + /// Real temperature toggle (`46`, `K6_DATA_TYPE_REAL_TEMP` — 1 payload byte). + mutating func realTemperature(on: Bool) -> LuckRingFrame { + frame(.send, LuckRingDataType.realTemp, [on ? 1 : 0]) + } + + // MARK: - Device actions + + /// Buzz the ring (`11`, `K6_DATA_TYPE_FIND_PHONE_OR_DEVICE` — 1 payload byte). + mutating func findDevice() -> LuckRingFrame { + frame(.send, LuckRingDataType.findDevice, [1]) + } + + /// Release the ring on Forget (`159`, `sendUnbind` — 1 payload byte). + mutating func unbind() -> LuckRingFrame { + frame(.send, LuckRingDataType.unbind, [1]) + } +} diff --git a/PulseLoop/RingProtocol/LuckRingHistorySync.swift b/PulseLoop/RingProtocol/LuckRingHistorySync.swift new file mode 100644 index 0000000..0ab5395 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingHistorySync.swift @@ -0,0 +1,156 @@ +import Foundation + +/// The LuckRing history pager. Unlike the YCBT transfer (whose terminal block ends each type), the K6 +/// history streams carry **no cursor and no end-of-transfer marker** — the ring just replays every stored +/// record of a type as one or more device-initiated data frames. So this is a simpler, *time-settled* +/// sequential pager: request a type, advance when its data frames stop arriving (a short settle window), +/// or skip it if none arrive at all (a stall timeout — an unsupported type answers with nothing). +/// +/// Replays are safe: persistence upserts history by `(kind, timestamp)`, activity by bucket timestamp, +/// and sleep by night, so re-requesting a type it already saw never double-counts. The destructive +/// `cleanData` (207) opcode is deliberately never sent. +@MainActor +final class LuckRingHistorySync { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// The full catalog, in request order. `mixSport` (10 — workout records) is skipped in v1. + static let catalog: [UInt8] = [ + LuckRingDataType.historySport, // 5 + LuckRingDataType.sleep, // 6 + LuckRingDataType.historyHeart, // 8 + LuckRingDataType.historyO2, // 40 + LuckRingDataType.historyBP, // 41 + LuckRingDataType.historyHRV, // 42 + LuckRingDataType.historyTemp, // 47 + LuckRingDataType.stressHistory, // 53 + ] + + /// The post-workout backfill subset — only the logs a session can have added to. + static let vitalsTypes: [UInt8] = [LuckRingDataType.historyHeart, LuckRingDataType.historyO2] + + private weak var writer: RingCommandWriter? + private let packetizer = LuckRingPacketizer() + /// Progress sink. `nil` publishes to the shared bus (the production path); tests inject a spy. + private let progressSink: ((PulseEvent) -> Void)? + + /// Re-armed on every data frame for the in-flight type; when it fires the type is "settled" and we + /// advance. Short, because the ring streams a type's frames back-to-back. + private let settleSeconds: TimeInterval + /// Fires when a type produces no data at all (unsupported / empty) — skip it. Longer than settle. + private let stallSeconds: TimeInterval + + private var queue: [UInt8] = [] + private var currentType: UInt8? + private var seq: UInt8 = 0 + private var settleTask: Task? + private var stallTask: Task? + + init( + writer: RingCommandWriter?, + settleSeconds: TimeInterval = 1.5, + stallSeconds: TimeInterval = 6, + progressSink: ((PulseEvent) -> Void)? = nil + ) { + self.writer = writer + self.settleSeconds = settleSeconds + self.stallSeconds = stallSeconds + self.progressSink = progressSink + } + + /// Surface a sync-progress event — to the injected spy in tests, else the shared bus. + private func publish(_ event: PulseEvent) { + if let progressSink { + progressSink(event) + } else { + Task { await PulseEventBus.shared.publish(event) } + } + } + + var isRunning: Bool { currentType != nil } + + /// Seed the queue and request the first type. A pass already in flight wins — a re-entrant `start` + /// would abandon the in-flight type mid-stream and land its frames in the wrong bucket. + func start(types: [UInt8]) { + guard !isRunning else { return } + queue = types + advance() + } + + /// Abandon any in-flight pass (disconnect / teardown). + func cancel() { + cancelTimers() + currentType = nil + queue.removeAll() + } + + /// Called by the driver for every completed device-initiated data frame. A frame for the in-flight + /// type re-arms the settle window; anything else is ignored. + func noteReceived(dataType: UInt8) { + guard let currentType, dataType == currentType else { return } + stallTask?.cancel(); stallTask = nil + armSettle() + } + + // MARK: - Driving the queue + + private func advance() { + cancelTimers() + guard !queue.isEmpty else { + currentType = nil + publish(.syncProgress(stage: "done")) + return + } + let type = queue.removeFirst() + currentType = type + publish(.syncProgress(stage: "Syncing \(Self.label(for: type))…")) + sendRequest(type) + armStall() + } + + private func sendRequest(_ dataType: UInt8) { + let frame = LuckRingFrame(cmdType: .request, dataType: dataType, payload: [], seq: seq) + seq &+= 1 + for packet in packetizer.packets(for: frame) { + writer?.enqueue(packet) + } + } + + private func armSettle() { + settleTask?.cancel() + settleTask = Task { [weak self] in + let nanos = UInt64((self?.settleSeconds ?? 1.5) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + guard !Task.isCancelled, let self else { return } + self.advance() + } + } + + private func armStall() { + stallTask?.cancel() + stallTask = Task { [weak self] in + let nanos = UInt64((self?.stallSeconds ?? 6) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + guard !Task.isCancelled, let self else { return } + self.advance() // no data ever arrived for this type — skip it + } + } + + private func cancelTimers() { + settleTask?.cancel(); settleTask = nil + stallTask?.cancel(); stallTask = nil + } + + private static func label(for dataType: UInt8) -> String { + switch dataType { + case LuckRingDataType.historySport: return "activity" + case LuckRingDataType.sleep: return "sleep" + case LuckRingDataType.historyHeart: return "heart rate" + case LuckRingDataType.historyO2: return "blood oxygen" + case LuckRingDataType.historyBP: return "blood pressure" + case LuckRingDataType.historyHRV: return "HRV" + case LuckRingDataType.historyTemp: return "temperature" + case LuckRingDataType.stressHistory: return "stress" + default: return "history" + } + } +} diff --git a/PulseLoop/RingProtocol/LuckRingProtocol.swift b/PulseLoop/RingProtocol/LuckRingProtocol.swift new file mode 100644 index 0000000..3138e46 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingProtocol.swift @@ -0,0 +1,327 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// LuckRing / TK18 ("K6 Protocol B") protocol primitives — GATT topology, the fixed 20-byte packet +/// framing, opcodes, the MixInfo TLV, and the little-endian byte helpers. This is the wire language the +/// whole 0xFF64 LuckRing family speaks (PID families 618/818/118/518/S2, sold under simsonlab and other +/// brands); what makes a *TK18* a TK18 is only its advertised identity + capability set, which live in +/// `LuckRingCoordinator`. +/// +/// Ground truth is the decompiled vendor SDK (`ce.com.cenewbluesdk`, internal family "K6"): +/// `CEBC.java` (opcodes), `queue/b.java` (framing + the app-ACK rule), `K6SendDataManager.java` +/// (`sendAynInfoDetail()` — the connect bundle), `entity/k6/K6_*.java` (struct byte layouts) and +/// `.../BlueProcessBean/ProcessDATA_TYPE_*.java` (record parsers). Written up in +/// `tasks/luckring-protocol.md`, which is the reference to read before touching any byte offset here. +/// +/// **Framing.** Every write/notify is a fixed **20-byte** packet. The head packet is +/// `[0]=0 [1]=devType [2]=#continuation-pages [3]=rolling-seq [4]=cmdType [5]=dataType` +/// `[6..7]=CRC16 (always 0x0000 — disabled in vendor code) [8..9]=payloadLen LE [10..19]=payload[0..9]` +/// and each continuation is `[0]=1-based page index [1..19]=next 19 payload bytes`. All integers are +/// little-endian. There is no crypto and no CRC to compute; binding is the MixInfo bundle (dataType 110). +enum LuckRingUUIDs { + /// Primary protocol service. + static let service = "0000f618-0000-1000-8000-00805f9b34fb" + /// Notify characteristic (CCCD) — the ring streams every reply / data frame here. + static let notify = "0000b001-0000-1000-8000-00805f9b34fb" + /// Write characteristic — the app writes every 20-byte packet here. + static let write = "0000b002-0000-1000-8000-00805f9b34fb" + + /// Standard BLE Heart Rate service. Present on the ring but **deliberately not subscribed** — mirror + /// the YCBT rationale: the proprietary `07` stream reflects real finger contact, the standard `180D` + /// characteristic can emit a stale cached value. Kept here only to document the GATT layout. + static let heartRateService = "180D" + + /// The head packet's `devType` byte. TK18 is a **618-family** unit (`CEBC.PID_TYPE.PID_618 == 1`); the + /// ring never validates an inbound head's devType (`queue/b.java` just stores `bArr[1]`), and every ACK + /// we send echoes the value the ring itself used, so this is only the seed for the first outbound + /// packets before any frame has been received. + static let deviceType: UInt8 = 1 +} + +/// The `cmdType` byte (`CEBC.K6.CMD_TYPE_*`). The app ACKs a device-initiated **SEND**; it never ACKs an +/// ACK or a SEND_NO_ACK (which by definition expects none), so ACKing only `.send` can neither stall the +/// ring nor spam the wire. +enum LuckRingCmdType: UInt8, Sendable { + case send = 1 + case sendNoAck = 2 + case request = 3 + case ack = 4 +} + +/// The `dataType` opcodes (`CEBC.K6.DATA_TYPE_*`). Only the ones PulseLoop drives or decodes are named; +/// the rest of the vendor table (alarms, watch faces, contacts, OTA, …) has no product surface here. +enum LuckRingDataType { + static let devInfo: UInt8 = 2 // 6B → firmware "customer.hardware.code.picture.font" + static let battery: UInt8 = 3 // [percent][charging] + static let realSport: UInt8 = 4 // live step buckets + static let historySport: UInt8 = 5 // stored step buckets + static let sleep: UInt8 = 6 // paged sleep timeline + static let realHeart: UInt8 = 7 // live HR (envelope + 5B records) + static let historyHeart: UInt8 = 8 // stored HR + static let devSync: UInt8 = 9 // settings sync — reply is a MixInfo TLV + static let mixSport: UInt8 = 10 // workout records (skipped in v1) + static let findDevice: UInt8 = 11 // buzz the ring + static let functionControl: UInt8 = 22 // capability bitmap (obfuscated — not mapped) + static let exerciseHeart: UInt8 = 17 // workout HR (envelope + 5B records) + static let realBP: UInt8 = 18 // live blood pressure + static let realO2: UInt8 = 20 // live SpO₂ + static let realHR: UInt8 = 24 // real-HR toggle (write side) + static let historyO2: UInt8 = 40 // stored SpO₂ + static let historyBP: UInt8 = 41 // stored blood pressure + static let historyHRV: UInt8 = 42 // stored HRV + static let realHRV: UInt8 = 45 // live HRV toggle + stream + static let realTemp: UInt8 = 46 // live temperature toggle + stream + static let historyTemp: UInt8 = 47 // stored temperature + static let stress: UInt8 = 52 // body-recovery / stress (live) + static let stressHistory: UInt8 = 53 // body-recovery / stress (stored) + static let userInfo: UInt8 = 102 // 9B profile + static let language: UInt8 = 103 + static let time: UInt8 = 104 // 9B clock + static let dataSwitch: UInt8 = 109 // 1 enables the real-time pushes + static let mixInfo: UInt8 = 110 // the binding / startup TLV bundle + static let goals: UInt8 = 111 // 16B goals + static let reset: UInt8 = 118 + static let pairFinish: UInt8 = 120 // ring → app: pairing complete + static let heartAutoSwitch: UInt8 = 128 // 8B auto-monitoring config (autoHR/interval/autoO2) + static let callAlarm: UInt8 = 124 + static let unbind: UInt8 = 159 +} + +/// Little-endian helpers. Every K6 integer is LE (`ByteUtil.int2bytes2` / `intToByte4` / `byte4ToInt`). +enum LuckRingBytes { + 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 u24(_ b: [UInt8], _ i: Int) -> Int { + guard b.count >= i + 3 else { return 0 } + return Int(b[i]) | (Int(b[i + 1]) << 8) | (Int(b[i + 2]) << 16) + } + + static func u32(_ b: [UInt8], _ i: Int) -> UInt32 { + guard b.count >= i + 4 else { return 0 } + return UInt32(b[i]) | (UInt32(b[i + 1]) << 8) | (UInt32(b[i + 2]) << 16) | (UInt32(b[i + 3]) << 24) + } + + static func le16(_ value: Int) -> [UInt8] { + [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)] + } + + static func le32(_ value: UInt32) -> [UInt8] { + [UInt8(value & 0xff), UInt8((value >> 8) & 0xff), UInt8((value >> 16) & 0xff), UInt8((value >> 24) & 0xff)] + } +} + +/// A *logical* K6 frame — one command or one reassembled data frame, independent of how many 20-byte +/// packets carry it. `cmdType`/`dataType`/`payload` are the meaning; `seq`/`devType` are the framing +/// identifiers an ACK must echo back (`queue/b.java` copies `bArr[3]`/`bArr[1]` into its reply). +struct LuckRingFrame: Equatable, Sendable { + var cmdType: LuckRingCmdType + var dataType: UInt8 + var payload: [UInt8] + var seq: UInt8 = 0 + var devType: UInt8 = LuckRingUUIDs.deviceType +} + +/// Splits a logical frame into exact 20-byte packets, and builds the mandatory app-ACK. +/// +/// Page math mirrors `queue/b.java`'s `a(length)`: the head carries the first 10 payload bytes, each +/// continuation the next 19, so `pages = ceil((len-10)/19)` (0 when `len ≤ 10`). Every packet is +/// zero-padded to 20 bytes; `[6..7]` is always `0x0000` because the vendor sends `figureCrc16()==0`. +struct LuckRingPacketizer { + /// The fixed BLE packet size (`queue/b.java`'s `h`). + static let packetSize = 20 + private static let headPayload = 10 + private static let continuationPayload = 19 + + /// The number of continuation pages a payload of `length` bytes needs. + static func continuationPages(payloadLength length: Int) -> Int { + guard length > headPayload else { return 0 } + let remainder = length - headPayload + return remainder / continuationPayload + (remainder % continuationPayload > 0 ? 1 : 0) + } + + func packets(for frame: LuckRingFrame) -> [Data] { + let payload = frame.payload + let pages = Self.continuationPages(payloadLength: payload.count) + + var head = [UInt8](repeating: 0, count: Self.packetSize) + head[0] = 0 + head[1] = frame.devType + head[2] = UInt8(min(pages, 255)) + head[3] = frame.seq + head[4] = frame.cmdType.rawValue + head[5] = frame.dataType + // [6..7] CRC16 disabled (0x0000); [8..9] payload length LE. + head[8] = UInt8(payload.count & 0xff) + head[9] = UInt8((payload.count >> 8) & 0xff) + for i in 0.. 0 { + for page in 1...pages { + var packet = [UInt8](repeating: 0, count: Self.packetSize) + packet[0] = UInt8(min(page, 255)) + let start = Self.headPayload + (page - 1) * Self.continuationPayload + for i in 0.. Data { + var packet = [UInt8](repeating: 0, count: Self.packetSize) + packet[1] = devType + packet[3] = seq + packet[4] = LuckRingCmdType.ack.rawValue + packet[5] = dataType + packet[8] = 1 // payload length = 1 + packet[10] = 1 // status 1 = accepted (CRC disabled ⇒ always accepted) + return Data(packet) + } +} + +/// Reassembles 20-byte notify packets into whole logical frames. +/// +/// A head packet (`[0]==0`) starts a frame and declares its payload length at `[8..9]` and its +/// continuation count at `[2]`; a device ACK head (`[4]==4`) is self-contained. Continuations +/// (`[0]!=0`) must arrive in strict 1-based order. A fresh head mid-assembly abandons the partial one +/// (`queue/b.java` overwrites its single buffer), and a continuation with no head is dropped — so one +/// truncated frame after a disconnect can't poison the next. +@MainActor +final class LuckRingFrameAssembler { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private struct Partial { + let cmdType: LuckRingCmdType + let dataType: UInt8 + let seq: UInt8 + let devType: UInt8 + let totalPages: Int + let declaredLength: Int + var payload: [UInt8] + var receivedPages: Int + } + + private var partial: Partial? + + /// Drop any half-assembled frame. A fresh driver is built per connection, so this is for reconnects + /// that reuse one (and for tests). + func reset() { partial = nil } + + /// Feed one 20-byte notify packet; returns the frame it completed, or nil while still assembling. + func append(_ data: Data) -> LuckRingFrame? { + let bytes = [UInt8](data) + guard bytes.count >= LuckRingPacketizer.packetSize else { return nil } + + if bytes[0] == 0 { + return handleHead(bytes) + } + return handleContinuation(bytes) + } + + private func handleHead(_ bytes: [UInt8]) -> LuckRingFrame? { + let devType = bytes[1] + let seq = bytes[3] + let rawCmd = bytes[4] & 0x0f + let dataType = bytes[5] + let cmdType = LuckRingCmdType(rawValue: rawCmd) ?? .send + + // A device ACK is a complete, single-packet frame — its status byte is at [10]. + if cmdType == .ack { + partial = nil + return LuckRingFrame(cmdType: .ack, dataType: dataType, payload: [bytes[10]], seq: seq, devType: devType) + } + + let declaredLen = LuckRingBytes.u16(bytes, 8) + let totalPages = Int(bytes[2]) + let firstChunk = Array(bytes[10.. LuckRingFrame? { + guard var current = partial else { return nil } // continuation with no head — drop it + let pageIndex = Int(bytes[0]) + guard pageIndex == current.receivedPages + 1 else { + // A jumped/duplicated page can't be recovered into this frame; abandon it. + partial = nil + return nil + } + current.payload.append(contentsOf: bytes[1..<20]) + current.receivedPages = pageIndex + partial = current + + guard current.receivedPages >= current.totalPages else { return nil } + partial = nil + // Trim to the head's declared length — the last continuation is zero-padded to 20 bytes, and the + // vendor codec sizes its buffer from the declared length. Without the cut, decoders that derive a + // record stride from the payload size (temperature's 5B/8B variants) would misparse. + return LuckRingFrame(cmdType: current.cmdType, dataType: current.dataType, + payload: Array(current.payload.prefix(current.declaredLength)), + seq: current.seq, devType: current.devType) + } +} + +/// The MixInfo TLV (`K6_MixInfoStruct`): the container the K6 protocol uses for its binding bundle +/// (dataType 110) and its settings-sync reply (dataType 9). Layout: +/// `[totalLen u16 LE][itemCount u8]` then, per property, `[propLen u16 LE = dataLen+3][propType u8][data]`. +/// `totalLen` is `(Σ propBytes) + 1` — the size of everything after the length field itself — and is +/// ignored on decode, which walks `itemCount` properties from offset 3. +enum LuckRingMixInfoTLV { + struct Property: Equatable { + let type: UInt8 + let data: [UInt8] + } + + static func encode(_ properties: [Property]) -> [UInt8] { + var propBytes: [UInt8] = [] + for property in properties { + let propLen = property.data.count + 3 + propBytes.append(contentsOf: LuckRingBytes.le16(propLen)) + propBytes.append(property.type) + propBytes.append(contentsOf: property.data) + } + var out = LuckRingBytes.le16(propBytes.count + 1) + out.append(UInt8(properties.count & 0xff)) + out.append(contentsOf: propBytes) + return out + } + + static func decode(_ bytes: [UInt8]) -> [Property] { + guard bytes.count >= 3 else { return [] } + let itemCount = Int(bytes[2]) + var properties: [Property] = [] + var i = 3 + for _ in 0..= 0, i + 3 + dataLen <= bytes.count else { break } + let type = bytes[i + 2] + let data = Array(bytes[(i + 3)..<(i + 3 + dataLen)]) + properties.append(Property(type: type, data: data)) + i += propLen + } + return properties + } +} diff --git a/PulseLoop/RingProtocol/LuckRingSyncEngine.swift b/PulseLoop/RingProtocol/LuckRingSyncEngine.swift new file mode 100644 index 0000000..68a5710 --- /dev/null +++ b/PulseLoop/RingProtocol/LuckRingSyncEngine.swift @@ -0,0 +1,129 @@ +import Foundation + +/// LuckRing sync engine. Connect is the MixInfo binding bundle followed by device-info / battery / +/// settings-sync requests and the history catalog pass; the ring's real-time streams are toggled by the +/// per-metric `K6_DATA_TYPE_REAL_*` sends. +/// +/// History is **not** driven from `handle(_:)`. The pager (`LuckRingHistorySync`, owned by the driver — the +/// only thing that sees frames) advances itself off the ring's data frames, so `handle` is a no-op. +/// +/// Every logical frame is split into 20-byte packets here (the driver's `frame(_:)` is identity) and each +/// packet is enqueued individually onto `RingBLEClient`'s serialized write queue. +@MainActor +final class LuckRingSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private var encoder = LuckRingEncoder() + private let packetizer = LuckRingPacketizer() + private let historySync: LuckRingHistorySync + + /// Pushed in by `RingSyncCoordinator` before `runStartup`, so the binding bundle carries the user's + /// real profile / goal. Defaults keep a freshly-paired ring sane until the store is read. + private var userProfile = UserProfileValues(metric: true, sex: nil, age: nil, heightCm: nil, weightKg: nil) + private var goalSteps = 10_000 + /// Auto-monitoring config pushed as opcode 128 on startup. Defaults to the jring-style 30-minute + /// cadence *enabled* — the K6 firmware default is monitoring **off**, which would leave every + /// history stream permanently empty on a ring the vendor app never configured. + private var measurementSettings = MeasurementSettings.jringDefault + + /// Whether this app has ever completed a LuckRing bind. The pair token's leading `1` triggers the + /// ring's pairing animation and must be sent only once, so it is latched in UserDefaults — see the + /// engine notes in `LuckRingEncoder.startupBundle`. (Not keyed per-peripheral: the engine has no + /// peripheral id, and one bind flag is the conservative behaviour for the common single-ring case.) + private static let pairFinishedKey = "luckring.pairFinished" + + init(writer: RingCommandWriter?, historySync: LuckRingHistorySync) { + self.writer = writer + self.historySync = historySync + } + + /// Split a logical frame into 20-byte packets and enqueue each (the driver's `frame(_:)` is identity). + private func send(_ frame: LuckRingFrame) { + for packet in packetizer.packets(for: frame) { + writer?.enqueue(packet) + } + } + + // MARK: Startup + + func runStartup() { + let firstPair = !UserDefaults.standard.bool(forKey: Self.pairFinishedKey) + send(encoder.startupBundle(profile: userProfile, goalSteps: goalSteps, firstPair: firstPair)) + UserDefaults.standard.set(true, forKey: Self.pairFinishedKey) + + send(encoder.autoMonitoring(measurementSettings)) + + send(encoder.request(LuckRingDataType.devInfo)) + send(encoder.request(LuckRingDataType.battery)) + send(encoder.request(LuckRingDataType.devSync)) + + historySync.start(types: LuckRingHistorySync.catalog) + } + + /// History is pager-driven — nothing here advances it. + func handle(_ event: RingDecodedEvent) {} + + // MARK: History passes (both re-enter `start`, a no-op while a pass is already in flight) + + func syncHistory() { + historySync.start(types: LuckRingHistorySync.catalog) + } + + func syncVitalsHistory() { + historySync.start(types: LuckRingHistorySync.vitalsTypes) + } + + // MARK: Live actions (per-metric `K6_DATA_TYPE_REAL_*` toggles) + + func startHeartRate() { send(encoder.realHeartRate(on: true)) } + func stopHeartRate() { send(encoder.realHeartRate(on: false)) } + func startSpO2() { send(encoder.realSpO2(on: true)) } + func stopSpO2() { send(encoder.realSpO2(on: false)) } + func startHRV() { send(encoder.realHRV(on: true)) } + func stopHRV() { send(encoder.realHRV(on: false)) } + func startBloodPressure() { send(encoder.realBloodPressure(on: true)) } + func stopBloodPressure() { send(encoder.realBloodPressure(on: false)) } + // `measureHeartRateSpot` falls back to `startHeartRate` (the default) — the ring has no separate + // manual-HR command; a spot reading is the first sample off the same live stream. + + func findDevice() { send(encoder.findDevice()) } + + func setGoal(steps: Int) { + goalSteps = steps + send(encoder.setGoal(steps: steps)) + } + + // MARK: Clock / battery / profile + + /// The ring stamps records from its own RTC, so a timezone or wall-clock change must be re-pushed. + func resyncTime() { send(encoder.setTime()) } + + /// Battery is in-band — request dataType 3. + func requestBattery() { send(encoder.request(LuckRingDataType.battery)) } + + func setUserProfile(_ profile: UserProfileValues) { userProfile = profile } + + func applyUserProfile(_ profile: UserProfileValues) { + userProfile = profile + send(encoder.userInfo(profile)) + } + + // MARK: Teardown + + /// Release the ring on Forget so it stops streaming and re-advertises for other apps. + func unbind() { send(encoder.unbind()) } + + // MARK: Measurement settings (opcode 128 — the ring's own background-logging switch) + + /// Seed before `runStartup` (the coordinator pushes the stored per-device config here first). + func setMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + } + + /// A live settings change from the UI — push it to the ring immediately. + func applyMeasurementSettings(_ settings: MeasurementSettings) { + measurementSettings = settings + send(encoder.autoMonitoring(settings)) + } +} diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index fbcc25a..51603a7 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -28,14 +28,19 @@ final class RingBLEClient: NSObject { /// Registry of supported wearables. First coordinator whose `matches` claims a peripheral wins. /// **Adding a wearable = append one entry here.** /// - /// The order is load-bearing at exactly one place: `ColmiSmartHealthCoordinator` must precede - /// `ColmiCoordinator`. Both recognize the same Colmi local names, and the QRing matcher needs - /// *only* the name — so behind it, no SmartHealth ring would ever be claimed. The SmartHealth - /// matcher is a conjunction a QRing ring cannot satisfy, so it is safe in front. + /// The order is load-bearing at exactly two places: + /// • `ColmiSmartHealthCoordinator` must precede `ColmiCoordinator`. Both recognize the same Colmi + /// local names, and the QRing matcher needs *only* the name — so behind it, no SmartHealth ring + /// would ever be claimed. The SmartHealth matcher is a conjunction a QRing ring cannot satisfy. + /// • `LuckRingCoordinator` must precede `TK5Coordinator`. LuckRing matches strong, family-exclusive + /// signals (the `F618` service, the `0xFF64` company ID) that no other coordinator claims; ordering + /// it ahead of TK5 is defensive, so TK5's weak `TK5`-name prefix could never shadow a hypothetical + /// `TK5x`-named LuckRing sibling. ("TK18" does not hit the `TK5` prefix, so today it is moot.) static let coordinators: [WearableCoordinator.Type] = [ JringCoordinator.self, ColmiSmartHealthCoordinator.self, ColmiCoordinator.self, + LuckRingCoordinator.self, TK5Coordinator.self, ] @@ -435,10 +440,32 @@ final class RingBLEClient: NSObject { let peripheral, let writeChar, !writeQueue.isEmpty else { return } - let item = writeQueue.removeFirst() // Big-data requests go to the command char (`de5bf72a`); fall back to the write char if the // device/firmware didn't expose a separate one. - let target = (item.useCommandChannel ? commandChar : writeChar) ?? writeChar + let target = (writeQueue[0].useCommandChannel ? commandChar : writeChar) ?? writeChar + + // The write type must come from the characteristic, not a constant. A characteristic that only + // supports write-without-response (the TK18's `B002`) silently *discards* a `.withResponse` + // write — CoreBluetooth never calls `didWriteValueFor`, so every packet used to sit out the + // full missed-ACK timeout and the device never received a byte. This mirrors Android's + // `writeCharacteristic`, which auto-selects the type from the properties. + let type: CBCharacteristicWriteType = + target.properties.contains(.write) ? .withResponse : .withoutResponse + + if type == .withoutResponse { + // No ATT round-trip to serialize on; pace with the peripheral's buffer instead. + // `peripheralIsReady(toSendWriteWithoutResponse:)` re-pumps when it drains. + guard peripheral.canSendWriteWithoutResponse else { return } + let item = writeQueue.removeFirst() + publishRawPacket(direction: .outgoing, data: item.data) + // Deliberately no `noteActivity()`: an unacknowledged write proves nothing about the link, + // and crediting it would blind the watchdog to a zombie connection during a silent sync. + peripheral.writeValue(item.data, for: target, type: .withoutResponse) + pumpWrites() + return + } + + let item = writeQueue.removeFirst() writeInFlight = true writeSeq &+= 1 let seq = writeSeq @@ -871,4 +898,12 @@ extension RingBLEClient: CBPeripheralDelegate { pumpWrites() } } + + /// The without-response buffer drained — resume the queue. (Writes gated on + /// `canSendWriteWithoutResponse` park here when the peripheral's buffer is full.) + nonisolated func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + MainActor.assumeIsolated { + pumpWrites() + } + } } diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index 6f1d69d..c4e19af 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -238,6 +238,7 @@ struct DeviceHeroCard: View { // line has no single representative image — fall back to the generic ring. case .colmiR02, .colmiSmartHealth: return nil case .tk5: return "tk5" + case .luckRing: return "luckring-tk18" case nil: return nil } } diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index 955fffc..c00eb80 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -12,6 +12,9 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { /// that driver, not `ColmiDriver`. Which of the two a given ring is cannot be read off its /// advertisement — the user declares it at pairing (see `RingAppVariant`). case colmiSmartHealth + /// LuckRing / TK18 family (the "K6" vendor SDK, company ID `0xFF64`). Sold under simsonlab and other + /// brands; TK18 is the hardware-tested unit. See `LuckRingCoordinator`. + case luckRing /// Human-facing default name when no advertised name is available. var displayName: String { @@ -20,6 +23,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .colmiR02: return "Colmi / Yawell ring" case .tk5: return "TK5 ring" case .colmiSmartHealth: return "Colmi ring (SmartHealth)" + case .luckRing: return "LuckRing" } } } diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index ea56991..5babdcd 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -53,7 +53,7 @@ enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { switch family { case .colmiR02: self = .qring case .colmiSmartHealth: self = .smartHealth - case .jring, .tk5: return nil + case .jring, .tk5, .luckRing: return nil } } @@ -108,11 +108,12 @@ 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 - // Both YCBT families are unproven on hardware. The SmartHealth-Colmi has never been connected - // at all — its advertisement, its capability bitmap and its history types are all still - // predictions (plan B6 is what settles them). - case .tk5, .colmiSmartHealth: return .limited + case .jring, .colmiR02, .colmiSmartHealth: return .full + // The TK5 is unproven on hardware — its advertisement, capability bitmap and history types + // are still predictions. + case .tk5: return .limited + // TK18 is the only hardware-tested LuckRing; every 0xFF64 sibling is still a prediction. + case .luckRing: return .limited } } } @@ -173,6 +174,15 @@ extension WearableModel { advertisedNamePatterns: ["^TK5 ?[0-9A-Fa-f]{0,4}$"], imageName: "tk5" ) + // TK18 — the LuckRing app / "K6" protocol (company ID 0xFF64). The only hardware-tested unit of the + // whole 0xFF64 family, so it is `.limited`. Its baseline is what the driver can decode; untested + // siblings still pair via the coordinator's strong-signal match and get generic art + a fallback name. + static let luckRingTK18 = WearableModel( + id: "luckring-tk18", displayName: "TK18", brand: "LuckRing", family: .luckRing, + tint: PulseColors.accent, blurb: "HR · SpO₂ · HRV · Temp · BP · Sleep · Steps", + advertisedNamePatterns: ["^TK18([ _-].*)?$"], imageName: "luckring-tk18" + ) + // 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}$") @@ -301,6 +311,7 @@ extension WearableModel { colmiR99, yawellR05, yawellR10, yawellR11, h59, tk5, + luckRingTK18, ] static func model(id: String?) -> WearableModel? { diff --git a/PulseLoopTests/CapabilityGatingTests.swift b/PulseLoopTests/CapabilityGatingTests.swift index 34ff089..a837830 100644 --- a/PulseLoopTests/CapabilityGatingTests.swift +++ b/PulseLoopTests/CapabilityGatingTests.swift @@ -21,6 +21,37 @@ final class CapabilityGatingTests: XCTestCase { XCTAssertFalse(MetricsService.supports(.temperature, context: context)) } + /// The LuckRing / TK18 baseline surfaces its metric cards — but deliberately not blood sugar, REM + /// staging, fatigue, or a combined-vitals sweep (the K6 protocol has no command or record for any + /// of them). + func testLuckRingSurfacesItsBaselineButNotTheExcludedMetrics() throws { + let context = try TestSupport.makeContext() + let ring = Device(deviceType: .luckRing, capabilities: LuckRingCoordinator().capabilities) + context.insert(ring) + try context.save() + + for metric: MetricKey in [.heartRate, .spo2, .hrv, .temperature, .bloodPressureSystolic, .stress] { + XCTAssertTrue(MetricsService.supports(metric, context: context), metric.rawValue) + } + // Blood sugar and fatigue are the two vital *metrics* the family deliberately does not claim. + for metric: MetricKey in [.bloodSugar, .fatigue] { + XCTAssertFalse(MetricsService.supports(metric, context: context), metric.rawValue) + } + } + + /// The declared sets, straight from the coordinator: nothing excluded leaks in, and the family gates + /// nothing on a bitmap (the K6 FUNCTION_CONTROL bitmap is obfuscated in the decompile). + /// `.measurementInterval` *is* declared — the K6 auto-monitoring config (opcode 128) is a real + /// interval knob, and the firmware default is off. + func testLuckRingCoordinatorDeclaresTheApprovedSet() { + let coordinator = LuckRingCoordinator() + XCTAssertTrue(coordinator.bitmapGatedCapabilities.isEmpty) + XCTAssertTrue(coordinator.capabilities.contains(.measurementInterval)) + for cap: WearableCapability in [.bloodSugar, .remSleep, .fatigue, .combinedVitalsMeasurement, .powerOff, .factoryReset] { + XCTAssertFalse(coordinator.capabilities.contains(cap), cap.rawValue) + } + } + func testColmiShowsRichMetrics() throws { let context = try TestSupport.makeContext() let colmi = Device( diff --git a/PulseLoopTests/LuckRingDecoderTests.swift b/PulseLoopTests/LuckRingDecoderTests.swift new file mode 100644 index 0000000..b9a5a05 --- /dev/null +++ b/PulseLoopTests/LuckRingDecoderTests.swift @@ -0,0 +1,207 @@ +import XCTest +@testable import PulseLoop + +/// The decoder's record cutting, per `ProcessDATA_TYPE_*`: battery, firmware, the metric history fan-outs, +/// the sport u24 fields, the paged sleep timeline (sessions → per-minute stages), the empty-envelope +/// "ended" markers, and the /10 temperature scaling. Range gating is `RingEventBridge`'s job, so these +/// assert the raw decode. +@MainActor +final class LuckRingDecoderTests: XCTestCase { + private let decoder = LuckRingDecoder() + private let base: UInt32 = 1_700_000_000 + private var baseDate: Date { Date(timeIntervalSince1970: TimeInterval(base)) } + + /// Concatenate byte chunks without `+` chains — CI's older Swift compiler times out + /// type-checking heterogeneous `[UInt8] + [literal]` expressions ("unable to type-check + /// this expression in reasonable time"). + private func cat(_ parts: [UInt8]...) -> [UInt8] { + parts.flatMap { $0 } + } + + // Envelope: [total u16 LE][items u8] + records. + private func envelope(items: Int, records: [[UInt8]]) -> [UInt8] { + cat(LuckRingBytes.le16(items), [UInt8(items)], records.flatMap { $0 }) + } + + private func frame(_ dataType: UInt8, _ payload: [UInt8], cmd: LuckRingCmdType = .send) -> LuckRingFrame { + LuckRingFrame(cmdType: cmd, dataType: dataType, payload: payload) + } + + private func le24(_ v: Int) -> [UInt8] { + [UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff)] + } + + // MARK: Simple frames + + func testBattery() { + guard case let .battery(percent) = decoder.decode(frame(3, [85, 1])).first else { + return XCTFail("expected battery") + } + XCTAssertEqual(percent, 85) + } + + func testFirmwareStringJoinsBytesOneToFive() { + // [items, customer, hardware, code, picture, font] → "customer.hardware.code.picture.font". + guard case let .firmware(version) = decoder.decode(frame(2, [6, 1, 2, 3, 4, 5])).first else { + return XCTFail("expected firmware") + } + XCTAssertEqual(version, "1.2.3.4.5") + } + + func testDeviceAckDecodesToCommandAck() { + guard case let .commandAck(id) = decoder.decode(frame(111, [1], cmd: .ack)).first else { + return XCTFail("expected commandAck") + } + XCTAssertEqual(id, 111) + } + + // MARK: History fan-outs + + func testHeartRateHistoryFansOutPerRecord() { + let payload = envelope(items: 2, records: [ + cat(LuckRingBytes.le32(base), [72]), + cat(LuckRingBytes.le32(base + 60), [75]), + ]) + let events = decoder.decode(frame(8, payload)) + let readings: [(Double, Date)] = events.compactMap { + if case let .historyMeasurement(kind, value, ts) = $0, kind == .heartRate { return (value, ts) } + return nil + } + XCTAssertEqual(readings.map(\.0), [72, 75]) + XCTAssertEqual(readings.first?.1, baseDate) + } + + func testSpo2HistoryDecodes() { + let payload = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), [97])]) + guard case let .historyMeasurement(kind, value, _) = decoder.decode(frame(40, payload)).first else { + return XCTFail("expected spo2 history") + } + XCTAssertEqual(kind, .spo2) + XCTAssertEqual(value, 97) + } + + func testBloodPressureHistoryFansOutSystolicAndDiastolic() { + let payload = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), [120, 80])]) + let events = decoder.decode(frame(41, payload)) + let kinds = events.compactMap { evt -> (MeasurementKind, Double)? in + if case let .historyMeasurement(kind, value, _) = evt { return (kind, value) } + return nil + } + XCTAssertEqual(kinds.count, 2) + XCTAssertTrue(kinds.contains { $0 == (.bloodPressureSystolic, 120) }) + XCTAssertTrue(kinds.contains { $0 == (.bloodPressureDiastolic, 80) }) + } + + func testHrvHistoryDecodes() { + let payload = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), [45])]) + guard case let .historyMeasurement(kind, value, _) = decoder.decode(frame(42, payload)).first else { + return XCTFail("expected hrv history") + } + XCTAssertEqual(kind, .hrv) + XCTAssertEqual(value, 45) + } + + func testTemperatureScalesByTenFromTheWideRecord() { + // 8-byte record (`parseFloat`): [time u32][value u16 LE]/10 (+2 pad). 365 → 36.5 °C. + let payload = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), LuckRingBytes.le16(365), [0, 0])]) + guard case let .historyMeasurement(kind, value, _) = decoder.decode(frame(47, payload)).first else { + return XCTFail("expected temperature history") + } + XCTAssertEqual(kind, .temperature) + XCTAssertEqual(value, 36.5, accuracy: 0.001) + } + + // MARK: Live streams + + func testLiveHeartRateSamplesAndEndedMarker() { + let stream = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), [66])]) + guard case let .heartRateSample(bpm, _) = decoder.decode(frame(7, stream)).first else { + return XCTFail("expected live HR sample") + } + XCTAssertEqual(bpm, 66) + + // Empty envelope (items 0) = the measurement ended. + let ended = decoder.decode(frame(7, envelope(items: 0, records: []))) + guard case .heartRateComplete = ended.first else { + return XCTFail("expected heartRateComplete for an empty envelope, got \(ended)") + } + } + + func testLiveBloodPressureDecodes() { + let payload = envelope(items: 1, records: [cat(LuckRingBytes.le32(base), [118, 76])]) + guard case let .bloodPressureSample(sys, dia, _) = decoder.decode(frame(18, payload)).first else { + return XCTFail("expected live BP") + } + XCTAssertEqual(sys, 118) + XCTAssertEqual(dia, 76) + } + + // MARK: Sport + + func testSportRecordDecodesU24Fields() { + // [start u32][steps u32][distance u24+pad][calories u24+pad][duration u24+pad]. + let record = cat(LuckRingBytes.le32(base), LuckRingBytes.le32(1234), + le24(5000), [0], le24(300), [0], le24(600), [0]) + let events = decoder.decode(frame(5, envelope(items: 1, records: [record]))) + guard case let .activityBucket(ts, steps, distance) = events.first else { + return XCTFail("expected activityBucket, got \(events)") + } + XCTAssertEqual(ts, baseDate) + XCTAssertEqual(steps, 1234) + XCTAssertEqual(distance, 5000) + } + + // MARK: Sleep + + /// One session: start → 5 min light, deep → 10 min deep, wake ends it. The 15-slot page is padded. + func testSleepSingleSessionExpandsToPerMinuteStages() { + let entries: [(UInt8, UInt32)] = [ + (1, base), // session start + (2, base + 300), // deep, 5 min after start + (4, base + 900), // wake, 10 min after the deep entry + ] + let events = decoder.decode(frame(6, sleepPayload(pages: [(3, entries)]))) + guard case let .sleepTimeline(ts, stages) = events.first else { + return XCTFail("expected sleepTimeline, got \(events)") + } + XCTAssertEqual(ts, baseDate) + XCTAssertEqual(stages.prefix(5), ArraySlice([.light, .light, .light, .light, .light])) + XCTAssertEqual(stages.count, 15) + XCTAssertEqual(stages.suffix(10), ArraySlice(Array(repeating: SleepStage.deep, count: 10))) + } + + /// Type 5 (movement) maps to light; two sessions separated by a wake both surface. + func testSleepMultiSessionAndMovementMapping() { + let first: [(UInt8, UInt32)] = [ + (1, base), (5, base + 120), (4, base + 300), + ] + let second: [(UInt8, UInt32)] = [ + (1, base + 3600), (2, base + 3720), (4, base + 3900), + ] + let events = decoder.decode(frame(6, sleepPayload(pages: [(3, first), (3, second)]))) + let sessions = events.compactMap { evt -> (Date, [SleepStage])? in + if case let .sleepTimeline(ts, stages) = evt { return (ts, stages) } + return nil + } + XCTAssertEqual(sessions.count, 2) + XCTAssertTrue(sessions[0].1.allSatisfy { $0 == .light }, "start + movement both render as light") + XCTAssertEqual(sessions[1].0, Date(timeIntervalSince1970: TimeInterval(base + 3600))) + } + + // Build a sleep payload: [total u16][pageCount], then each page = [validCount] + 15 × [type, time u32]. + private func sleepPayload(pages: [(valid: Int, entries: [(UInt8, UInt32)])]) -> [UInt8] { + var out = cat(LuckRingBytes.le16(0), [UInt8(pages.count)]) + for page in pages { + out.append(UInt8(page.valid)) + for slot in 0..<15 { + if slot < page.entries.count { + out.append(page.entries[slot].0) + out.append(contentsOf: LuckRingBytes.le32(page.entries[slot].1)) + } else { + out.append(contentsOf: [0, 0, 0, 0, 0]) + } + } + } + return out + } +} diff --git a/PulseLoopTests/LuckRingDriverTests.swift b/PulseLoopTests/LuckRingDriverTests.swift new file mode 100644 index 0000000..88379b3 --- /dev/null +++ b/PulseLoopTests/LuckRingDriverTests.swift @@ -0,0 +1,113 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// The driver's inbound routing, and the one thing no decoder can do: **acknowledging a device-initiated +/// SEND**. The ring retransmits an un-ACKed SEND until the app answers, so a missing ACK wedges the link; +/// an ACK for an ACK, or for a SEND_NO_ACK, is wrong the other way. +@MainActor +final class LuckRingDriverTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + private let notify = CBUUID(string: LuckRingUUIDs.notify) + private let packetizer = LuckRingPacketizer() + + private func packets(_ frame: LuckRingFrame) -> [Data] { + packetizer.packets(for: frame) + } + + /// Concatenate byte chunks without `+` chains — CI's older Swift compiler times out + /// type-checking heterogeneous `[UInt8] + [literal]` expressions. + private func cat(_ parts: [UInt8]...) -> [UInt8] { + parts.flatMap { $0 } + } + + // MARK: Auto-ACK + + func testDeviceSendIsAckedAndDecoded() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + let frame = LuckRingFrame(cmdType: .send, dataType: LuckRingDataType.battery, payload: [90, 1], seq: 5, devType: 2) + let events = driver.ingest(packets(frame)[0], from: notify) + + XCTAssertEqual(writer.sent.count, 1, "a device SEND must be ACKed") + let ack = [UInt8](writer.sent[0]) + XCTAssertEqual(ack[4], 4, "ACK cmdType") + XCTAssertEqual(ack[5], LuckRingDataType.battery, "ACK echoes the frame's dataType") + XCTAssertEqual(ack[3], 5, "ACK echoes the frame's seq") + XCTAssertEqual(ack[1], 2, "ACK echoes the frame's devType") + + guard case let .battery(percent) = events.first else { return XCTFail("expected battery, got \(events)") } + XCTAssertEqual(percent, 90) + } + + func testDeviceAckIsNotAcked() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + var ack = [UInt8](repeating: 0, count: 20) + ack[1] = 1; ack[4] = 4; ack[5] = LuckRingDataType.mixInfo; ack[8] = 1; ack[10] = 1 + _ = driver.ingest(Data(ack), from: notify) + + XCTAssertTrue(writer.sent.isEmpty, "an ACK must never be ACKed") + } + + func testSendNoAckIsNotAcked() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + let frame = LuckRingFrame(cmdType: .sendNoAck, dataType: LuckRingDataType.realHeart, + payload: cat(LuckRingBytes.le16(1), [1], LuckRingBytes.le32(1_700_000_000), [70]), + seq: 0, devType: 1) + _ = driver.ingest(packets(frame)[0], from: notify) + + XCTAssertTrue(writer.sent.isEmpty, "a SEND_NO_ACK expects no reply") + } + + // MARK: Reassembly + + func testMultiPacketFrameOnlyAcksAndDecodesOnceComplete() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + // A 2-record HR history frame that spans a head + one continuation. + let payload = cat(LuckRingBytes.le16(2), [2], + LuckRingBytes.le32(1_700_000_000), [72], + LuckRingBytes.le32(1_700_000_060), [75]) + let frame = LuckRingFrame(cmdType: .send, dataType: LuckRingDataType.historyHeart, payload: payload, seq: 1, devType: 1) + let wire = packets(frame) + XCTAssertGreaterThan(wire.count, 1) + + let firstEvents = driver.ingest(wire[0], from: notify) + XCTAssertTrue(firstEvents.isEmpty, "no decode until the frame is whole") + XCTAssertTrue(writer.sent.isEmpty, "no ACK until the frame is whole") + + let lastEvents = driver.ingest(wire[1], from: notify) + XCTAssertEqual(writer.sent.count, 1, "ACK once, on completion") + XCTAssertEqual(lastEvents.count, 2, "both HR records decode") + } + + // MARK: Lifecycle + + func testConnectionResetDiscardsAPartialFrame() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + let payload = cat(LuckRingBytes.le16(2), [2], + LuckRingBytes.le32(1_700_000_000), [72], + LuckRingBytes.le32(1_700_000_060), [75]) + let wire = packets(LuckRingFrame(cmdType: .send, dataType: LuckRingDataType.historyHeart, payload: payload, seq: 1, devType: 1)) + + _ = driver.ingest(wire[0], from: notify) // head only + driver.connectionDidStart() // the link came back — discard the partial + let events = driver.ingest(wire[1], from: notify) // a continuation with no head now + + XCTAssertTrue(events.isEmpty, "the reset dropped the partial, so the stray continuation is ignored") + XCTAssertTrue(writer.sent.isEmpty) + } +} diff --git a/PulseLoopTests/LuckRingEncoderTests.swift b/PulseLoopTests/LuckRingEncoderTests.swift new file mode 100644 index 0000000..c701aea --- /dev/null +++ b/PulseLoopTests/LuckRingEncoderTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import PulseLoop + +/// The encoder's byte layouts, each pinned against its `K6_*` vendor struct: the MixInfo binding bundle +/// (property set + order + framing), the clock, the real-time toggles, and the requests. A wrong offset +/// here is a command the ring silently ignores, so the golden assertions are the contract. +@MainActor +final class LuckRingEncoderTests: XCTestCase { + private let fixedProfile = UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 175, weightKg: 70) + private let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: Struct layouts + + func testUserInfoBytesMatchK6SendUserInfo() { + // [userId u32 LE][sex][age][height][weight][reserved]; sex is inverted (male → 0). + let bytes = LuckRingEncoder.userInfoBytes(fixedProfile) + XCTAssertEqual(bytes, [0, 0, 0, 0, 0, 30, 175, 70, 0]) + } + + func testUserInfoSexInversionAndAgeFloor() { + let female = UserProfileValues(metric: true, sex: "female", age: 0, heightCm: 160, weightKg: 55) + let bytes = LuckRingEncoder.userInfoBytes(female) + XCTAssertEqual(bytes[4], 1, "female → sex byte 1") + XCTAssertEqual(bytes[5], 20, "age 0 floors to the vendor default of 20") + } + + func testTimeBytesAreTrueUtcSeconds() { + let bytes = LuckRingEncoder.timeBytes(date: fixedDate, timeZone: TimeZone(identifier: "UTC")!) + XCTAssertEqual(bytes.count, 9) + XCTAssertEqual(LuckRingBytes.u32(bytes, 0), 1_700_000_000) // abs seconds (UTC, no wall-clock shift) + XCTAssertEqual(LuckRingBytes.u32(bytes, 4), 0) // UTC offset + XCTAssertEqual(bytes[8], 0) // format byte + } + + func testGoalBytesMatchK6SendGoal() { + let bytes = LuckRingEncoder.goalBytes(steps: 8000) + XCTAssertEqual(bytes.count, 16) + XCTAssertEqual(LuckRingBytes.u32(bytes, 0), 8000) // step goal, LE + XCTAssertEqual(Array(bytes[4...]), [UInt8](repeating: 0, count: 12)) // distance/cal/sleep/duration = 0 + } + + // MARK: MixInfo bundle + + func testStartupBundleHasVendorPropertyOrderAndData() { + var encoder = LuckRingEncoder() + let frame = encoder.startupBundle(profile: fixedProfile, goalSteps: 8000, firstPair: true, date: fixedDate) + XCTAssertEqual(frame.cmdType, .send) + XCTAssertEqual(frame.dataType, 110) + + let props = LuckRingMixInfoTLV.decode(frame.payload) + // Exact order of `sendAynInfoDetail()`: 102, 104, 124, 103, 109, 111, 120. + XCTAssertEqual(props.map(\.type), [102, 104, 124, 103, 109, 111, 120]) + XCTAssertEqual(props[0].data, LuckRingEncoder.userInfoBytes(fixedProfile)) + XCTAssertEqual(props[1].data.count, 9) // time + XCTAssertEqual(props[2].data, [1, 0xFF, 0xFF, 0, 0]) // call-alarm constant + XCTAssertEqual(props[3].data, [0]) // language + XCTAssertEqual(props[4].data, [1]) // data-switch enables real-time pushes + XCTAssertEqual(props[5].data, LuckRingEncoder.goalBytes(steps: 8000)) + XCTAssertEqual(props[6].data, [1, 0]) // first-pair token + } + + func testStartupBundleClearsPairTokenAfterFirstBind() { + var encoder = LuckRingEncoder() + let frame = encoder.startupBundle(profile: fixedProfile, goalSteps: 0, firstPair: false, date: fixedDate) + let props = LuckRingMixInfoTLV.decode(frame.payload) + XCTAssertEqual(props.last?.data, [0, 0], "a non-first bind must not re-trigger the pairing animation") + } + + // MARK: Toggles / requests + + func testRealTimeToggleLayouts() { + var encoder = LuckRingEncoder() + assertFrame(encoder.realHeartRate(on: true), .send, 24, [1]) + assertFrame(encoder.realSpO2(on: true), .send, 20, [1, 0, 0, 0, 0]) + assertFrame(encoder.realHRV(on: true), .send, 45, [1]) + assertFrame(encoder.realBloodPressure(on: true), .send, 18, [1, 0, 0, 0, 0, 0]) + assertFrame(encoder.realTemperature(on: true), .send, 46, [1]) + assertFrame(encoder.realHeartRate(on: false), .send, 24, [0]) + } + + func testRequestIsAnEmptyRequestFrame() { + var encoder = LuckRingEncoder() + assertFrame(encoder.request(LuckRingDataType.battery), .request, 3, []) + } + + /// Opcode 128 (`K6_DATA_TYPE_HEART_AUTO_SWITCH`): `[autoHR][hr24h=0][interval min][autoO2][0×4]`. + /// The firmware default is monitoring *off*, so this frame is what arms background history logging. + func testAutoMonitoringMatchesK6HeartAutoSwitch() { + var encoder = LuckRingEncoder() + let settings = MeasurementSettings( + hrEnabled: true, hrIntervalMinutes: 30, + spo2Enabled: true, stressEnabled: false, hrvEnabled: false, temperatureEnabled: false + ) + assertFrame(encoder.autoMonitoring(settings), .send, 128, [1, 0, 30, 1, 0, 0, 0, 0]) + let off = MeasurementSettings( + hrEnabled: false, hrIntervalMinutes: 5, + spo2Enabled: false, stressEnabled: false, hrvEnabled: false, temperatureEnabled: false + ) + assertFrame(encoder.autoMonitoring(off), .send, 128, [0, 0, 5, 0, 0, 0, 0, 0]) + } + + func testFindDeviceAndUnbind() { + var encoder = LuckRingEncoder() + assertFrame(encoder.findDevice(), .send, 11, [1]) + assertFrame(encoder.unbind(), .send, 159, [1]) + } + + func testSequenceCounterIncrementsPerFrame() { + var encoder = LuckRingEncoder() + XCTAssertEqual(encoder.realHeartRate(on: true).seq, 0) + XCTAssertEqual(encoder.realHeartRate(on: false).seq, 1) + XCTAssertEqual(encoder.request(LuckRingDataType.battery).seq, 2) + } + + private func assertFrame( + _ frame: LuckRingFrame, _ cmd: LuckRingCmdType, _ dataType: UInt8, _ payload: [UInt8], + line: UInt = #line + ) { + XCTAssertEqual(frame.cmdType, cmd, line: line) + XCTAssertEqual(frame.dataType, dataType, line: line) + XCTAssertEqual(frame.payload, payload, line: line) + } +} diff --git a/PulseLoopTests/LuckRingHistorySyncTests.swift b/PulseLoopTests/LuckRingHistorySyncTests.swift new file mode 100644 index 0000000..1c9e184 --- /dev/null +++ b/PulseLoopTests/LuckRingHistorySyncTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import PulseLoop + +/// The history pager: it walks the catalog one type at a time, advancing when a type's data settles or +/// timing it out when nothing comes, refuses a re-entrant `start`, and signals completion. The K6 streams +/// carry no terminal marker, so this time-settled sequencing is the whole of the transfer contract. +@MainActor +final class LuckRingHistorySyncTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + /// The dataType of each request packet written (head byte [5]); REQUEST frames only. + var requestedTypes: [UInt8] { sent.map { [UInt8]($0)[5] } } + } + + private final class Spy { + var stages: [String] = [] + var didFinish: Bool { stages.contains("done") } + } + + private func makeSync(writer: FakeWriter, spy: Spy, settle: TimeInterval, stall: TimeInterval) -> LuckRingHistorySync { + LuckRingHistorySync(writer: writer, settleSeconds: settle, stallSeconds: stall, progressSink: { event in + if case let .syncProgress(stage) = event { spy.stages.append(stage) } + }) + } + + private func sleep(_ seconds: TimeInterval) async { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + func testSequentialAdvanceOnDataSettle() async { + let writer = FakeWriter() + let spy = Spy() + let sync = makeSync(writer: writer, spy: spy, settle: 0.05, stall: 5) + + sync.start(types: [5, 6]) + XCTAssertEqual(writer.requestedTypes, [5], "the first type is requested immediately") + + sync.noteReceived(dataType: 5) + await sleep(0.2) + XCTAssertEqual(writer.requestedTypes, [5, 6], "the pass advanced once type 5's data settled") + + sync.noteReceived(dataType: 6) + await sleep(0.2) + XCTAssertFalse(sync.isRunning, "the queue drained") + XCTAssertTrue(spy.didFinish, "completion is signalled") + } + + func testUnsupportedTypeIsSkippedOnStall() async { + let writer = FakeWriter() + let spy = Spy() + let sync = makeSync(writer: writer, spy: spy, settle: 5, stall: 0.05) + + sync.start(types: [42]) // no data will ever arrive + XCTAssertEqual(writer.requestedTypes, [42]) + + await sleep(0.2) + XCTAssertFalse(sync.isRunning, "a type that never answers is skipped on the stall timeout") + XCTAssertTrue(spy.didFinish) + } + + func testReEntrantStartIsIgnoredWhileRunning() async { + let writer = FakeWriter() + let spy = Spy() + let sync = makeSync(writer: writer, spy: spy, settle: 5, stall: 5) + + sync.start(types: [5]) + sync.start(types: [6]) // must not interrupt the in-flight pass + XCTAssertEqual(writer.requestedTypes, [5]) + sync.cancel() // stop the in-flight timer… + await sleep(0.05) // …and let the cancelled Task retire before the instance is torn down + } + + func testCancelStopsThePass() async { + let writer = FakeWriter() + let spy = Spy() + let sync = makeSync(writer: writer, spy: spy, settle: 0.05, stall: 0.05) + + sync.start(types: [5, 6, 8]) + sync.cancel() + await sleep(0.2) + XCTAssertFalse(sync.isRunning) + XCTAssertEqual(writer.requestedTypes, [5], "cancel halts before any further type is requested") + } + + func testCatalogAndVitalsSubsetsAreWhatWeExpect() { + XCTAssertEqual(LuckRingHistorySync.catalog, [5, 6, 8, 40, 41, 42, 47, 53]) + XCTAssertEqual(LuckRingHistorySync.vitalsTypes, [8, 40]) + } +} diff --git a/PulseLoopTests/LuckRingProtocolTests.swift b/PulseLoopTests/LuckRingProtocolTests.swift new file mode 100644 index 0000000..d9ad411 --- /dev/null +++ b/PulseLoopTests/LuckRingProtocolTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import PulseLoop + +/// The K6 "Protocol B" framing: the 20-byte packetizer (head / continuation / ACK), the assembler's +/// round-trip and its recovery from a stale head, and the MixInfo TLV. These are the bytes the ring sees +/// and the bytes it sends, so a golden-byte pin here is the whole contract. +@MainActor +final class LuckRingProtocolTests: XCTestCase { + private let packetizer = LuckRingPacketizer() + + // MARK: Packetizer + + func testHeadPacketGoldenBytes() { + // REQUEST devInfo (cmd 3, dataType 2, empty payload), seq 0, devType 1. + let frame = LuckRingFrame(cmdType: .request, dataType: 2, payload: [], seq: 0, devType: 1) + let packets = packetizer.packets(for: frame) + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].count, 20) + XCTAssertEqual(packets[0].hexString, "0001000003020000000000000000000000000000") + } + + func testSinglePacketCarriesFirstTenPayloadBytes() { + let frame = LuckRingFrame(cmdType: .send, dataType: 24, payload: [1], seq: 7, devType: 1) + let packets = packetizer.packets(for: frame) + XCTAssertEqual(packets.count, 1) + let bytes = [UInt8](packets[0]) + XCTAssertEqual(bytes[0], 0) // head marker + XCTAssertEqual(bytes[1], 1) // devType + XCTAssertEqual(bytes[2], 0) // continuation pages + XCTAssertEqual(bytes[3], 7) // seq + XCTAssertEqual(bytes[4], 1) // cmdType SEND + XCTAssertEqual(bytes[5], 24) // dataType + XCTAssertEqual(bytes[8], 1) // payload length LE + XCTAssertEqual(bytes[9], 0) + XCTAssertEqual(bytes[10], 1) // payload[0] + } + + func testAckGoldenBytes() { + let ack = packetizer.ack(dataType: 8, seq: 5, devType: 1) + XCTAssertEqual(ack.count, 20) + // [4]=4 ACK, [5]=8 dataType, [8]=1 len, [10]=1 status. + XCTAssertEqual(ack.hexString, "0001000504080000010001000000000000000000") + } + + func testMultiPacketSplitAndPageCount() { + // 25-byte payload → head (first 10) + 1 continuation (next 15 of 19). + let payload = (0..<25).map { UInt8($0) } + XCTAssertEqual(LuckRingPacketizer.continuationPages(payloadLength: 25), 1) + let frame = LuckRingFrame(cmdType: .send, dataType: 110, payload: payload, seq: 3, devType: 1) + let packets = packetizer.packets(for: frame) + XCTAssertEqual(packets.count, 2) + XCTAssertEqual(packets[0].count, 20) + XCTAssertEqual([UInt8](packets[0])[2], 1) // one continuation page declared + XCTAssertEqual([UInt8](packets[1])[0], 1) // continuation index (1-based) + XCTAssertEqual(Array([UInt8](packets[1])[1...15]), Array(payload[10...24])) + } + + // MARK: Assembler round-trip + + func testAssemblerRoundTripSinglePacket() { + let assembler = LuckRingFrameAssembler() + let frame = LuckRingFrame(cmdType: .send, dataType: 3, payload: [88, 1], seq: 9, devType: 1) + var completed: LuckRingFrame? + for packet in packetizer.packets(for: frame) { + completed = assembler.append(packet) + } + XCTAssertEqual(completed, frame) + } + + func testAssemblerRoundTripMultiPacket() { + let assembler = LuckRingFrameAssembler() + let payload = (0..<67).map { UInt8($0 & 0xff) } // the MixInfo bundle's size + let frame = LuckRingFrame(cmdType: .send, dataType: 110, payload: payload, seq: 4, devType: 1) + let packets = packetizer.packets(for: frame) + XCTAssertEqual(packets.count, 4) // head + 3 continuations + + var completed: LuckRingFrame? + for (i, packet) in packets.enumerated() { + let result = assembler.append(packet) + if i < packets.count - 1 { + XCTAssertNil(result, "must not complete until the last continuation") + } else { + completed = result + } + } + XCTAssertEqual(completed, frame) + } + + func testAssemblerTrimsPaddingWhenPayloadDoesNotFillTheLastPage() { + let assembler = LuckRingFrameAssembler() + // 25 bytes needs one continuation but fills only 15 of its 19 payload slots: the last packet is + // zero-padded on the wire, and the assembler must cut back to the head's declared length — + // temperature derives its record stride from the payload size, so padding would corrupt it. + let payload = (1...25).map { UInt8($0) } + let frame = LuckRingFrame(cmdType: .send, dataType: 47, payload: payload, seq: 3, devType: 1) + var completed: LuckRingFrame? + for packet in packetizer.packets(for: frame) { + completed = assembler.append(packet) + } + XCTAssertEqual(completed?.payload.count, 25, "padding from the final packet must be trimmed") + XCTAssertEqual(completed, frame) + } + + func testAssemblerRecoversFromAStaleHead() { + let assembler = LuckRingFrameAssembler() + // A head that promises continuations, then a *new* head before they arrive: the partial is dropped. + let abandoned = LuckRingFrame(cmdType: .send, dataType: 5, payload: (0..<30).map { UInt8($0) }, seq: 1, devType: 1) + _ = assembler.append(packetizer.packets(for: abandoned)[0]) // head only, no continuations + + let fresh = LuckRingFrame(cmdType: .send, dataType: 3, payload: [77, 0], seq: 2, devType: 1) + let completed = assembler.append(packetizer.packets(for: fresh)[0]) + XCTAssertEqual(completed, fresh, "a fresh head mid-assembly abandons the stale partial and completes") + } + + func testAssemblerDropsAContinuationWithNoHead() { + let assembler = LuckRingFrameAssembler() + var continuation = [UInt8](repeating: 0, count: 20) + continuation[0] = 1 + XCTAssertNil(assembler.append(Data(continuation))) + } + + func testAssemblerParsesADeviceAck() { + let assembler = LuckRingFrameAssembler() + // A device ACK head: [4]=4, status at [10]. + var ack = [UInt8](repeating: 0, count: 20) + ack[1] = 1; ack[3] = 6; ack[4] = 4; ack[5] = 111; ack[8] = 1; ack[10] = 1 + let frame = assembler.append(Data(ack)) + XCTAssertEqual(frame?.cmdType, .ack) + XCTAssertEqual(frame?.dataType, 111) + XCTAssertEqual(frame?.payload, [1]) + } + + // MARK: MixInfo TLV + + func testMixInfoTLVRoundTrip() { + let properties: [LuckRingMixInfoTLV.Property] = [ + .init(type: 102, data: [1, 2, 3, 4]), + .init(type: 124, data: [1, 0xFF, 0xFF, 0, 0]), + .init(type: 120, data: [1, 0]), + ] + let encoded = LuckRingMixInfoTLV.encode(properties) + // Header: [totalLen u16 LE][itemCount], totalLen = Σ propBytes + 1. + let propBytesLen = (4 + 3) + (5 + 3) + (2 + 3) + XCTAssertEqual(LuckRingBytes.u16(encoded, 0), propBytesLen + 1) + XCTAssertEqual(encoded[2], 3) + XCTAssertEqual(LuckRingMixInfoTLV.decode(encoded), properties) + } +} diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index 9b1e41a..d049619 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -301,7 +301,7 @@ final class PairingMatchingTests: XCTestCase { // The user still outranks everything, in both directions. XCTAssertEqual(card.preferredFamily(picked: .qring, rowFamily: .colmiSmartHealth, hinted: nil), .colmiR02) XCTAssertEqual(card.otherVariant(than: .smartHealth), .qring) - XCTAssertEqual(card.supportLevel, .limited) + XCTAssertEqual(card.supportLevel, .full) // No R99 art exists; nil is the only value `RingArtView` has a fallback for. XCTAssertNil(card.imageName) } @@ -623,20 +623,74 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(colmi.refinedCapabilities(bitmapDerived: [.powerOff]), colmi.capabilities) } + // MARK: - LuckRing / TK18 + + /// The TK18's real advertisement, captured with nRF: manufacturer data `64 FF …` — company ID + /// `0xFF64` in the little-endian slot, then MAC + the ASCII `WB39_1_2_0` version string. The + /// company-ID prefix is the single signal the vendor app matches on, so it is authoritative. + private var luckRingAdv: AdvertisementInfo { + AdvertisementInfo(serviceUUIDs: [], manufacturerData: bytes("64ffada9e302cedf024f574233395f315f325f30")) + } + + /// The `F618` service, advertised in its 16-bit short form. + private var luckRingServiceAdv: AdvertisementInfo { + AdvertisementInfo(serviceUUIDs: [CBUUID(string: "F618")], manufacturerData: nil) + } + + func testLuckRingClaimedByItsRealAdvertisement() { + XCTAssertTrue(LuckRingCoordinator.matches(name: nil, advertisement: luckRingAdv)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: nil, advertisement: luckRingAdv), .luckRing) + // No other coordinator may claim it. + XCTAssertFalse(TK5Coordinator.matches(name: nil, advertisement: luckRingAdv)) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: nil, advertisement: luckRingAdv)) + XCTAssertFalse(ColmiCoordinator.matches(name: nil, advertisement: luckRingAdv)) + XCTAssertFalse(JringCoordinator.matches(name: nil, advertisement: luckRingAdv)) + } + + func testLuckRingClaimedByServiceUUID() { + XCTAssertTrue(LuckRingCoordinator.matches(name: "Unlabeled", advertisement: luckRingServiceAdv)) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: "Unlabeled", advertisement: luckRingServiceAdv), .luckRing) + } + + func testTK18NameResolvesToTheLuckRingModel() { + for name in ["TK18", "TK18 A1B2", "TK18_1_2_0", "TK18-x"] { + XCTAssertEqual(WearableModel.model(advertisedName: name)?.id, "luckring-tk18", name) + } + XCTAssertEqual(WearableModel.luckRingTK18.family, .luckRing) + XCTAssertEqual(WearableModel.resolve(advertisedName: "TK18", selectedModelID: nil, family: .luckRing)?.id, "luckring-tk18") + } + + /// The two never cross-claim: a TK18 is not a TK5, and a TK5 is not a LuckRing. + func testTK18AndTK5DoNotCrossClaim() { + XCTAssertEqual(WearableModel.model(advertisedName: "TK18")?.families, [.luckRing]) + XCTAssertFalse(TK5Coordinator.matches(name: "TK18", advertisement: noAdv)) + XCTAssertFalse(LuckRingCoordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + // A TK5 / Colmi advertisement is never claimed as a LuckRing. + XCTAssertFalse(LuckRingCoordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + XCTAssertFalse(LuckRingCoordinator.matches(name: "R09_00AA", advertisement: qringAdv)) + XCTAssertFalse(LuckRingCoordinator.matches(name: "R99 54DC", advertisement: smartHealthAdv)) + } + + func testLuckRingSupportLevelIsLimited() { + XCTAssertEqual(RingDeviceType.luckRing.supportLevel, .limited) + XCTAssertEqual(WearableModel.luckRingTK18.supportLevel, .limited) + XCTAssertNil(RingAppVariant(family: .luckRing), "single-firmware family — no app picker") + } + // MARK: - Support level func testSupportLevelIsPerFamily() { XCTAssertEqual(RingDeviceType.jring.supportLevel, .full) XCTAssertEqual(RingDeviceType.colmiR02.supportLevel, .full) XCTAssertEqual(RingDeviceType.tk5.supportLevel, .limited) - XCTAssertEqual(RingDeviceType.colmiSmartHealth.supportLevel, .limited) + XCTAssertEqual(RingDeviceType.colmiSmartHealth.supportLevel, .full) } - /// Both YCBT families are unproven, and only unproven families get a badge. The two cards that - /// *default* to one — the TK5 and the R99 — therefore wear it as they sit; a two-app Colmi card wears - /// it only while its picker is on SmartHealth (the same physical ring, a different driver's maturity). + /// Only unproven families get a badge — the TK5 (never connected on hardware) and the LuckRing + /// family (only the TK18 unit is proven). The SmartHealth-Colmi graduated to `.full` once an R99 + /// ran against the driver on hardware, so neither Colmi picker position wears a badge anymore. func testLimitedSupportFamiliesCarryTheBadge() { - let limitedByDefault: Set = [WearableModel.tk5.id, WearableModel.colmiR99.id] + let limitedByDefault: Set = [WearableModel.tk5.id, WearableModel.luckRingTK18.id] for model in WearableModel.catalog { let expected: WearableSupportLevel = limitedByDefault.contains(model.id) ? .limited : .full XCTAssertEqual(model.supportLevel, expected, model.displayName) @@ -648,7 +702,7 @@ final class PairingMatchingTests: XCTestCase { } for model in WearableModel.catalog where !model.appVariants.isEmpty { XCTAssertEqual(model.supportLevel(for: .qring), .full, model.displayName) - XCTAssertEqual(model.supportLevel(for: .smartHealth), .limited, model.displayName) + XCTAssertEqual(model.supportLevel(for: .smartHealth), .full, model.displayName) } } diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 128c635..05929ab 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -56,12 +56,23 @@ section breaks the hardware down by manufacturer. [:octicons-arrow-right-24: TK5 / SmartHealth](tk5.md) +- :material-flask-outline: __LuckRing / TK18__ + + --- + + 🧪 Limited. The ~$10 Shenzhen Coolwear (Kewo) ring family — `0xFF64` "K6" + protocol, LuckRing app — rebuilt from the vendor SDK. TK18 is the tested + unit; white-labeled siblings (SIMSONLAB, Yoidesu, …) pair too. + + [:octicons-arrow-right-24: LuckRing / TK18](luckring.md) + - :material-help-circle-outline: __SIMSONLAB__ --- - Not supported — unknown protocol on a Phyplus PHY6222 SoC, with no public - reverse engineering. Documented here for reference. + Split: the **LuckRing-app** simsonlab rings are 🧪 supported (see + [LuckRing / TK18](luckring.md)); the **LA380-YJ** (Phyplus PHY6222, unknown + protocol) is not. Documented here for reference. [:octicons-arrow-right-24: SIMSONLAB](simsonlab.md) @@ -151,7 +162,7 @@ own column — same hardware, different protocol, different driver. ³ Profile-derived estimate from sex/age/height/weight, not a real glucometer reading. ⁴ Decoded from the vendor SDK, but the *value scale* is inferred rather than observed — one confirmed reading on hardware settles it. See [TK5: needs on-device confirmation](tk5.md#needs-on-device-confirmation). ⁵ PulseLoop implements no OTA for either YCBT ring. On the TK5 that path is JieLi RCSP auth on `AE00` (out of scope); on a SmartHealth-Colmi the chip scheme — and therefore which DFU stack it would even need — is unknown. -⁶ A Colmi/Yawell ring that shipped with the **SmartHealth** app rather than QRing — R09/R10 confirmed, other models possible. It speaks the TK5's YCBT protocol, so it runs the same driver. 🧪 throughout: **no unit has been connected yet**. See [SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings). +⁶ A Colmi/Yawell ring that shipped with the **SmartHealth** app rather than QRing — R09/R10 confirmed, other models possible. It speaks the TK5's YCBT protocol, so it runs the same driver. The family is **verified on hardware** (an R99 runs against the driver daily — connect, battery, activity/sleep/HR/BP/vitals sync); 🧪 marks the capabilities not yet individually cross-checked against the vendor app. See [SmartHealth-app Colmi rings](colmi.md#smarthealth-app-colmi-rings). ⁷ Sensor-dependent, so it is claimed **per ring** rather than per family: the handshake reads the ring's own capability bitmap and PulseLoop enables the metric only if the ring claims it. ⁸ Not claimed: no capability bit names fatigue, so it can be neither gated nor honestly promised on hardware nobody has connected. The first real sync decides. @@ -161,7 +172,7 @@ The TK5 stores stress and fatigue on the ring itself (the body-data record), whi | Ring | Reason | |---|---| -| **SIMSONLAB LA380-YJ** | Unknown protocol (PHY6222 SoC), no reverse engineering — see [SIMSONLAB](simsonlab.md) | +| **SIMSONLAB LA380-YJ** | Unknown protocol (PHY6222 SoC), no reverse engineering — see [SIMSONLAB](simsonlab.md). (Note: the *other* simsonlab-branded rings, which pair with the **LuckRing** app, **are** supported — see [LuckRing / TK18](luckring.md).) | | **Oura Gen 3/4** | Encrypted BLE, proprietary protocol, subscription required — see [Premium rings](premium.md) | | **Ultrahuman Ring Air** | Not yet implemented (protocol is documented) — see [Premium rings](premium.md) | | **RingConn Gen 2** | No public protocol, no reverse engineering — see [Premium rings](premium.md) | @@ -178,9 +189,10 @@ Multiple hardware platforms span from $7 commodity rings to $350 premium devices |---|---|---|---|---|---| | **[56ff / Jring](jring.md)** | Renesas DA14531 | Custom 56ff (SXR KeepFit SDK) | Jring / KeepFit | $7–12 | ✅ App + FW | | **[Colmi / Yawell (QRing)](colmi.md)** | Realtek RTL8762 family | Nordic-UART (QRing) | QRing | $15–30 | ✅ App (R02 FW too) | -| **[Colmi / Yawell (SmartHealth)](colmi.md#smarthealth-app-colmi-rings)** | Realtek RTL8762 family | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | 🧪 App (limited — same protocol as the TK5) | +| **[Colmi / Yawell (SmartHealth)](colmi.md#smarthealth-app-colmi-rings)** | Realtek RTL8762 family | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | ✅ App (same protocol as the TK5; verified on an R99) | | **[Colmi R11](colmi.md#colmi-r11-qring-compatible-with-fidget-shell)** | Realtek AB2026 | Nordic-UART QRing | QRing / Da Rings | ~$15–25 | ✅ App (untested) | | **[TK5](tk5.md)** | JieLi (part ❓) | Yucheng YCBT (`be940`) | SmartHealth | ❓ | 🧪 App (limited) | +| **[LuckRing / TK18](luckring.md)** | ❓ (Coolwear/Kewo OEM) | Custom "K6" (`F618`) | LuckRing | ~$10 | 🧪 App (limited) | | **[SIMSONLAB](simsonlab.md)** | Phyplus PHY6222 | Unknown | SIMSONLAB app | ~$10–20 | ❌ | ### Premium Rings diff --git a/docs/hardware/luckring.md b/docs/hardware/luckring.md new file mode 100644 index 0000000..7d8e3ac --- /dev/null +++ b/docs/hardware/luckring.md @@ -0,0 +1,168 @@ +--- +title: TK18 / LuckRing +description: >- + The ~$10 LuckRing-app ring family (Shenzhen Coolwear/Kewo OEM, "K6" protocol, + company ID 0xFF64), sold as TK18/TK18A and white-labeled under SIMSONLAB and + other brands. HR, SpO₂, HRV, temperature, BP trend, sleep, steps. +--- + +# LuckRing / TK18 + +**PulseLoop support: 🧪 Limited — TK18 is the tested unit** + +A ~$10 commodity smart ring family built by the **Shenzhen Coolwear (Kewo)** OEM +and white-labeled under many storefront brands — the unit PulseLoop was tested +against was sold as a **SIMSONLAB** ring on Shein, but the hardware identifies +itself as **TK18** and pairs with Coolwear's **LuckRing** app. It speaks a custom +cleartext protocol (vendor SDK name "K6") on service `F618` that shares nothing +with the other ring families. As far as we know this is the first public +reverse-engineering of this family. + +!!! warning "Limited support — one unit tested" + TK18 is the only hardware-tested unit of the `0xFF64` family. The protocol and + record layouts come from the vendor SDK and are unit-tested against fixture + bytes, but a few scales and toggles still want a confirmed on-device reading + (noted in the capability table). Every decoded metric is range-gated before + storage, so a misdecode is dropped rather than saved as garbage. + +## At a glance + +| | Detail | +|---|---| +| **SoC** | Unknown (not published; no public teardown) | +| **Bluetooth** | BLE 5.3 (per the TK18A wholesale listing) | +| **PPG sensor** | Unknown (HR/SpO₂ + skin temperature) | +| **Accelerometer** | Yes (steps, sleep) | +| **Battery / life** | 15 mAh · 3–4 days (TK18A); a 20 mAh / ~7-day variant exists | +| **Charging** | Magnetic cable (no case), ~60 min full charge | +| **Waterproof** | "5ATM" claimed | +| **Weight / size** | ~5 g · 2.5 mm wall · US sizes 7–13 | +| **Price** | ~$10 retail (wholesale $10.70–11.80) | +| **Protocol** | Custom "K6" (`F618` service), fixed 20-byte frames, cleartext | +| **App** | LuckRing (iOS + Android) | +| **Advertised name** | `TK18`; manufacturer company ID `0xFF64` | +| **Custom firmware** | ❌ None known | + +## Manufacturer + +- **Shenzhen Coolwear Technology Co., Ltd.** (est. 2020, Longhua District, Shenzhen) — + app-package alias **"Kewo"** (`com.kewo.coolring`); watch/wearable ODM +- Runs the **LuckRing** app and the `luckring.coolwear.fit` backend; same developer + ships Coolwear / CoolWear Pro / CoolWear MAX-01 / 灵犀·魔戒 (Lingxi Magic Ring) +- Holds FCC ID **2BKWA-L01** ("Smart Ring") — grant details not publicly retrievable +- Wholesale channel: listed as model **TK18A** by Shenzhen IU Smart Technology + (~$11, explicitly "works with LuckRing") +- Retail white-labels observed: **SIMSONLAB** (Shein), **Yoidesu** and unbranded + "LuckRing App" rings (Amazon/eBay) +- LuckRing app: Google Play since July 2024 (~79k downloads, ~2.2★), App Store + id6472891975 (~2.1★) — the low ratings are the vendor app; the hardware is fine + once driven directly + +## Hardware + +| Component | Detail | +|---|---| +| **SoC** | Unknown — never published, no FCC internal photos available | +| **PPG sensor** | Unknown (green LED HR, red/IR SpO₂, plus skin temperature) | +| **Accelerometer** | Yes (steps, sleep staging) | +| **Battery** | 15 mAh (TK18A listing) or 20 mAh (LuckRing-branded manual) — at least two variants | +| **Material** | Stainless steel shell, glue-poured (epoxy) body | +| **Weight** | ~5 g | +| **Waterproof** | "5ATM" claimed (treat skeptically at this price) | +| **Sizes** | US 7–13 (18–22.1 mm inner diameter) | + +## Protocol + +Reverse-engineered from the decompiled LuckRing Android app (vendor SDK +`ce.com.cenewbluesdk`, internal family "K6"). No public documentation existed +before this work. The full wire spec lives in the repo at +`tasks/luckring-protocol.md`. + +| Property | Value | +|---|---| +| **Service UUID** | `0000f618-0000-1000-8000-00805f9b34fb` | +| **Write characteristic** | `0000b002-...` (write-without-response only) | +| **Notify characteristic** | `0000b001-...` | +| **Secondary service** | `FF12` (chars `FF14`/`FF15`) — believed OTA, unused | +| **Frame size** | Fixed 20 bytes, little-endian | +| **CRC** | Disabled — the vendor always sends `0x0000` | +| **Epoch** | True UTC Unix seconds | +| **Encryption** | None (cleartext); binding is a plaintext TLV bundle | +| **Standard BLE services** | Heart Rate Service (`0x180D`) — deliberately ignored | +| **Identification** | Manufacturer company ID `0xFF64` (not a Bluetooth SIG assignment) | + +The SDK's PID table names sibling families **618 / 818 / 118 / 518 / S2** (service +`F618` = the 618 family), so other LuckRing-app rings should pair too — PulseLoop +matches the same signals the vendor app does (the `0xFF64` company ID / `F618` +service, no name whitelist). A non-TK18 sibling still pairs but gets generic ring +art and a fallback name. + +## Capabilities + +| Capability | Status | Notes | +|---|:---:|---| +| Heart rate — spot / live / history | ✅ | BPM | +| SpO₂ — spot / history | ✅ | | +| HRV — spot / history | ✅ | ms | +| Steps / distance | ✅ | Distance/calorie units unverified; calories dropped | +| Sleep (light / deep / awake) | ✅ | No REM; "movement" entries render as light sleep | +| Battery level | ✅ | In-band, with charging flag | +| Find device | ✅ | | +| Auto-monitoring config | ✅ | Firmware default is **off** — PulseLoop pushes the HR/SpO₂ interval config on every connect, or the ring never logs on its own | +| Skin temperature | 🧪 | `value/10 °C` — awaiting a confirmed on-device reading | +| Blood pressure | 🧪 | The ring accepts the command but streamed no reading in testing — possibly unsupported on this SKU. No cuff calibration — treat as a trend | +| Stress | 🧪 | Record layout unconfirmed; range-gated | +| REM sleep | ❌ | Protocol has no REM stage | +| Blood sugar | ❌ | The vendor opcode is a placeholder — the app's value is an estimate, not a sensor | +| Menstrual cycle | ❌ | User-entered calendar data in the vendor app, not a sensor | +| Continuous background sync | ❌ | Read while connected only | +| FW update via app | ❌ | Out of scope | + +!!! info "PulseLoop never wipes the ring" + The vendor SDK has a destructive clear-history opcode; PulseLoop never sends + it. The ring replays its whole log on every sync, and PulseLoop dedupes + app-side (upserts by kind + timestamp / bucket / night), so re-syncs never + duplicate. + +### What the TK18 CANNOT do + +- REM sleep detection (protocol has light/deep/awake/movement only) +- Real blood sugar (app-side estimate, no device measurement) +- Continuous streaming while disconnected (logs on its own schedule; read on connect) + +## Known models + +- **TK18 / TK18A** — the tested unit; ~$10 on Shein (as SIMSONLAB), ~$11 wholesale +- Unbranded "LuckRing App" rings and **Yoidesu**-branded units on Amazon/eBay +- SDK PID families 618 / 818 / 118 / 518 / S2 — untested siblings, expected to pair + +## Firmware + +- The advertisement carries a board/version string (observed: `WB39_1_2_0`) with no + public footprint — no FCC teardown, firmware repo, or update URL is known +- Device-info reply reports a 5-part dotted version (customer.hardware.code.picture.font) +- OTA is in-band / via the `FF12` service in the vendor app; not implemented in PulseLoop + +## Background behavior + +- The ring only logs autonomously after the auto-monitoring config is pushed + (firmware default: off) — PulseLoop sends it on every connect +- No keepalive needed; the link stays up and history re-syncs every 30 minutes + while connected, plus a post-workout vitals pass +- History horizon: PulseLoop drops samples older than ~8 days on ingest + +## Hackability + +**🥉 Newly documented** + +- ✅ Protocol reverse-engineered from the decompiled vendor app (this repo: + `tasks/luckring-protocol.md` + the `LuckRing*` driver sources) +- ✅ Cleartext BLE, no encryption, no CRC +- ❌ No public teardown, FCC internals, SoC identification, or custom firmware +- ❌ No other open-source implementation exists (checked Gadgetbridge, ATC rings) +- **Price:** ~$10 + +--- + +See the [SIMSONLAB page](simsonlab.md) for the *unsupported* LA380-YJ, or the +[hardware overview](index.md) for the full cross-manufacturer comparison tables. diff --git a/docs/hardware/simsonlab.md b/docs/hardware/simsonlab.md index 7c19ae6..c5bad9b 100644 --- a/docs/hardware/simsonlab.md +++ b/docs/hardware/simsonlab.md @@ -7,7 +7,14 @@ description: >- # SIMSONLAB -**PulseLoop support: ❌ Not supported** +**PulseLoop support: ❌ Not supported (this model)** + +!!! tip "SIMSONLAB sells more than one ring — the LuckRing-app ones *are* supported" + SIMSONLAB is a retail brand (SHARE AUDIO HONG KONG LIMITED) that sources rings from at least two + OEMs. This page is about the **LA380-YJ** (a Phyplus PHY6222 ring on an unknown protocol, paired + with the *SIMSONLAB* app), which is not supported. The SIMSONLAB-branded rings that pair with the + **LuckRing** app are a different OEM's hardware (Shenzhen Coolwear/Kewo, advertising as `TK18`) and + **are** supported — see **[LuckRing / TK18](luckring.md)**. Check which app your ring came with. The SIMSONLAB LA380-YJ uses a completely different, undocumented BLE protocol on a Phyplus PHY6222 SoC. There's no public reverse engineering, so it isn't diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 7e18f01..837aafe 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -1,8 +1,7 @@ --- title: TK5 / SmartHealth description: >- - The TK5 ring (SmartHealth app) — the Yucheng YCBT protocol, rebuilt from the - decompiled vendor SDK. Broad metric support; awaiting on-device confirmation. + The TK5 ring (SmartHealth app) — the Yucheng YCBT protocol. Broad metric support. --- # TK5 / SmartHealth @@ -13,9 +12,6 @@ The TK5 pairs with the **SmartHealth** app (`com.zhuoting.healthyucheng`) and sp YCBT** protocol on a `be940` service — nothing in common at the wire level with the [56ff / Jring](jring.md) or [Colmi / Yawell QRing](colmi.md) families. -PulseLoop's driver is reconstructed from the **decompiled Yucheng YCBT SDK** (`com.yucheng.ycbtsdk`, -v4.0.10) that ships inside the SmartHealth Android app. - !!! warning "Limited support — the protocol is proven, the TK5 hasn't been re-tested" The YCBT stack is confirmed working on real hardware — but on a *sibling* ring, the [Colmi R09](colmi.md#smarthealth-app-colmi-rings), not on a TK5. Pairing, the handshake, history @@ -81,18 +77,6 @@ at all: they are offered only if *this* unit's `02 01` capability bitmap sets th (`YCBTSupportFunction` → `TK5Coordinator.bitmapGatedCapabilities`). Everything else is a **baseline** promise — the app claims it unconditionally, and the bitmap can only ever *add*, never remove. -The reason for the split is a sibling ring. The owner's **R99** (a Colmi on this same YCBT protocol) -had HRV promised unconditionally, denied it four independent ways — bitmap bit clear, `01 45` → `0xFC`, -`05 33` → `0xFC`, `03 2f` mode `0a` → outright refusal — and the "Measure HRV" button spun for 45 s and -failed, every time. The TK5's temperature / stress / fatigue / blood-sugar claims rested on exactly the -same kind of reasoning that produced that bug (*the SDK defines the record type*), and no TK5 has ever -been seen producing one of those records. So the ring now says, and the app listens. - -**HRV is the exception that proves it**: it stays a baseline promise because it was *observed working on -a TK5* (48 / 79 ms, cross-checked against the vendor app). Evidence from the hardware outranks a bit — -and since no TK5 `02 01` reply has ever been captured, gating HRV could only risk losing a feature that -demonstrably works. - | Capability | Status | Needs on-device confirmation | Notes | |---|:---:|---|---| | Heart rate — spot | 🧪 | — | `03 2f` mode `00` → `06 01` stream; the standard `180D` char is deliberately ignored (see below) | @@ -167,15 +151,7 @@ app-side: every history sample upserts on `(kind, timestamp)`, activity buckets epoch, and the cumulative step counter is a per-day `max` ratchet. All three are idempotent under replay, so a double-sync produces no duplicates. The cost is a longer sync as the ring's log fills. -(An earlier version of the driver sent `05 40/42/43/44/4E` *believing they enabled monitoring*. They -are the delete opcodes. The real enables are the five `01 xx {enable, interval}` monitor commands.) - ## Known limitations - -- **No TK5 has run this driver yet.** The protocol is confirmed on a [sibling YCBT ring](colmi.md#smarthealth-app-colmi-rings) - and the layouts come from the vendor SDK, but the TK5-specific items in - [Needs on-device confirmation](#needs-on-device-confirmation) are still open, which is why support - stays "Limited". See [Contributing](../project/contributing.md) if you own one. - **~8-day history horizon.** `RingEventBridge` drops any history sample, sleep session or activity timestamp outside `now − 8 days … now + 1 hour`. A ring's log can hold records stamped under a *previous* clock, which decode hours or days out of place — and because history rows upsert, one diff --git a/mkdocs.yml b/mkdocs.yml index b1a562f..08d2301 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,6 +111,7 @@ nav: - Jring / 56ff: hardware/jring.md - Colmi / Yawell (QRing + SmartHealth): hardware/colmi.md - TK5 / SmartHealth: hardware/tk5.md + - LuckRing / TK18: hardware/luckring.md - SIMSONLAB: hardware/simsonlab.md - Premium Rings: hardware/premium.md - Platforms: