From 424fb61523f455b5d64e4e145c747cfee05041db Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 5 Aug 2026 12:54:09 -0400 Subject: [PATCH] Add RWfit ring family support (dual 0x7E/0xAB protocol) --- .../Diagnostics/DiagnosticsExporter.swift | 11 +- PulseLoop/Diagnostics/RawPacketCapture.swift | 29 ++ PulseLoop/Events/PulseEventBus.swift | 21 +- PulseLoop/RingProtocol/RWfitCommandGate.swift | 141 ++++++ PulseLoop/RingProtocol/RWfitCoordinator.swift | 70 +++ PulseLoop/RingProtocol/RWfitDecoder.swift | 413 ++++++++++++++++++ PulseLoop/RingProtocol/RWfitDriver.swift | 173 ++++++++ PulseLoop/RingProtocol/RWfitEncoder.swift | 189 ++++++++ PulseLoop/RingProtocol/RWfitHistorySync.swift | 136 ++++++ PulseLoop/RingProtocol/RWfitJLCodec.swift | 99 +++++ PulseLoop/RingProtocol/RWfitLegacyCodec.swift | 123 ++++++ PulseLoop/RingProtocol/RWfitProtocol.swift | 285 ++++++++++++ PulseLoop/RingProtocol/RWfitSyncEngine.swift | 139 ++++++ PulseLoop/RingProtocol/RingBLEClient.swift | 7 + PulseLoop/Services/Repositories.swift | 8 +- PulseLoop/Views/Settings/DeviceHeroCard.swift | 2 + .../Settings/PrivacyDataSettingsView.swift | 30 +- PulseLoop/Wearables/WearableCoordinator.swift | 7 + PulseLoop/Wearables/WearableDriver.swift | 13 + PulseLoop/Wearables/WearableModel.swift | 21 +- PulseLoopTests/CapabilityGatingTests.swift | 27 ++ PulseLoopTests/PairingMatchingTests.swift | 74 +++- PulseLoopTests/RWfitDecoderTests.swift | 286 ++++++++++++ PulseLoopTests/RWfitDriverTests.swift | 214 +++++++++ PulseLoopTests/RWfitHistorySyncTests.swift | 129 ++++++ PulseLoopTests/RWfitJLCodecTests.swift | 92 ++++ PulseLoopTests/RWfitLegacyCodecTests.swift | 119 +++++ docs/hardware/index.md | 12 + docs/hardware/rwfit.md | 180 ++++++++ mkdocs.yml | 1 + 30 files changed, 3027 insertions(+), 24 deletions(-) create mode 100644 PulseLoop/Diagnostics/RawPacketCapture.swift create mode 100644 PulseLoop/RingProtocol/RWfitCommandGate.swift create mode 100644 PulseLoop/RingProtocol/RWfitCoordinator.swift create mode 100644 PulseLoop/RingProtocol/RWfitDecoder.swift create mode 100644 PulseLoop/RingProtocol/RWfitDriver.swift create mode 100644 PulseLoop/RingProtocol/RWfitEncoder.swift create mode 100644 PulseLoop/RingProtocol/RWfitHistorySync.swift create mode 100644 PulseLoop/RingProtocol/RWfitJLCodec.swift create mode 100644 PulseLoop/RingProtocol/RWfitLegacyCodec.swift create mode 100644 PulseLoop/RingProtocol/RWfitProtocol.swift create mode 100644 PulseLoop/RingProtocol/RWfitSyncEngine.swift create mode 100644 PulseLoopTests/RWfitDecoderTests.swift create mode 100644 PulseLoopTests/RWfitDriverTests.swift create mode 100644 PulseLoopTests/RWfitHistorySyncTests.swift create mode 100644 PulseLoopTests/RWfitJLCodecTests.swift create mode 100644 PulseLoopTests/RWfitLegacyCodecTests.swift create mode 100644 docs/hardware/rwfit.md diff --git a/PulseLoop/Diagnostics/DiagnosticsExporter.swift b/PulseLoop/Diagnostics/DiagnosticsExporter.swift index 735183f4..e76953c7 100644 --- a/PulseLoop/Diagnostics/DiagnosticsExporter.swift +++ b/PulseLoop/Diagnostics/DiagnosticsExporter.swift @@ -5,8 +5,9 @@ import UIKit #endif /// Builds a shareable diagnostics bundle (JSON): app/OS/device info + the recent `WearableLog` -/// timeline. Raw BLE packets (`RawPacketRow`) are included only in DEBUG builds, so release exports -/// never leak protocol bytes. +/// timeline. Raw BLE packets (`RawPacketRow`) ride along only while `RawPacketCapture.isEnabled` — +/// always in DEBUG builds, and in release only under the user's explicit Privacy & Data opt-in +/// (the remote-tester workflow for rings nobody on the project has in hand). @MainActor enum DiagnosticsExporter { /// Serialize a diagnostics report to pretty-printed JSON. @@ -16,9 +17,9 @@ enum DiagnosticsExporter { root["app"] = appInfo() root["device"] = deviceInfo(context: context) root["logs"] = recentLogs(context: context, limit: maxLogs) - #if DEBUG - root["rawPackets"] = recentPackets(context: context, limit: 200) - #endif + if RawPacketCapture.isEnabled { + root["rawPackets"] = recentPackets(context: context, limit: 200) + } guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]), let json = String(data: data, encoding: .utf8) else { diff --git a/PulseLoop/Diagnostics/RawPacketCapture.swift b/PulseLoop/Diagnostics/RawPacketCapture.swift new file mode 100644 index 00000000..200200be --- /dev/null +++ b/PulseLoop/Diagnostics/RawPacketCapture.swift @@ -0,0 +1,29 @@ +import Foundation + +/// The switch that decides whether raw BLE packets (`RawPacketRow`) are persisted and exported. +/// +/// DEBUG builds always capture — the packet feed is the daily protocol-debugging tool. Release +/// builds capture **only while the user has switched it on** in Privacy & Data → Diagnostics. The +/// release path exists for exactly one workflow: a remote tester on TestFlight pairing a ring +/// family nobody on the project has in hand (RWfit is the first), where the diagnostics export's +/// hex rows are the only way to see what the ring actually said. Raw packets encode health +/// readings, so the toggle is off by default, visibly labelled, and the captured rows can be +/// cleared from the same screen. +enum RawPacketCapture { + static let defaultsKey = "diagnostics.captureRawPackets" + + /// Whether the persistence subscriber should store packets right now. + static var isEnabled: Bool { + #if DEBUG + return true + #else + return userOptedIn + #endif + } + + /// The release-build opt-in, as shown by the Privacy & Data toggle. + static var userOptedIn: Bool { + get { UserDefaults.standard.bool(forKey: defaultsKey) } + set { UserDefaults.standard.set(newValue, forKey: defaultsKey) } + } +} diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 5bf45980..284d644d 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -89,13 +89,12 @@ final class EventPersistenceSubscriber { private let context: ModelContext private var task: Task? - #if DEBUG - /// Rolling cap for the DEBUG-only raw-packet trace, and how often we prune (every Nth insert, - /// so we don't pay a fetch on every packet during a sync burst). + /// Rolling cap for the raw-packet trace, and how often we prune (every Nth insert, so we don't + /// pay a fetch on every packet during a sync burst). DEBUG builds always capture; release + /// builds only when the user opts in (see `RawPacketCapture`). private let rawPacketCap = 2_000 private let rawPacketPruneInterval = 200 private var rawPacketInsertsSincePrune = 0 - #endif /// Coalesced-save state. During a sync the ring streams hundreds of events; saving per event /// woke every `@Query` hundreds of times (the re-render storm). Instead we insert/mutate without @@ -239,9 +238,12 @@ final class EventPersistenceSubscriber { context.insert(device) recordBatterySample(percent) case let .rawPacket(direction, data, decoded): - // The raw byte trace is a developer diagnostic only — never stored in release builds, so - // production never persists protocol hex/opcodes. - #if DEBUG + // The raw byte trace is a developer diagnostic. DEBUG builds always keep it; release + // builds keep it only while the user has explicitly enabled capture in Privacy & Data — + // the toggle exists so a remote tester on TestFlight can hand back protocol bytes from a + // ring family we've never had in hand (raw packets encode health data, hence opt-in, + // off by default and clearable). + guard RawPacketCapture.isEnabled else { return } context.insert( RawPacketRow( direction: direction, @@ -252,14 +254,13 @@ final class EventPersistenceSubscriber { confidence: decoded.confidence ) ) - // Keep the debug trace a rolling window so it can't grow without bound. Prune only - // every Nth insert to avoid a fetch on every packet during a sync burst. + // Keep the trace a rolling window so it can't grow without bound. Prune only every Nth + // insert to avoid a fetch on every packet during a sync burst. rawPacketInsertsSincePrune += 1 if rawPacketInsertsSincePrune >= rawPacketPruneInterval { rawPacketInsertsSincePrune = 0 DebugRepository.pruneRawPackets(maxRows: rawPacketCap, context: context) } - #endif case let .derivedUpdate(kind, entityType, entityId, payloadJSON): context.insert(DerivedUpdateRow(kind: kind, entityType: entityType, entityId: entityId, payloadJSON: payloadJSON)) case let .activityUpdate(timestamp, steps, distanceMeters, calories): diff --git a/PulseLoop/RingProtocol/RWfitCommandGate.swift b/PulseLoop/RingProtocol/RWfitCommandGate.swift new file mode 100644 index 00000000..8447af87 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitCommandGate.swift @@ -0,0 +1,141 @@ +import Foundation + +/// The RWfit protocol-level command queue: **one outstanding command at a time**, released by the +/// device's transport ACK (legacy `0xFE` matching our serial+cmd; JieLi flag-0x11 matching our +/// triple) or by a timeout after one retry. Mirrors `x5/d.java` / `x5/c.java`'s LinkedList queues, +/// including their inter-command spacing (100 ms legacy / 230 ms JieLi). +/// +/// Lives behind the driver (which owns the codecs and sees the ACKs); the sync engine and history +/// pager submit logical `RWfitOutbound` commands and never see wire bytes. Our own outbound ACK +/// frames deliberately bypass this queue — they expect no reply, and delaying one stalls the ring's +/// retransmit loop (the vendor's `b3 == -1` fast path). +@MainActor +final class RWfitCommandGate { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let legacyCodec: RWfitLegacyCodec + private let jlCodec: RWfitJLCodec + + /// Response timeout per attempt; the vendor allows 2 retries at a shorter spacing, but + /// `RingBLEClient`'s own 4 s GATT write-ACK timeout already covers the transport layer, so one + /// protocol retry is enough to survive a dropped notification. + private let responseTimeout: TimeInterval + private let legacySpacing: TimeInterval = 0.1 + private let jieliSpacing: TimeInterval = 0.23 + + /// The framing every submitted command is framed with. Set by the driver at service discovery, + /// before anything can be submitted (`runStartup` runs after `.connected`). + var framing: RWfitFraming = .legacy + + private var queue: [RWfitOutbound] = [] + private var inFlight: RWfitOutbound? + private var inFlightSerial = 0 + private var retried = false + private var timeoutTask: Task? + private var spacingTask: Task? + + init( + writer: RingCommandWriter?, + legacyCodec: RWfitLegacyCodec, + jlCodec: RWfitJLCodec, + responseTimeout: TimeInterval = 2 + ) { + self.writer = writer + self.legacyCodec = legacyCodec + self.jlCodec = jlCodec + self.responseTimeout = responseTimeout + } + + var isIdle: Bool { inFlight == nil && queue.isEmpty } + + /// Enqueue a logical command; sends immediately when the channel is free. + func submit(_ command: RWfitOutbound) { + queue.append(command) + pump() + } + + /// Drop everything (disconnect/teardown). In-flight state must not survive into the next link — + /// its serial would never match and would wedge the queue. + func cancel() { + timeoutTask?.cancel(); timeoutTask = nil + spacingTask?.cancel(); spacingTask = nil + queue.removeAll() + inFlight = nil + retried = false + } + + // MARK: - ACKs from the device (driver calls these from `ingest`) + + /// Legacy `0xFE`: release when serial and cmd match the in-flight command (`x5/d.java i()`). + func noteLegacyAck(cmd: UInt8, serial: Int) { + guard case let .legacy(inCmd, _)? = inFlight, inCmd == cmd, serial == inFlightSerial else { return } + release() + } + + /// JieLi flag-0x11: release when the echoed triple matches (`x5/c.java f()`). + func noteJieliAck(triple: RWfitJLTriple) { + guard case let .jieli(payload)? = inFlight, payload.count >= 3, + payload[0] == triple.cmd, payload[1] == triple.key, payload[2] == triple.keyFlag + else { return } + release() + } + + // MARK: - Pump + + private func pump() { + guard inFlight == nil, spacingTask == nil, !queue.isEmpty else { return } + let command = queue.removeFirst() + inFlight = command + retried = false + send(command) + } + + private func send(_ command: RWfitOutbound) { + switch command { + case let .legacy(cmd, payload): + let encoded = legacyCodec.encode(cmd: cmd, payload: payload) + inFlightSerial = encoded.serial + writer?.enqueue(encoded.frame) + case let .jieli(payload): + writer?.enqueue(jlCodec.encode(payload: payload)) + } + armTimeout() + } + + private func armTimeout() { + timeoutTask?.cancel() + timeoutTask = Task { [weak self] in + let nanos = UInt64((self?.responseTimeout ?? 2) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + guard !Task.isCancelled, let self else { return } + self.timedOut() + } + } + + private func timedOut() { + guard let command = inFlight else { return } + if retried { + // Two silent attempts: drop it and move on — wedging the queue on one lost command + // starves everything behind it (the vendor does the same after its retry budget). + release() + } else { + retried = true + send(command) + } + } + + private func release() { + timeoutTask?.cancel(); timeoutTask = nil + inFlight = nil + // Inter-command spacing: the firmware drops back-to-back commands (the vendor paces at + // 100/230 ms), so the next send waits out the gap. + let spacing = framing == .jieli ? jieliSpacing : legacySpacing + spacingTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(spacing * 1_000_000_000)) + guard !Task.isCancelled, let self else { return } + self.spacingTask = nil + self.pump() + } + } +} diff --git a/PulseLoop/RingProtocol/RWfitCoordinator.swift b/PulseLoop/RingProtocol/RWfitCoordinator.swift new file mode 100644 index 00000000..165403bf --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitCoordinator.swift @@ -0,0 +1,70 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// Coordinator for the RWfit family (`com.rw.revivalfit` — rings sold under assorted brands, +/// including "Colmi"-labelled units that share nothing with the Colmi protocol). This file is the +/// whole of what makes an RWfit ring an RWfit ring: its advertised identity and its capability set; +/// the two-framings problem lives entirely in `RWfitDriver`. +/// +/// Recognition is by **strong, family-exclusive signals** — exactly the ones the vendor's own +/// scanner keys on (`r5/d.java:70-134`): +/// - the advertised `A00A` service (the vendor's `pidType 1` pattern `02 01 06 03 03 0a a0` is +/// Flags + a 16-bit service list containing `0xA00A`, which CoreBluetooth surfaces as a service +/// UUID), or +/// - manufacturer data opening with company ID `0x05D6` (`d6 05 02 00` / `d6 05 41 54` "AT") or +/// `0x06D6` (`d6 06 02 00`, the "T-Ring" line). +/// +/// **No name matching, on purpose**: the one field a rebrander always changes is the name — the +/// known unit was bought as a "Colmi" — and the catalog card's `advertisedNamePatterns` is empty +/// until a diagnostics export shows what these rings actually call themselves. +@MainActor +final class RWfitCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let deviceType: RingDeviceType = .rwfit + + /// Manufacturer-data prefixes: little-endian company ID + the vendor's fixed lead-in bytes. + static let manufacturerHexPrefixes = ["d6050200", "d6054154", "d6060200"] + + static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool { + if advertisesService(advertisement) { return true } + if let mfg = advertisement.manufacturerData { + let hex = mfg.hexString + if manufacturerHexPrefixes.contains(where: hex.hasPrefix) { return true } + } + return false + } + + /// True when the advertisement carries the `A00A` service, 16-bit or 128-bit form. + private static func advertisesService(_ advertisement: AdvertisementInfo) -> Bool { + advertisement.serviceUUIDs.contains { uuid in + let value = uuid.uuidString.uppercased() + return value == "A00A" || value == "0000A00A-0000-1000-8000-00805F9B34FB" + } + } + + /// The baseline: what **every** RWfit ring's firmware serves regardless of framing — the + /// history streams both wire protocols define unconditionally, plus in-band battery. REM is in: + /// both sleep formats carry a REM stage (legacy type 3, JieLi model 4). + let capabilities: Set = [ + .heartRate, .spo2, .steps, .sleep, .remSleep, .battery, + ] + + /// Everything per-unit, granted only when the connected ring claims it: + /// - sensor streams from the legacy `0x03` feature bitmap / the JieLi bind-reply TLV + /// (temperature, BP, HRV, stress, blood sugar); + /// - the manual/realtime measurement set, granted by the driver on JieLi links — the vendor app + /// has no legacy on-demand measurement command at all, so a legacy link must not render + /// measure buttons that could only ever time out. + let bitmapGatedCapabilities: Set = [ + .temperature, .bloodPressure, .manualBloodPressure, + .hrv, .manualHrv, .stress, .bloodSugar, + .realtimeHeartRate, .manualHeartRate, .manualSpo2, + ] + + let iconSystemName = "circle.circle.fill" + + func makeDriver(writer: RingCommandWriter) -> WearableDriver { + RWfitDriver(writer: writer) + } +} diff --git a/PulseLoop/RingProtocol/RWfitDecoder.swift b/PulseLoop/RingProtocol/RWfitDecoder.swift new file mode 100644 index 00000000..75c4b271 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitDecoder.swift @@ -0,0 +1,413 @@ +import Foundation + +/// Pure byte → `RingDecodedEvent` parsers for both RWfit framings. Every layout is a port of the +/// vendor parser cited on it (`x5/b.java` unless noted); offsets are kept identical to the Java so +/// a fixture disagreement points straight at the source line. +/// +/// Legacy history payloads arrive as one or more **day blocks** concatenated; JieLi history payloads +/// carry the 3-byte command triple at [0..2] and fixed-stride items from offset 3. All multi-byte +/// fields are big-endian; timestamps are local wall-clock epochs converted through `RWfitClock`. +struct RWfitDecoder { + let clock: RWfitClock + + // MARK: - Legacy (0x7E family) + + func decodeLegacy(cmd: UInt8, payload: [UInt8]) -> [RingDecodedEvent] { + let bytes = payload + switch cmd { + case RWfitLegacyCommand.deviceInfo: + return decodeLegacyDeviceInfo(bytes) + case RWfitLegacyCommand.battery, 0x60: + // `[lowPower, powerStatus, power]` (`x5/b.java:3204`; 0x60 is the push variant). + guard bytes.count >= 3 else { return [.unknown(commandId: cmd, raw: Data(bytes))] } + return [.battery(percent: Int(bytes[2]))] + case RWfitLegacyCommand.bindStatus: + // `[bindStatus, bindType, userId UTF-16LE…]` (`c()` @1639). Diagnostic only. + guard bytes.count >= 2 else { return [.unknown(commandId: cmd, raw: Data(bytes))] } + return [.bind(action: bytes[0], state: bytes[1])] + case RWfitLegacyCommand.features: + return [.supportFunctions(Self.capabilities(fromLegacyFeatures: bytes))] + case RWfitLegacyCommand.syncManifest: + // Consumed by the history pager via the driver; nothing to persist. + return [.commandAck(commandId: cmd)] + default: + return decodeLegacyHistory(cmd: cmd, bytes: bytes) + ?? [.unknown(commandId: cmd, raw: Data(bytes))] + } + } + + /// The `0xA1`–`0xA7` history streams, split out of `decodeLegacy` so neither switch grows past + /// the project's cyclomatic-complexity limit. nil ⇒ not a history command. + private func decodeLegacyHistory(cmd: UInt8, bytes: [UInt8]) -> [RingDecodedEvent]? { + switch cmd { + case RWfitLegacyCommand.stepsHistory: + return decodeLegacySteps(bytes) + case RWfitLegacyCommand.sleepHistory: + return decodeLegacySleep(bytes) + case RWfitLegacyCommand.heartRateHistory: + return decodeLegacyValueSeries(bytes, cmd: cmd) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .heartRate, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitLegacyCommand.spo2History: + return decodeLegacyValueSeries(bytes, cmd: cmd) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .spo2, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitLegacyCommand.bloodPressureHistory: + // 6-byte items `[ts u32][systolic][diastolic]` (`s0()`); split so each trends alone. + return decodeLegacyValueSeries(bytes, cmd: cmd, itemStride: 6) { ts, item in + guard item[4] > 0, item[5] > 0 else { return [] } + return [ + .historyMeasurement(kind: .bloodPressureSystolic, value: Double(item[4]), timestamp: ts), + .historyMeasurement(kind: .bloodPressureDiastolic, value: Double(item[5]), timestamp: ts), + ] + } + case RWfitLegacyCommand.temperatureHistory: + // `(raw + 200) / 10` °C (`u0()`) — raw 0 would be 20.0 °C, so treat 0 as "no sample". + return decodeLegacyValueSeries(bytes, cmd: cmd) { ts, item in + item[4] > 0 + ? [.historyMeasurement(kind: .temperature, value: (Double(item[4]) + 200) / 10, timestamp: ts)] + : [] + } + case RWfitLegacyCommand.breatheHistory: + return decodeLegacyValueSeries(bytes, cmd: cmd) { ts, item in + item[4] > 0 + ? [.historyMeasurement(kind: .respiratoryRate, value: Double(item[4]), timestamp: ts)] + : [] + } + default: + return nil + } + } + + /// `[len, deviceClazz UTF-8, len2, deviceNo UTF-8]` (`o()` @2259). + private func decodeLegacyDeviceInfo(_ bytes: [UInt8]) -> [RingDecodedEvent] { + guard !bytes.isEmpty else { return [.unknown(commandId: RWfitLegacyCommand.deviceInfo, raw: Data())] } + let clazzLen = Int(bytes[0]) + guard bytes.count >= 1 + clazzLen + 1 else { + return [.unknown(commandId: RWfitLegacyCommand.deviceInfo, raw: Data(bytes))] + } + let clazz = String(bytes: bytes[1..<(1 + clazzLen)], encoding: .utf8) ?? "" + let noLen = Int(bytes[1 + clazzLen]) + let noStart = 2 + clazzLen + let deviceNo = bytes.count >= noStart + noLen + ? String(bytes: bytes[noStart..<(noStart + noLen)], encoding: .utf8) ?? "" + : "" + let version = [clazz, deviceNo].filter { !$0.isEmpty }.joined(separator: " ") + return version.isEmpty + ? [.commandAck(commandId: RWfitLegacyCommand.deviceInfo)] + : [.firmware(version: version)] + } + + /// Legacy day-series template: `[ts u32][count u16]` + `count` fixed-stride items whose first + /// four bytes are the item's own timestamp (`w0()`/`r0()`/`s0()`/`u0()`/`t0()`). + private func decodeLegacyValueSeries( + _ bytes: [UInt8], + cmd: UInt8, + itemStride: Int = 5, + item decodeItem: (Date, [UInt8]) -> [RingDecodedEvent] + ) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + var offset = 0 + while offset + 6 <= bytes.count { + let count = RWfitBytes.u16BE(bytes, offset + 4) + offset += 6 + for _ in 0.. [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + var offset = 0 + while offset + 15 <= bytes.count { + let dayStart = clock.date(fromLegacyEpoch: RWfitBytes.u32BE(bytes, offset)) + let steps = RWfitBytes.u24BE(bytes, offset + 4) + let distance = Double(RWfitBytes.u24BE(bytes, offset + 10)) + let count = RWfitBytes.u16BE(bytes, offset + 13) + offset += 15 + count * 8 + if steps > 0 { + events.append(.activityBucket(timestamp: dayStart, steps: steps, distanceMeters: distance)) + } + } + return events.isEmpty ? [.commandAck(commandId: RWfitLegacyCommand.stepsHistory)] : events + } + + /// Sleep night blocks (`A0()` @180): 16-byte header `[night ts u32][totalMin u16] + /// [asleep epoch u32][awake epoch u32][count u16]` + `count` × 2-byte items `[minutes][type]`, + /// type 0 = awake, 1 = light, 2 = deep, 3 = REM (`service/s1.java:1635`). Items run + /// consecutively from the asleep epoch; expanded to per-minute stages for `.sleepTimeline`. + private func decodeLegacySleep(_ bytes: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + var offset = 0 + while offset + 16 <= bytes.count { + let asleep = clock.date(fromLegacyEpoch: RWfitBytes.u32BE(bytes, offset + 6)) + let count = RWfitBytes.u16BE(bytes, offset + 14) + offset += 16 + var stages: [SleepStage] = [] + for _ in 0.. SleepStage { + switch type { + case 0: return .awake + case 1: return .light + case 2: return .deep + case 3: return .rem + default: return .unknown + } + } + + /// Legacy `0x03` SupportMenuBean bitmap, byte 0 LSB-first: step, sleep, hr, bloodPress, + /// bloodOxy, bodyTemp, ecg, breathe (`x5/b.java:1872`). Only the bits that gate a + /// `WearableCapability` are mapped; the baseline metrics don't need their bits. + static func capabilities(fromLegacyFeatures bytes: [UInt8]) -> Set { + guard !bytes.isEmpty else { return [] } + var caps: Set = [] + if bytes[0] & (1 << 3) != 0 { caps.insert(.bloodPressure) } + if bytes[0] & (1 << 5) != 0 { caps.insert(.temperature) } + return caps + } + + // MARK: - JieLi (0xAB family) + + /// `payload` includes the `{CMD, Key, KeyFlag}` triple at [0..2] (vendor parsers start at 3). + func decodeJieli(triple: RWfitJLTriple, payload: [UInt8]) -> [RingDecodedEvent] { + switch (triple.cmd, triple.key) { + case (0x02, 0x03): + // `[3]` = percent, `[4..5]` = millivolts (`G()`). + guard payload.count >= 4 else { return [.unknown(commandId: triple.cmd, raw: Data(payload))] } + return [.battery(percent: Int(payload[3]))] + case (0x02, 0x04): + // `[3..5]` = firmware version triplet (`C()` @332). + guard payload.count >= 6 else { return [.unknown(commandId: triple.cmd, raw: Data(payload))] } + return [.firmware(version: payload[3...5].map(String.init).joined(separator: "."))] + case (0x02, 0x01): + return [.timeSyncAck(timestamp: Date())] + case (0x03, 0x01): + return decodeJieliBind(payload) + case (0x05, _): + return decodeJieliHistory(key: triple.key, payload: payload) + ?? [.unknown(commandId: triple.cmd, raw: Data(payload))] + case (0x06, 0x09): + return decodeJieliRealtime(payload) + default: + return [.unknown(commandId: triple.cmd, raw: Data(payload))] + } + } + + /// The `05`-group history streams, split out of `decodeJieli` so neither switch grows past the + /// project's cyclomatic-complexity limit. nil ⇒ a `05` type we don't decode. + private func decodeJieliHistory(key: UInt8, payload: [UInt8]) -> [RingDecodedEvent]? { + switch key { + case RWfitJLDataType.steps: + return decodeJieliSteps(payload) + case RWfitJLDataType.sleep: + return decodeJieliSleep(payload) + case RWfitJLDataType.heartRate: + return decodeJieliSeries(payload, cmd: key) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .heartRate, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitJLDataType.spo2: + return decodeJieliSeries(payload, cmd: key) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .spo2, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitJLDataType.bloodPressure: + // `[4]` systolic, `[5]` diastolic (`T()`). + return decodeJieliSeries(payload, cmd: key) { ts, item in + guard item[4] > 0, item[5] > 0 else { return [] } + return [ + .historyMeasurement(kind: .bloodPressureSystolic, value: Double(item[4]), timestamp: ts), + .historyMeasurement(kind: .bloodPressureDiastolic, value: Double(item[5]), timestamp: ts), + ] + } + case RWfitJLDataType.temperature: + // `[4..5]` u16 BE ÷ 10 °C (`U()`). + return decodeJieliSeries(payload, cmd: key) { ts, item in + let raw = RWfitBytes.u16BE(item, 4) + return raw > 0 + ? [.historyMeasurement(kind: .temperature, value: Double(raw) / 10, timestamp: ts)] + : [] + } + case RWfitJLDataType.hrv: + return decodeJieliSeries(payload, cmd: key) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .hrv, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitJLDataType.stress: + return decodeJieliSeries(payload, cmd: key) { ts, item in + item[4] > 0 ? [.historyMeasurement(kind: .stress, value: Double(item[4]), timestamp: ts)] : [] + } + case RWfitJLDataType.bloodSugar: + // `[4..5]` u16 BE ÷ 10 = mmol/L (`R()`); converted to the mg/dL the app displays. + return decodeJieliSeries(payload, cmd: key) { ts, item in + let mmol = Double(RWfitBytes.u16BE(item, 4)) / 10 + return mmol > 0 + ? [.historyMeasurement(kind: .bloodSugar, value: mmol * 18.016, timestamp: ts)] + : [] + } + default: + return nil + } + } + + /// Bind-status reply (`u()` @2636): `[3]` = bindStatus; from offset 8, a NUL-terminated run of + /// `(0x05, type)` pairs advertising which `05`-group streams the ring supports — the JieLi + /// family's capability bitmap. + private func decodeJieliBind(_ payload: [UInt8]) -> [RingDecodedEvent] { + guard payload.count >= 4 else { return [.unknown(commandId: 0x03, raw: Data(payload))] } + var events: [RingDecodedEvent] = [.bind(action: payload[3], state: 0)] + if payload.count > 8 { + events.append(.supportFunctions(Self.capabilities(fromJieliBindTLV: Array(payload.dropFirst(8))))) + } + return events + } + + /// Map the bind reply's `(0x05, type)` pairs onto gated capabilities. Scanning stops at the + /// first NUL, like the vendor (`u()` counts NULs and only reads pairs before the first). + /// The vendor's own decompile skips type 8 (temperature) — treated here as an R8 artifact and + /// mapped anyway; a wrong grant renders one empty card, a missed one hides a real sensor. + static func capabilities(fromJieliBindTLV tlv: [UInt8]) -> Set { + var caps: Set = [] + var index = 0 + while index + 1 < tlv.count, tlv[index] != 0 { + if tlv[index] == 0x05 { + switch tlv[index + 1] { + case RWfitJLDataType.bloodPressure: caps.formUnion([.bloodPressure, .manualBloodPressure]) + case RWfitJLDataType.temperature: caps.insert(.temperature) + case RWfitJLDataType.hrv: caps.formUnion([.hrv, .manualHrv]) + case RWfitJLDataType.stress: caps.insert(.stress) + case RWfitJLDataType.bloodSugar: caps.insert(.bloodSugar) + default: break + } + } + index += 2 + } + return caps + } + + /// JieLi 6-byte-stride series template: items from offset 3, `[ts2000 u32][value][…]` + /// (`V()`/`S()`/`T()`/`U()`/`W()`/`Y()`/`R()`). + private func decodeJieliSeries( + _ payload: [UInt8], + cmd: UInt8, + item decodeItem: (Date, [UInt8]) -> [RingDecodedEvent] + ) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + var offset = 3 + while offset + 6 <= payload.count { + let item = Array(payload[offset..<(offset + 6)]) + let timestamp = clock.date(fromJieliEpoch: RWfitBytes.u32BE(item, 0)) + events.append(contentsOf: decodeItem(timestamp, item)) + offset += 6 + } + return events.isEmpty ? [.commandAck(commandId: cmd)] : events + } + + /// JieLi steps (`a0()` @1549): 16-byte records `[ts2000 u32][pad][steps u24][kcal×10 u32] + /// [distance u32]`. The vendor renders `distance / 10000` (km), so the raw unit is decimetres — + /// ÷10 for metres. One record per day slot; published as buckets so re-syncs upsert. + private func decodeJieliSteps(_ payload: [UInt8]) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + var offset = 3 + while offset + 16 <= payload.count { + let timestamp = clock.date(fromJieliEpoch: RWfitBytes.u32BE(payload, offset)) + let steps = RWfitBytes.u24BE(payload, offset + 5) + let distanceMeters = Double(RWfitBytes.u32BE(payload, offset + 12)) / 10 + offset += 16 + if steps > 0 { + events.append(.activityBucket(timestamp: timestamp, steps: steps, distanceMeters: distanceMeters)) + } + } + return events.isEmpty ? [.commandAck(commandId: RWfitJLDataType.steps)] : events + } + + /// JieLi sleep (`Z()` @1520 + reconstruction in `service/s1.java:1004`): 7-byte records + /// `[ts2000 u32][sleepModel][pad2]` forming a **stage-transition stream**: `0x11` opens a + /// session (its first segment counts as light sleep), `0x22` closes it, and 1/2/3-or-0/4 mark + /// deep/light/awake/REM segments whose lengths are the gaps between consecutive records. + private func decodeJieliSleep(_ payload: [UInt8]) -> [RingDecodedEvent] { + var records: [(timestamp: Date, model: UInt8)] = [] + var offset = 3 + while offset + 7 <= payload.count { + records.append(( + timestamp: clock.date(fromJieliEpoch: RWfitBytes.u32BE(payload, offset)), + model: payload[offset + 4] + )) + offset += 7 + } + + var events: [RingDecodedEvent] = [] + var sessionStart: Date? + var stages: [SleepStage] = [] + for (index, record) in records.enumerated() { + if record.model == 0x11 { + sessionStart = record.timestamp + stages = [] + } + guard let start = sessionStart else { continue } + if record.model == 0x22 { + if !stages.isEmpty { + events.append(.sleepTimeline(timestamp: start, stages: stages)) + } + sessionStart = nil + stages = [] + continue + } + // Segment length = gap to the next record (`s1.java`'s consecutive-delta division). + guard index + 1 < records.count else { continue } + let minutes = Int(records[index + 1].timestamp.timeIntervalSince(record.timestamp) / 60) + guard minutes > 0, minutes < 24 * 60 else { continue } + stages.append(contentsOf: Array(repeating: Self.jieliSleepStage(record.model), count: minutes)) + } + return events.isEmpty ? [.commandAck(commandId: RWfitJLDataType.sleep)] : events + } + + private static func jieliSleepStage(_ model: UInt8) -> SleepStage { + switch model { + case 1: return .deep + case 2: return .light + case 3, 0: return .awake + case 4: return .rem + case 0x11: return .light // session-start marker doubles as the first light segment + default: return .unknown + } + } + + /// Realtime-measure reply (`x5/b.java:3734`, internal id 31): `[3]` echoes the measurement type, + /// `[5]` carries the reading **minus 10** (the vendor displays `data[5] + 10`; presumably a + /// transport offset so 0 can mean "measuring"). Zero → still measuring, surfaced as an ack. + private func decodeJieliRealtime(_ payload: [UInt8]) -> [RingDecodedEvent] { + guard payload.count > 5, payload[5] > 0 else { return [.commandAck(commandId: 0x06)] } + let value = Int(payload[5]) + 10 + let now = Date() + switch payload[3] { + case RWfitJLDataType.heartRate: return [.heartRateSample(bpm: value, timestamp: now)] + case RWfitJLDataType.spo2: return [.spo2Result(value: value, timestamp: now)] + case RWfitJLDataType.hrv: return [.hrvSample(value: value, timestamp: now)] + case RWfitJLDataType.stress: return [.stressSample(value: value, timestamp: now)] + default: return [.unknown(commandId: 0x06, raw: Data(payload))] + } + } +} diff --git a/PulseLoop/RingProtocol/RWfitDriver.swift b/PulseLoop/RingProtocol/RWfitDriver.swift new file mode 100644 index 00000000..1086fef8 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitDriver.swift @@ -0,0 +1,173 @@ +import Foundation +@preconcurrency import CoreBluetooth + +/// RWfit driver. One GATT — service `A00A`, write `B002`, notify `B003` — but **two wire framings**, +/// and which one this ring speaks is only knowable from the sibling services it exposes: +/// JieLi `AE00` / Telink OTA / PixArt `FF00` present ⇒ JieLi `0xAB` framing; none ⇒ legacy `0x7E` +/// (the vendor's `onServicesDiscovered`, `r5/b.java:684-740`). `servicesDiscovered` makes that call +/// before any characteristic I/O, so framing is fixed before the first outbound frame. +/// +/// **Framing is identity** — the command gate frames logical commands itself (it owns the serial +/// counter the device-ACK matching needs), and outbound protocol ACKs are built pre-framed by the +/// codecs. **Inbound is ACK-before-decode**: both firmwares retransmit a device-initiated frame +/// until the app answers, so the ACK is enqueued before decoding can slow anything down (the +/// LuckRing discipline; vendor equivalent in `x5/d.java h()` / `r5/b.java`). +@MainActor +final class RWfitDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private weak var writer: RingCommandWriter? + private let legacyCodec = RWfitLegacyCodec() + private let jlCodec = RWfitJLCodec() + private let clock = RWfitClock() + private let decoder: RWfitDecoder + private let gate: RWfitCommandGate + /// The history pager. Driver-owned because only the driver sees frames (`noteReceived`); handed + /// to the engine so `runStartup`/`syncHistory` can seed passes. + private let historySync: RWfitHistorySync + + /// The wire framing of the current link. Defaults to `.legacy` (the harmless direction — see + /// `RWfitFraming`) until `servicesDiscovered` decides. + private(set) var framing: RWfitFraming = .legacy + + /// Everything this unit has claimed so far — framing-implied realtime commands plus whatever the + /// feature reply / bind TLV granted. Grows monotonically; each growth is re-published whole + /// because `RingBLEClient.applySupportFunctions` recomputes from the latest set (last write + /// wins, so partial announcements would drop earlier grants). + private var derivedCapabilities: Set = [] + /// Whether this link has already folded the framing-implied capabilities in. + private var framingCapabilitiesAnnounced = false + + /// The on-demand measurement commands are JieLi-only — the vendor app has no legacy sender for + /// them — so a JieLi link grants the manual/realtime set the coordinator pre-approved. + static let jieliRealtimeCapabilities: Set = [ + .realtimeHeartRate, .manualHeartRate, .manualSpo2, + ] + + init(writer: RingCommandWriter) { + self.writer = writer + self.decoder = RWfitDecoder(clock: clock) + self.gate = RWfitCommandGate(writer: writer, legacyCodec: legacyCodec, jlCodec: jlCodec) + self.historySync = RWfitHistorySync(gate: gate) + } + + // MARK: - BLE topology + + let serviceUUIDs: [CBUUID] = [CBUUID(string: RWfitUUIDs.service)] + let writeUUID = CBUUID(string: RWfitUUIDs.write) + let notifyUUIDs: [CBUUID] = [CBUUID(string: RWfitUUIDs.notify)] + let batteryServiceUUID: CBUUID? = nil // battery is in-band (legacy 0x01 / JieLi 02 03 10) + let batteryCharUUID: CBUUID? = nil + /// Single notify channel; declaring it documents that nothing may fire before B003 notifies. + var requiredSubscriptionsBeforeConnected: [CBUUID] { notifyUUIDs } + + /// Identity — the command gate and codecs emit fully framed packets. + func frame(_ command: Data) -> Data { command } + + // MARK: - Framing selection + + func servicesDiscovered(_ services: [CBUUID]) { + let jieliMarkers = [RWfitUUIDs.jieli, RWfitUUIDs.telinkOTA, RWfitUUIDs.pixartOTA] + .map { CBUUID(string: $0) } + framing = services.contains(where: jieliMarkers.contains) ? .jieli : .legacy + gate.framing = framing + historySync.framing = framing + } + + // MARK: - Lifecycle + + /// Auto-reconnect reuses this driver: stale reassembly would corrupt the new link's first + /// frames, and a half-run history pass would mis-bucket its types. Framing is re-decided by the + /// fresh discovery pass; derived capabilities persist (a unit's sensors don't change between + /// links) but the framing grant is re-folded per link in case the firmware changed shape. + func connectionDidStart() { + legacyCodec.reset() + jlCodec.reset() + gate.cancel() + historySync.cancel() + framingCapabilitiesAnnounced = false + } + + /// The pager's settle/stall timers and the gate's retry timer must not refill the write queue + /// across the reconnect gap. + func connectionDidEnd() { + legacyCodec.reset() + jlCodec.reset() + gate.cancel() + historySync.cancel() + } + + // MARK: - Inbound + + func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { + var events = framing == .jieli ? ingestJieli(data) : ingestLegacy(data) + + // Fold the framing-implied capabilities in once per link, piggybacked on the first inbound + // frame — the earliest moment an event can flow up to `applySupportFunctions`. + if framing == .jieli, !framingCapabilitiesAnnounced { + framingCapabilitiesAnnounced = true + derivedCapabilities.formUnion(Self.jieliRealtimeCapabilities) + events.append(.supportFunctions(derivedCapabilities)) + } + return events + } + + private func ingestLegacy(_ data: Data) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for inbound in legacyCodec.decode(data) { + switch inbound { + case let .ackNeeded(cmd, serial): + // ACK before decode — the ring retransmits until we answer. + writer?.enqueue(legacyCodec.ack(cmd: cmd, serial: serial, status: 0x00)) + case let .checksumFailed(cmd, serial): + // NACK (status 2) asks the device to retransmit the frame (`x5/d.java h()`). + writer?.enqueue(legacyCodec.ack(cmd: cmd, serial: serial, status: 0x02)) + case let .deviceAck(cmd, serial, _): + gate.noteLegacyAck(cmd: cmd, serial: serial) + case let .frame(cmd, payload): + if let type = RWfitHistoryType(legacyCommand: cmd) { + historySync.noteReceived(type: type) + } + events.append(contentsOf: intercept(decoder.decodeLegacy(cmd: cmd, payload: payload))) + } + } + return events + } + + private func ingestJieli(_ data: Data) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + for inbound in jlCodec.decode(data) { + switch inbound { + case let .deviceAck(triple): + gate.noteJieliAck(triple: triple) + case .crcFailed: + break // the vendor drops silently; the ring retransmits on its own + case let .frame(triple, payload): + // ACK before decode (flag 0x11, triple echoed — `r5/b.java`). + writer?.enqueue(jlCodec.ack(triple: triple)) + if triple.cmd == 0x05, let type = RWfitHistoryType(jlType: triple.key) { + historySync.noteReceived(type: type) + } + events.append(contentsOf: intercept(decoder.decodeJieli(triple: triple, payload: payload))) + } + } + return events + } + + /// Fold any decoder capability grant into the cumulative set before it goes up — the client's + /// refinement recomputes from whatever set it last saw, so every announcement must be the whole + /// truth so far, not just this frame's contribution. + private func intercept(_ decoded: [RingDecodedEvent]) -> [RingDecodedEvent] { + decoded.map { event in + guard case let .supportFunctions(granted) = event else { return event } + derivedCapabilities.formUnion(granted) + return .supportFunctions(derivedCapabilities) + } + } + + func makeSyncEngine() -> RingSyncEngine { + RWfitSyncEngine(gate: gate, historySync: historySync, clock: clock, framingProvider: { [weak self] in + self?.framing ?? .legacy + }) + } +} diff --git a/PulseLoop/RingProtocol/RWfitEncoder.swift b/PulseLoop/RingProtocol/RWfitEncoder.swift new file mode 100644 index 00000000..c91b57f1 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitEncoder.swift @@ -0,0 +1,189 @@ +import Foundation + +/// One logical outbound command, before wire framing. The command gate frames it with whichever +/// codec the connection's framing selected — the encoder below emits the right variant for the +/// active framing, so a caller never handles both. +enum RWfitOutbound: Equatable { + case legacy(cmd: UInt8, payload: [UInt8]) + case jieli(payload: [UInt8]) // payload includes the {CMD, Key, KeyFlag} triple +} + +/// Logical command builders for both RWfit framings behind one API. Each method mirrors the vendor +/// builder cited on it (`p.java` = `…/mlkit_vision_common/p.java`, the R8-relocated `CmdHelper`). +/// +/// Stateless — the framing is passed per call (the driver owns it, and it can change between +/// connects if a user swaps rings). +struct RWfitEncoder { + /// PulseLoop's bind identity. The vendor binds with its account's numeric user id; the ring just + /// stores and echoes it. UTF-16LE on the wire (`y5/b.java m()`). + static let bindUserID = "PL" + + // MARK: - Clock + + /// Set the ring's RTC from **local** calendar components — both firmwares stamp history off this + /// clock, so it leads the startup sequence. Legacy: `[yearBE(2), mo, d, h, m, s]` (`p.java u()`); + /// JieLi: `{02 01 00, year-2000, mo, d, h, m, s}` (`p.java v()`). + func setTime(framing: RWfitFraming, components: DateComponents) -> RWfitOutbound { + let year = components.year ?? 2000 + let fields = [ + UInt8(components.month ?? 1), UInt8(components.day ?? 1), + UInt8(components.hour ?? 0), UInt8(components.minute ?? 0), UInt8(components.second ?? 0), + ] + switch framing { + case .legacy: + let yearBytes = RWfitBytes.packU16BE(year) + return .legacy(cmd: RWfitLegacyCommand.setTime, payload: yearBytes + fields) + case .jieli: + return .jieli(payload: RWfitJLTriple.setTime.bytes + [UInt8(clamping: year - 2000)] + fields) + } + } + + // MARK: - Reads + + func deviceInfo(framing: RWfitFraming) -> RWfitOutbound { + request(framing, legacy: RWfitLegacyCommand.deviceInfo, jl: .deviceInfo) + } + + func battery(framing: RWfitFraming) -> RWfitOutbound { + request(framing, legacy: RWfitLegacyCommand.battery, jl: .battery) + } + + /// Capability discovery. Legacy: the `0x03` SupportMenuBean bitmap. JieLi: the bind-status + /// reply's trailing TLV carries the same information, so the request is the same `03 01 00`. + func features(framing: RWfitFraming) -> RWfitOutbound { + request(framing, legacy: RWfitLegacyCommand.features, jl: .bindStatus) + } + + func bindStatus(framing: RWfitFraming) -> RWfitOutbound { + request(framing, legacy: RWfitLegacyCommand.bindStatus, jl: .bindStatus) + } + + // MARK: - Bind / unbind + + /// Claim the ring. Legacy: `[bindType, userId UTF-16LE…]` (`p.java s()`); JieLi: `03 01 20` + + /// the userId's UTF-16LE bytes right-aligned into 4 (`p.java t()`). + func bind(framing: RWfitFraming) -> RWfitOutbound { + let userID = Array(Self.bindUserID.data(using: .utf16LittleEndian) ?? Data()) + switch framing { + case .legacy: + return .legacy(cmd: RWfitLegacyCommand.bind, payload: [0x01] + userID) + case .jieli: + var id: [UInt8] = [0, 0, 0, 0] + let tail = userID.suffix(4) + id.replaceSubrange((4 - tail.count)..<4, with: tail) + return .jieli(payload: RWfitJLTriple.bind.bytes + id) + } + } + + /// Release the ring on Forget. Legacy: `0x44`, empty (`h0.java:319`); JieLi: `03 01 30 00` + /// (`p.java X()`). + func unbind(framing: RWfitFraming) -> RWfitOutbound { + switch framing { + case .legacy: return .legacy(cmd: RWfitLegacyCommand.unbind, payload: []) + case .jieli: return .jieli(payload: RWfitJLTriple.unbind.bytes + [0x00]) + } + } + + // MARK: - Profile / units / goal + + /// Push the user profile. Legacy `0x2E`: `[gender(1=male), age, heightBE u16, weight×10 BE u16, + /// goalBE u16, nickname UTF-16LE…]` (`p.java x()`). JieLi `02 06 00`: `[unit, gender, age, + /// height float LE(4), weight float LE(4)]` (`p.java Q()` — the two IEEE-754 floats are the one + /// little-endian field in the whole protocol). + func userProfile( + framing: RWfitFraming, + profile: UserProfileValues, + goalSteps: Int + ) -> RWfitOutbound { + // RWfit gender byte: 1 = male, 0 = everyone else (the vendor has no third value). + let gender: UInt8 = profile.gender == 0x01 ? 1 : 0 + switch framing { + case .legacy: + var payload: [UInt8] = [gender, profile.age] + payload += RWfitBytes.packU16BE(Int(profile.heightCm)) + payload += RWfitBytes.packU16BE(Int(profile.weightKg) * 10) + payload += RWfitBytes.packU16BE(goalSteps) + payload += Array("PulseLoop".data(using: .utf16LittleEndian) ?? Data()) + return .legacy(cmd: RWfitLegacyCommand.profile, payload: payload) + case .jieli: + var payload = RWfitJLTriple.profile.bytes + payload.append(profile.metric ? 0 : 1) + payload.append(gender) + payload.append(profile.age) + payload += floatLE(Float(profile.heightCm)) + payload += floatLE(Float(profile.weightKg)) + return .jieli(payload: payload) + } + } + + /// Language / units. Legacy `0x24`: `[lang, measureUnit, tempUnit, timeFont]` (`p.java P()`); + /// JieLi `02 11 00 ` (`p.java w()`). 0 = metric/Celsius, English. + func units(framing: RWfitFraming, metric: Bool) -> RWfitOutbound { + let unit: UInt8 = metric ? 0 : 1 + switch framing { + case .legacy: + return .legacy(cmd: RWfitLegacyCommand.units, payload: [0x00, unit, unit, 0x00]) + case .jieli: + return .jieli(payload: RWfitJLTriple.units.bytes + [unit]) + } + } + + /// Daily step goal. Legacy has no standalone goal command — it rides the profile (`p.java x()`), + /// so the legacy variant re-sends the profile. JieLi: `02 07 00` + u32 BE (`p.java`, line 194). + func goal( + framing: RWfitFraming, + steps: Int, + profile: UserProfileValues + ) -> RWfitOutbound { + switch framing { + case .legacy: + return userProfile(framing: .legacy, profile: profile, goalSteps: steps) + case .jieli: + return .jieli(payload: RWfitJLTriple.goal.bytes + RWfitBytes.packU32BE(steps)) + } + } + + // MARK: - History + + /// Request one history stream. Both framings: empty-bodied requests (`blesdk/service/l.java`, + /// `y.java`) — the ring replies with everything it holds for the type. + /// Returns nil when the active framing has no such stream. + func historyRequest(framing: RWfitFraming, type: RWfitHistoryType) -> RWfitOutbound? { + switch framing { + case .legacy: + guard let cmd = type.legacyCommand else { return nil } + return .legacy(cmd: cmd, payload: []) + case .jieli: + guard let jlType = type.jlType else { return nil } + return .jieli(payload: RWfitJLTriple.historySync(type: jlType).bytes) + } + } + + /// The legacy "what do you have" manifest (`0xA0`, `u1.java:444`). Legacy-only. + func syncManifest() -> RWfitOutbound { + .legacy(cmd: RWfitLegacyCommand.syncManifest, payload: []) + } + + // MARK: - Realtime measurement (JieLi-only) + + /// Start/stop an on-demand measurement: `06 09 00 05 ` (`u0.java n()` et al.). + /// The vendor app never sends a legacy equivalent — no legacy builder for internal id `0x1F` + /// exists — so on legacy links the caller must not offer these (capability-gated). + func realtimeMeasure(type: UInt8, on: Bool) -> RWfitOutbound { + .jieli(payload: RWfitJLTriple.realtimeMeasure.bytes + [type, 0x05, on ? 0x01 : 0x00]) + } + + // MARK: - Helpers + + private func request(_ framing: RWfitFraming, legacy: UInt8, jl: RWfitJLTriple) -> RWfitOutbound { + switch framing { + case .legacy: return .legacy(cmd: legacy, payload: []) + case .jieli: return .jieli(payload: jl.bytes) + } + } + + /// IEEE-754 float, little-endian byte order (`p.java Q()` reverses the big-endian array). + private func floatLE(_ value: Float) -> [UInt8] { + withUnsafeBytes(of: value.bitPattern.littleEndian) { Array($0) } + } +} diff --git a/PulseLoop/RingProtocol/RWfitHistorySync.swift b/PulseLoop/RingProtocol/RWfitHistorySync.swift new file mode 100644 index 00000000..aaa7fc1d --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitHistorySync.swift @@ -0,0 +1,136 @@ +import Foundation + +/// The RWfit history pager — the `LuckRingHistorySync` pattern on a two-framing family: request one +/// type, advance when its reply frames settle, skip it if nothing ever arrives. Types the active +/// framing doesn't speak (legacy has no HRV/stress/blood-sugar stream; JieLi has no breathe) are +/// skipped for free by the encoder returning nil. +/// +/// Replays are safe: persistence upserts history by `(kind, timestamp)`, activity by bucket +/// timestamp, sleep by night. The vendor's delete-acks (`05 xx 30`) — which erase synced records +/// from the ring — are deliberately never sent: PulseLoop's idempotent upserts don't need them, and +/// leaving the log intact lets the user's original app keep working alongside ours. +@MainActor +final class RWfitHistorySync { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// Full catalog, in request order (the vendor's own sync order: activity first, vitals after — + /// `blesdk/service/l.java` / `y.java`). Unsupported-per-framing types drop out at request time. + static let catalog: [RWfitHistoryType] = [ + .steps, .sleep, .heartRate, .bloodPressure, .spo2, .temperature, .breathe, + .hrv, .stress, .bloodSugar, + ] + + /// Post-workout backfill subset — only the logs a session can have added to. + static let vitalsTypes: [RWfitHistoryType] = [.heartRate, .spo2] + + private let encoder = RWfitEncoder() + private let gate: RWfitCommandGate + /// 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 of the in-flight type; firing means the type has settled. + private let settleSeconds: TimeInterval + /// Fires when a type produces nothing at all (unsupported / empty) — skip it. + private let stallSeconds: TimeInterval + + /// Set by the driver at service discovery, with the gate's. + var framing: RWfitFraming = .legacy + + private var queue: [RWfitHistoryType] = [] + private var currentType: RWfitHistoryType? + private var settleTask: Task? + private var stallTask: Task? + + init( + gate: RWfitCommandGate, + settleSeconds: TimeInterval = 1.5, + stallSeconds: TimeInterval = 6, + progressSink: ((PulseEvent) -> Void)? = nil + ) { + self.gate = gate + self.settleSeconds = settleSeconds + self.stallSeconds = stallSeconds + self.progressSink = progressSink + } + + 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. + func start(types: [RWfitHistoryType]) { + 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 history data frame. A frame for the in-flight type + /// re-arms the settle window; anything else is ignored (late frames from a skipped type). + func noteReceived(type: RWfitHistoryType) { + guard let currentType, type == currentType else { return } + stallTask?.cancel(); stallTask = nil + armSettle() + } + + // MARK: - Driving the queue + + private func advance() { + cancelTimers() + // Skip past types the active framing has no stream for. + var request: RWfitOutbound? + var type: RWfitHistoryType? + while request == nil, !queue.isEmpty { + let candidate = queue.removeFirst() + request = encoder.historyRequest(framing: framing, type: candidate) + type = candidate + } + guard let request, let type else { + currentType = nil + publish(.syncProgress(stage: "done")) + return + } + currentType = type + publish(.syncProgress(stage: "Syncing \(type.label)…")) + gate.submit(request) + armStall() + } + + 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 + } +} diff --git a/PulseLoop/RingProtocol/RWfitJLCodec.swift b/PulseLoop/RingProtocol/RWfitJLCodec.swift new file mode 100644 index 00000000..f43287bc --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitJLCodec.swift @@ -0,0 +1,99 @@ +import Foundation + +/// One deframed JieLi (`0xAB`) event, as surfaced to `RWfitDriver.ingest`. +enum RWfitJLInbound: Equatable { + /// A complete device-initiated frame (flag `0x01`), CRC-verified. `payload` **includes** the + /// 3-byte `{CMD, Key, KeyFlag}` triple at [0..2] — kept that way so decoder offsets match the + /// vendor parsers (`x5/b.java`, which all start reading items at offset 3). + case frame(triple: RWfitJLTriple, payload: [UInt8]) + /// The device ACKed one of our commands (flag `0x11`, triple echoed). Releases the command gate. + case deviceAck(triple: RWfitJLTriple) + /// A completed frame failed its CRC. The vendor drops these without a NACK (`r5/b.java`). + case crcFailed +} + +/// JieLi (`0xAB`) wire codec: framing, CRC-16/ARC, ACKs, and inbound continuation reassembly. +/// Byte-for-byte port of the encoder in `x5/c.java g()` and the inline decoder in +/// `r5/b.java onCharacteristicChanged`. +/// +/// Header (6 bytes): `AB flag lenHi lenLo crcHi crcLo`, followed by the payload — whose first three +/// bytes are the `{CMD, Key, KeyFlag}` triple and count toward both `len` and the CRC. A payload +/// longer than one notification continues in **headerless** packets: raw payload bytes until `len` +/// have arrived. +@MainActor +final class RWfitJLCodec { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// In-flight reassembly of one logical frame (the protocol interleaves nothing — continuations + /// immediately follow their header packet, `r5/b.java`'s single `x5.a` state struct). + private var pendingFlag: UInt8 = 0 + private var pendingCRC: UInt16 = 0 + private var pendingLength = 0 + private var buffer: [UInt8] = [] + private var reassembling = false + + /// Discard any half-assembled frame. Call on connect/disconnect. + func reset() { + reassembling = false + buffer.removeAll() + pendingLength = 0 + } + + // MARK: - Encode + + /// Frame one payload (triple + data). `isAck: true` sets the reply flag `0x11` — used only for + /// the ACKs we owe the device; everything else goes out as a request (`0x01`). + func encode(payload: [UInt8], isAck: Bool = false) -> Data { + var frame: [UInt8] = [0xab, isAck ? 0x11 : 0x01] + frame.append(contentsOf: RWfitBytes.packU16BE(payload.count)) + let crc = RWfitBytes.crc16ARC(payload) + frame.append(UInt8(crc >> 8)) + frame.append(UInt8(crc & 0xff)) + frame.append(contentsOf: payload) + return Data(frame) + } + + /// Build the app→device ACK for an inbound frame: flag `0x11`, payload = the echoed triple — + /// with one quirk: the realtime-measure reply (`CMD 06, Key 09`) is ACKed with a fourth `0x00` + /// byte (`r5/b.java`'s `if (b3 == 6 && b10 == 9)` special case). + func ack(triple: RWfitJLTriple) -> Data { + var payload = triple.bytes + if triple.cmd == 0x06, triple.key == 0x09 { + payload.append(0x00) + } + return encode(payload: payload, isAck: true) + } + + // MARK: - Decode + + /// Feed one notification. Returns the events completed by it (usually none mid-reassembly). + func decode(_ data: Data) -> [RWfitJLInbound] { + let bytes = [UInt8](data) + guard !bytes.isEmpty else { return [] } + + if !reassembling { + // Expecting a header packet. Anything without the magic is noise (e.g. a legacy frame on + // a mis-detected link) — the vendor logs and drops it; so do we. + guard bytes.count >= 6, bytes[0] == 0xab else { return [] } + pendingFlag = bytes[1] + pendingLength = RWfitBytes.u16BE(bytes, 2) + pendingCRC = UInt16(bytes[4]) << 8 | UInt16(bytes[5]) + buffer = Array(bytes.dropFirst(6)) + reassembling = true + } else { + // Headerless continuation: raw payload bytes (`r5/b.java`'s multi-packet branch). + buffer.append(contentsOf: bytes) + } + + guard buffer.count >= pendingLength else { return [] } + let payload = Array(buffer.prefix(pendingLength)) + let flag = pendingFlag + let expectedCRC = pendingCRC + reset() + + guard payload.count >= 3 else { return [] } + guard RWfitBytes.crc16ARC(payload) == expectedCRC else { return [.crcFailed] } + let triple = RWfitJLTriple(cmd: payload[0], key: payload[1], keyFlag: payload[2]) + return flag == 0x11 ? [.deviceAck(triple: triple)] : [.frame(triple: triple, payload: payload)] + } +} diff --git a/PulseLoop/RingProtocol/RWfitLegacyCodec.swift b/PulseLoop/RingProtocol/RWfitLegacyCodec.swift new file mode 100644 index 00000000..79f4a7d6 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitLegacyCodec.swift @@ -0,0 +1,123 @@ +import Foundation + +/// One deframed legacy (`0x7E`) event, as surfaced to `RWfitDriver.ingest`. +enum RWfitLegacyInbound: Equatable { + /// A complete data frame (single-packet, or a fully reassembled multi-packet payload). The driver + /// must app-ACK it (`ackNeeded` accompanies it) and decode it. + case frame(cmd: UInt8, payload: [UInt8]) + /// The device ACKed one of our commands: `0xFE` with `[serHi, serLo, cmd, status]` + /// (`x5/d.java i()`). Releases the command gate. + case deviceAck(cmd: UInt8, serial: Int, status: UInt8) + /// A frame (or one chunk of a multi-packet frame) arrived and must be app-ACKed with the given + /// serial — emitted *before* the corresponding `.frame`, mirroring the vendor's ACK-before-parse + /// order (`x5/d.java h()`). + case ackNeeded(cmd: UInt8, serial: Int) + /// A frame failed its XOR checksum; NACK it (status `0x02`) so the device retransmits. + case checksumFailed(cmd: UInt8, serial: Int) +} + +/// Legacy (`0x7E` / "Realtek") wire codec: framing, serials, XOR checksums, ACK frames, and inbound +/// multi-packet reassembly. Byte-for-byte port of `x5/d.java` (`CmdHandlerUtils`). +/// +/// Header (single-packet, 8 bytes): `7E 01 cmd flags dataLen serHi serLo xor`. Multi-packet sets +/// flag bit 3 and inserts `totalBE(2) currentBE(2)` at [8..11] (current is 1-based); each chunk +/// carries its own dataLen and XOR. Serials run 1…65535 and wrap. +@MainActor +final class RWfitLegacyCodec { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// Outbound serial counter (`x5/d.java` `this.e`, incremented per queued command). + private var serial: Int = 0 + /// In-flight inbound multi-packet reassembly, keyed by cmd id (`x5/d.java` `f19809c`). + private var partials: [UInt8: [(index: Int, chunk: [UInt8])]] = [:] + + /// Reset all cross-frame state. Call on connect/disconnect — a chunk left from a dropped link + /// must never complete a frame on the next one. + func reset() { + partials.removeAll() + // Serials deliberately keep counting: the vendor never resets them mid-session, and a fresh + // link accepts any serial (it is an echo token, not a sequence check). + } + + private func nextSerial() -> Int { + serial = serial >= 65535 ? 1 : serial + 1 + return serial + } + + // MARK: - Encode + + /// Frame one logical command, returning the serial it was stamped with so the command gate can + /// match the device's `0xFE` ACK against it. All PulseLoop commands fit a single packet (dataLen + /// is one byte and our largest payload — the bind userId — is well under 255); the vendor only + /// multi-packets file transfers, which we don't do. + func encode(cmd: UInt8, payload: [UInt8]) -> (frame: Data, serial: Int) { + precondition(payload.count <= 0xff, "legacy payload exceeds single-frame capacity") + let serial = nextSerial() + var frame: [UInt8] = [0x7e, 0x01, cmd, 0x00, UInt8(payload.count)] + frame.append(contentsOf: RWfitBytes.packU16BE(serial)) + frame.append(payload.isEmpty ? 0x00 : RWfitBytes.xorChecksum(payload)) + frame.append(contentsOf: payload) + return (Data(frame), serial) + } + + /// Build the app→device ACK for an inbound frame: cmd `0xFF`, payload `[serHi, serLo, cmd, + /// status]` where the serial is the *inbound* frame's (`x5/d.java b()`). Status 0 = OK, + /// 2 = checksum failure (asks the device to retransmit). The ACK frame's own header serial is + /// freshly assigned, exactly as the vendor's `j((byte) -1, …)` path does. + func ack(cmd: UInt8, serial inboundSerial: Int, status: UInt8) -> Data { + let ser = RWfitBytes.packU16BE(inboundSerial) + return encode(cmd: RWfitLegacyCommand.appAck, payload: [ser[0], ser[1], cmd, status]).frame + } + + // MARK: - Decode + + /// Deframe one notification. Returns every event it produced (ACK requests first, then frames). + func decode(_ data: Data) -> [RWfitLegacyInbound] { + let bytes = [UInt8](data) + guard bytes.count >= 8, bytes[0] == 0x7e else { return [] } + + let cmd = bytes[2] + let isMultiPacket = (bytes[3] >> 3) & 1 == 1 + let dataLen = Int(bytes[4]) + let serial = RWfitBytes.u16BE(bytes, 5) + let checksum = bytes[7] + + if !isMultiPacket || bytes.count <= 9 { + guard bytes.count >= 8 + dataLen else { return [] } + let payload = Array(bytes[8..<(8 + dataLen)]) + if dataLen > 0, checksum != RWfitBytes.xorChecksum(payload) { + return [.checksumFailed(cmd: cmd, serial: serial)] + } + if cmd == RWfitLegacyCommand.deviceAck { + guard payload.count >= 4 else { return [] } + return [.deviceAck(cmd: payload[2], serial: RWfitBytes.u16BE(payload, 0), status: payload[3])] + } + return [.ackNeeded(cmd: cmd, serial: serial), .frame(cmd: cmd, payload: payload)] + } + + // Multi-packet: `totalBE currentBE` at [8..11], chunk at [12...]. Each chunk is ACKed on its + // own and buffered; the combined payload is surfaced when all chunks are in + // (`x5/d.java h()`, multi-packet branch — including its sort by current index). + guard bytes.count >= 12 + dataLen else { return [] } + let total = RWfitBytes.u16BE(bytes, 8) + let current = RWfitBytes.u16BE(bytes, 10) + let chunk = Array(bytes[12..<(12 + dataLen)]) + if dataLen > 0, checksum != RWfitBytes.xorChecksum(chunk) { + return [.checksumFailed(cmd: cmd, serial: serial)] + } + + var events: [RWfitLegacyInbound] = [] + if cmd != RWfitLegacyCommand.deviceAck { + events.append(.ackNeeded(cmd: cmd, serial: serial)) + } + var collected = partials[cmd] ?? [] + collected.append((index: current, chunk: chunk)) + partials[cmd] = collected + if current == total, collected.count == total { + let payload = collected.sorted { $0.index < $1.index }.flatMap(\.chunk) + partials[cmd] = nil + events.append(.frame(cmd: cmd, payload: payload)) + } + return events + } +} diff --git a/PulseLoop/RingProtocol/RWfitProtocol.swift b/PulseLoop/RingProtocol/RWfitProtocol.swift new file mode 100644 index 00000000..98c7a429 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitProtocol.swift @@ -0,0 +1,285 @@ +import Foundation + +/// Shared vocabulary for the RWfit family (`com.rw.revivalfit` vendor app). Every byte layout in the +/// `RWfit*` files is reconstructed from that app's decompiled source; each constant cites the file it +/// was read from (paths relative to `rwfit-official/sources/`). +/// +/// One GATT, two wire framings: +/// - **Legacy `0x7E`** ("Realtek"): XOR checksum, per-frame serials, mandatory `0xFE`/`0xFF` ACK +/// handshake (`x5/d.java`). +/// - **JieLi `0xAB`**: CRC-16/ARC, `{CMD, Key, KeyFlag}` triple addressing, flag-0x11 ACKs +/// (`x5/c.java`, decode inline in `r5/b.java`). +/// +/// Which one a ring speaks is decided *after* connect from the sibling services it exposes +/// (`r5/b.java onServicesDiscovered`): JieLi `AE00`, Telink OTA or PixArt `FF00` present ⇒ JieLi +/// framing; none of them ⇒ legacy. The advertisement carries no such signal, which is why the whole +/// family is one `RingDeviceType` and the driver owns the decision (`RWfitDriver.servicesDiscovered`). +enum RWfitUUIDs { + /// Primary data service, both framings (`y5/a.java f19994a`). + static let service = "0000a00a-0000-1000-8000-00805f9b34fb" + /// Command write characteristic (`f19995b`). Accepts write with or without response; the vendor + /// app uses the characteristic default, so `RingBLEClient`'s property-driven pick is correct. + static let write = "0000b002-0000-1000-8000-00805f9b34fb" + /// Notify characteristic — responses and device-initiated pushes (`f19996c`). + static let notify = "0000b003-0000-1000-8000-00805f9b34fb" + + // Framing discriminators — never subscribed, only *seen* at service discovery. + /// JieLi platform service (`y5/a.java e`). Presence ⇒ JieLi framing. + static let jieli = "0000ae00-0000-1000-8000-00805f9b34fb" + /// Telink OTA service (`f20000h`). Presence ⇒ JieLi framing (`r5/b.java:703-727`). + static let telinkOTA = "00010203-0405-0607-0809-0a0b0c0d1912" + /// PixArt OTA service (`f19998f`). Presence ⇒ JieLi framing. + static let pixartOTA = "0000ff00-0000-1000-8000-00805f9b34fb" +} + +/// The two wire framings served by `RWfitDriver`. `.legacy` is the safe default when the discovery +/// hook never fires — a legacy frame sent to a JieLi ring is ignored (wrong magic), while the reverse +/// would also be ignored; legacy is the more common firmware in the vendor's install base. +enum RWfitFraming: String, Sendable { + case legacy + case jieli +} + +/// Legacy (`0x7E`) command ids actually used by PulseLoop. Full table in `x5/b.java a()`. +enum RWfitLegacyCommand { + static let deviceInfo: UInt8 = 0x00 + static let battery: UInt8 = 0x01 + static let bindStatus: UInt8 = 0x02 + /// Supported-features bitmap (SupportMenuBean, `x5/b.java:1872`) — capability discovery. + static let features: UInt8 = 0x03 + static let bind: UInt8 = 0x20 + static let setTime: UInt8 = 0x21 + /// `[lang, measureUnit, tempUnit, timeFont]` (`p.java P()`). + static let units: UInt8 = 0x24 + /// `[gender, age, heightBE u16, weight×10 BE u16, goalBE u16, nickname UTF-16LE…]` (`p.java x()`). + static let profile: UInt8 = 0x2e + /// Unbind on Forget — empty payload (`h0.java:319`, `u1.java:520`). + static let unbind: UInt8 = 0x44 + /// Health-sync manifest: which history types the ring holds (`x5/b.java v0()`). + static let syncManifest: UInt8 = 0xa0 + static let stepsHistory: UInt8 = 0xa1 + static let sleepHistory: UInt8 = 0xa2 + static let heartRateHistory: UInt8 = 0xa3 + static let bloodPressureHistory: UInt8 = 0xa4 + static let spo2History: UInt8 = 0xa5 + static let temperatureHistory: UInt8 = 0xa6 + static let breatheHistory: UInt8 = 0xa7 + /// Device→app ACK of our command: payload `[serHi, serLo, cmd, status]` (`x5/d.java i()`). + static let deviceAck: UInt8 = 0xfe + /// App→device ACK of a device frame: same payload, sent as its own framed command + /// (`x5/d.java b()` — `j((byte) -1, …)`). + static let appAck: UInt8 = 0xff +} + +/// A JieLi `{CMD, Key, KeyFlag}` command triple — the first three payload bytes of every `0xAB` +/// frame, in both directions (`x5/a.java`, `y5/c.java`). `KeyFlag` convention: `0x00` set, +/// `0x10` get/sync, `0x20`/`0x30` variants (bind-with-id / unbind). +struct RWfitJLTriple: Equatable, Sendable { + let cmd: UInt8 + let key: UInt8 + let keyFlag: UInt8 + + var bytes: [UInt8] { [cmd, key, keyFlag] } + + // The triples PulseLoop speaks (`y5/c.java`, senders in `p.java` / `blesdk/service/y.java`). + static let setTime = RWfitJLTriple(cmd: 0x02, key: 0x01, keyFlag: 0x00) + static let battery = RWfitJLTriple(cmd: 0x02, key: 0x03, keyFlag: 0x10) + static let deviceInfo = RWfitJLTriple(cmd: 0x02, key: 0x04, keyFlag: 0x10) + static let profile = RWfitJLTriple(cmd: 0x02, key: 0x06, keyFlag: 0x00) + static let goal = RWfitJLTriple(cmd: 0x02, key: 0x07, keyFlag: 0x00) + static let units = RWfitJLTriple(cmd: 0x02, key: 0x11, keyFlag: 0x00) + static let bindStatus = RWfitJLTriple(cmd: 0x03, key: 0x01, keyFlag: 0x00) + static let bind = RWfitJLTriple(cmd: 0x03, key: 0x01, keyFlag: 0x20) + static let unbind = RWfitJLTriple(cmd: 0x03, key: 0x01, keyFlag: 0x30) + /// `06 09 00 05 ` — unified realtime-measurement toggle (`u0.java n()`). + static let realtimeMeasure = RWfitJLTriple(cmd: 0x06, key: 0x09, keyFlag: 0x00) + + /// History sync request for one data type: `05 10` (`blesdk/service/y.java`). + static func historySync(type: UInt8) -> RWfitJLTriple { + RWfitJLTriple(cmd: 0x05, key: type, keyFlag: 0x10) + } +} + +/// JieLi `05`-group data-type bytes — used both in history-sync triples and as the `` byte of +/// the realtime-measure command (`y5/c.java`, `u0/k/n/g/r` presenters). +enum RWfitJLDataType { + static let steps: UInt8 = 0x02 + static let heartRate: UInt8 = 0x03 + static let bloodPressure: UInt8 = 0x04 + static let sleep: UInt8 = 0x05 + static let temperature: UInt8 = 0x08 + static let spo2: UInt8 = 0x09 + static let hrv: UInt8 = 0x0a + static let stress: UInt8 = 0x0d + static let bloodSugar: UInt8 = 0x10 +} + +/// One history stream, unified across the two framings so the pager and progress labels don't care +/// which wire it rides. +enum RWfitHistoryType: CaseIterable, Sendable { + case steps, sleep, heartRate, bloodPressure, spo2, temperature, breathe, hrv, stress, bloodSugar + + /// Legacy request command, or nil where the legacy protocol has no such stream + /// (`blesdk/service/l.java` — HRV/stress/blood-sugar are JieLi-only). + var legacyCommand: UInt8? { + switch self { + case .steps: return RWfitLegacyCommand.stepsHistory + case .sleep: return RWfitLegacyCommand.sleepHistory + case .heartRate: return RWfitLegacyCommand.heartRateHistory + case .bloodPressure: return RWfitLegacyCommand.bloodPressureHistory + case .spo2: return RWfitLegacyCommand.spo2History + case .temperature: return RWfitLegacyCommand.temperatureHistory + case .breathe: return RWfitLegacyCommand.breatheHistory + case .hrv, .stress, .bloodSugar: return nil + } + } + + /// JieLi `05`-group type byte, or nil where the JieLi protocol has no such stream + /// (breathe is legacy-only). + var jlType: UInt8? { + switch self { + case .steps: return RWfitJLDataType.steps + case .sleep: return RWfitJLDataType.sleep + case .heartRate: return RWfitJLDataType.heartRate + case .bloodPressure: return RWfitJLDataType.bloodPressure + case .spo2: return RWfitJLDataType.spo2 + case .temperature: return RWfitJLDataType.temperature + case .hrv: return RWfitJLDataType.hrv + case .stress: return RWfitJLDataType.stress + case .bloodSugar: return RWfitJLDataType.bloodSugar + case .breathe: return nil + } + } + + var label: String { + switch self { + case .steps: return "activity" + case .sleep: return "sleep" + case .heartRate: return "heart rate" + case .bloodPressure: return "blood pressure" + case .spo2: return "blood oxygen" + case .temperature: return "temperature" + case .breathe: return "respiration" + case .hrv: return "HRV" + case .stress: return "stress" + case .bloodSugar: return "blood sugar" + } + } + + /// The stream an inbound legacy history frame belongs to — for the pager's settle bookkeeping. + init?(legacyCommand: UInt8) { + guard let match = Self.allCases.first(where: { $0.legacyCommand == legacyCommand }) else { + return nil + } + self = match + } + + /// The stream an inbound JieLi `05`-group frame belongs to. + init?(jlType: UInt8) { + guard let match = Self.allCases.first(where: { $0.jlType == jlType }) else { return nil } + self = match + } +} + +/// The timezone offset the ring's RTC runs on — the RWfit twin of `JringClock`. +/// +/// Both framings stamp history records with **local wall-clock** epochs: the app sets the clock from +/// local calendar components (`p.java u()/v()`), and every vendor history parser subtracts a timezone +/// offset on the way in. The legacy epoch is Unix; the JieLi epoch counts from 2000-01-01 UTC +/// (`x5/b.java`, the `+ 946684800` in every JL parser). +/// +/// **Deliberate divergence from the vendor:** its legacy parsers subtract +/// `rawOffset + (zone-HAS-dst ? 1h : 0)` — an hour off for half the year in any DST zone — and its JL +/// parsers use the offset *now*, wrong for records that crossed a DST boundary. We latch +/// `secondsFromGMT(for: now)` when the clock is pushed (the same offset the ring will stamp with from +/// that moment), matching the `JringClock` contract: encoder and decoder always move together. +final class RWfitClock { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + /// Seconds between the JieLi epoch (2000-01-01 00:00:00 UTC) and the Unix epoch. + static let jieliEpochOffset: TimeInterval = 946_684_800 + + /// Seconds east of UTC, DST included, as latched at the last clock push. + private(set) var offsetSeconds: TimeInterval + + init(timeZone: TimeZone = .current, now: Date = Date()) { + offsetSeconds = TimeInterval(timeZone.secondsFromGMT(for: now)) + } + + /// Latch the offset that is about to go out in a set-time command. + func capture(timeZone: TimeZone = .current, now: Date = Date()) { + offsetSeconds = TimeInterval(timeZone.secondsFromGMT(for: now)) + } + + /// Convert a legacy-framing record epoch (local wall-clock Unix seconds) into a true `Date`. + func date(fromLegacyEpoch raw: UInt32) -> Date { + Date(timeIntervalSince1970: TimeInterval(raw) - offsetSeconds) + } + + /// Convert a JieLi-framing record epoch (local wall-clock seconds since 2000-01-01) into a `Date`. + func date(fromJieliEpoch raw: UInt32) -> Date { + Date(timeIntervalSince1970: TimeInterval(raw) + Self.jieliEpochOffset - offsetSeconds) + } + + /// The local calendar components a set-time command should carry right now. + func nowComponents(timeZone: TimeZone = .current, now: Date = Date()) -> DateComponents { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: now) + } +} + +/// Big-endian byte readers shared by the RWfit codecs and decoder. Every multi-byte field in both +/// framings is big-endian (`y5/b.java a()/d()/f()/j()`); the one little-endian exception (the JieLi +/// device-info screen size) is read explicitly at its use site. +enum RWfitBytes { + static func u16BE(_ bytes: [UInt8], _ offset: Int) -> Int { + guard bytes.count >= offset + 2 else { return 0 } + return Int(bytes[offset]) << 8 | Int(bytes[offset + 1]) + } + + static func u24BE(_ bytes: [UInt8], _ offset: Int) -> Int { + guard bytes.count >= offset + 3 else { return 0 } + return Int(bytes[offset]) << 16 | Int(bytes[offset + 1]) << 8 | Int(bytes[offset + 2]) + } + + static func u32BE(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + guard bytes.count >= offset + 4 else { return 0 } + return UInt32(bytes[offset]) << 24 + | UInt32(bytes[offset + 1]) << 16 + | UInt32(bytes[offset + 2]) << 8 + | UInt32(bytes[offset + 3]) + } + + static func packU16BE(_ value: Int) -> [UInt8] { + let clamped = UInt16(clamping: value) + return [UInt8(clamped >> 8), UInt8(clamped & 0xff)] + } + + static func packU32BE(_ value: Int) -> [UInt8] { + let clamped = UInt32(clamping: value) + return [ + UInt8((clamped >> 24) & 0xff), UInt8((clamped >> 16) & 0xff), + UInt8((clamped >> 8) & 0xff), UInt8(clamped & 0xff), + ] + } + + /// XOR of all payload bytes — the legacy framing's checksum (`y5/b.java o()`). + static func xorChecksum(_ bytes: some Sequence) -> UInt8 { + bytes.reduce(0, ^) + } + + /// CRC-16/ARC over the payload — the JieLi framing's checksum (`y5/d.java a()`, table at + /// `f20005a`): reflected poly `0xA001`, init `0x0000`, no final XOR. Table-free bitwise form — + /// identical output to the vendor's table (which is the standard ARC table). + static func crc16ARC(_ bytes: some Sequence) -> UInt16 { + var crc: UInt16 = 0 + for byte in bytes { + crc ^= UInt16(byte) + for _ in 0..<8 { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xa001 : crc >> 1 + } + } + return crc + } +} diff --git a/PulseLoop/RingProtocol/RWfitSyncEngine.swift b/PulseLoop/RingProtocol/RWfitSyncEngine.swift new file mode 100644 index 00000000..d8cd777f --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitSyncEngine.swift @@ -0,0 +1,139 @@ +import Foundation + +/// RWfit sync engine. Connect pushes the clock first (both firmwares stamp history off their RTC), +/// then identity/profile/config reads and writes, then the history catalog pass. History is **not** +/// driven from `handle(_:)` — the pager (driver-owned, the only thing that sees frames) advances +/// itself off the ring's data frames, so `handle` is a no-op (the LuckRing pattern). +/// +/// The framing is the driver's live decision, read through `framingProvider` at send time: the +/// engine is built at pairing, before service discovery has decided the framing, so a captured +/// value would freeze the default. +@MainActor +final class RWfitSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private let gate: RWfitCommandGate + private let historySync: RWfitHistorySync + private let clock: RWfitClock + private let framingProvider: () -> RWfitFraming + private let encoder = RWfitEncoder() + + /// Pushed in by `RingSyncCoordinator` before `runStartup`, so the handshake 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 + + /// Whether this app has ever bound an RWfit ring. The bind write stores our user id on the ring; + /// it only needs to happen once, and re-claiming on every connect would stomp a tester's + /// vendor-app binding harder than necessary. (Same single-flag tradeoff as the LuckRing engine.) + private static let pairFinishedKey = "rwfit.pairFinished" + + init( + gate: RWfitCommandGate, + historySync: RWfitHistorySync, + clock: RWfitClock, + framingProvider: @escaping () -> RWfitFraming + ) { + self.gate = gate + self.historySync = historySync + self.clock = clock + self.framingProvider = framingProvider + } + + private var framing: RWfitFraming { framingProvider() } + + // MARK: - Startup + + func runStartup() { + // Clock first: everything the ring logs from this moment is stamped against it. + clock.capture() + gate.submit(encoder.setTime(framing: framing, components: clock.nowComponents())) + + // Bind once (stores our id), then always read bind status — on JieLi the status reply's + // trailing TLV is the capability bitmap, so the read doubles as capability discovery. + let firstPair = !UserDefaults.standard.bool(forKey: Self.pairFinishedKey) + if firstPair { + gate.submit(encoder.bind(framing: framing)) + UserDefaults.standard.set(true, forKey: Self.pairFinishedKey) + } + gate.submit(encoder.bindStatus(framing: framing)) + + gate.submit(encoder.userProfile(framing: framing, profile: userProfile, goalSteps: goalSteps)) + gate.submit(encoder.units(framing: framing, metric: userProfile.metric)) + + gate.submit(encoder.deviceInfo(framing: framing)) + gate.submit(encoder.battery(framing: framing)) + if framing == .legacy { + // Legacy capability discovery is its own command (0x03 SupportMenuBean). + gate.submit(encoder.features(framing: .legacy)) + } + + historySync.start(types: RWfitHistorySync.catalog) + } + + /// History is pager-driven — nothing here advances it. + func handle(_ event: RingDecodedEvent) {} + + // MARK: - History passes (re-entering `start` is a no-op while a pass is in flight) + + func syncHistory() { + historySync.start(types: RWfitHistorySync.catalog) + } + + func syncVitalsHistory() { + historySync.start(types: RWfitHistorySync.vitalsTypes) + } + + // MARK: - Live actions (JieLi-only `06 09` toggles; capability-gated so legacy UIs never call) + + func startHeartRate() { submitRealtime(type: RWfitJLDataType.heartRate, on: true) } + func stopHeartRate() { submitRealtime(type: RWfitJLDataType.heartRate, on: false) } + func startSpO2() { submitRealtime(type: RWfitJLDataType.spo2, on: true) } + func stopSpO2() { submitRealtime(type: RWfitJLDataType.spo2, on: false) } + func startHRV() { submitRealtime(type: RWfitJLDataType.hrv, on: true) } + func stopHRV() { submitRealtime(type: RWfitJLDataType.hrv, on: false) } + func startBloodPressure() { submitRealtime(type: RWfitJLDataType.bloodPressure, on: true) } + func stopBloodPressure() { submitRealtime(type: RWfitJLDataType.bloodPressure, on: false) } + + /// The double gate: capability-gated UI shouldn't reach here on a legacy link, and if it does + /// anyway the command is dropped rather than sent as bytes the firmware never defined. + private func submitRealtime(type: UInt8, on: Bool) { + guard framing == .jieli else { return } + gate.submit(encoder.realtimeMeasure(type: type, on: on)) + } + + func findDevice() {} // no find-ring command located in the vendor source + + func setGoal(steps: Int) { + goalSteps = steps + gate.submit(encoder.goal(framing: framing, steps: steps, profile: userProfile)) + } + + // MARK: - Clock / battery / profile + + /// The ring stamps records from its own RTC — timezone and wall-clock changes must be re-pushed. + func resyncTime() { + clock.capture() + gate.submit(encoder.setTime(framing: framing, components: clock.nowComponents())) + } + + func requestBattery() { + gate.submit(encoder.battery(framing: framing)) + } + + func setUserProfile(_ profile: UserProfileValues) { userProfile = profile } + + func applyUserProfile(_ profile: UserProfileValues) { + userProfile = profile + gate.submit(encoder.userProfile(framing: framing, profile: profile, goalSteps: goalSteps)) + } + + // MARK: - Teardown + + /// Release the ring on Forget (legacy 0x44 / JieLi `03 01 30 00`) and forget the bind latch so + /// a future re-pair claims it again. + func unbind() { + gate.submit(encoder.unbind(framing: framing)) + UserDefaults.standard.set(false, forKey: Self.pairFinishedKey) + } +} diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 7a2c3be4..fdec5464 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -47,6 +47,10 @@ final class RingBLEClient: NSObject { ColmiCoordinator.self, LuckRingCoordinator.self, TK5Coordinator.self, + // Last is the zero-risk slot: RWfit matches only family-exclusive signals (the `A00A` + // service; company IDs `0x05D6`/`0x06D6`) that no coordinator above claims, and it matches + // no names at all, so it can neither shadow nor be shadowed. + RWfitCoordinator.self, ] /// Which coordinator serves a connection. Pure, so the pairing rules are testable without a @@ -785,6 +789,9 @@ extension RingBLEClient: CBPeripheralDelegate { nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { MainActor.assumeIsolated { guard let driver = activeDriver else { return } + // Full service list first, before any characteristic I/O: the RWfit driver picks its wire + // framing off which sibling services exist (see `WearableDriver.servicesDiscovered`). + driver.servicesDiscovered((peripheral.services ?? []).map(\.uuid)) for service in peripheral.services ?? [] { if driver.serviceUUIDs.contains(service.uuid) { var chars = driver.notifyUUIDs diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 07d7b1bd..b9fb0f47 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -532,9 +532,11 @@ enum DebugRepository { } /// Cap the raw-packet debug table to its most recent `maxRows` rows, deleting older ones. - /// `RawPacketRow` is a DEBUG-only byte trace that otherwise grows without bound (one row per - /// BLE packet); this keeps it a rolling window so it can't bloat the store or slow the Debug - /// feed. Does NOT save — the caller batches the save with its own writes. + /// `RawPacketRow` is a byte trace (DEBUG builds always; release only under the Privacy & Data + /// capture opt-in) that otherwise grows without bound (one row per BLE packet); this keeps it a + /// rolling window so it can't bloat the store or slow the Debug feed. `maxRows: 0` is the + /// "clear captured packets" action. Does NOT save — the caller batches the save with its own + /// writes. @MainActor static func pruneRawPackets(maxRows: Int, context: ModelContext) { var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.timestamp, order: .reverse)]) diff --git a/PulseLoop/Views/Settings/DeviceHeroCard.swift b/PulseLoop/Views/Settings/DeviceHeroCard.swift index fe3a5535..a4941cc7 100644 --- a/PulseLoop/Views/Settings/DeviceHeroCard.swift +++ b/PulseLoop/Views/Settings/DeviceHeroCard.swift @@ -243,6 +243,8 @@ struct DeviceHeroCard: View { // is the family's representative — an uncatalogued YCBT ring is far more likely to be one of // these than anything else. case .ycbt: return "r10m" + // No RWfit hardware captured yet, so no product art — the generic ring is the honest choice. + case .rwfit: return nil case nil: return nil } } diff --git a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift index 8c90221f..1c577288 100644 --- a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift +++ b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift @@ -14,6 +14,8 @@ struct PrivacyDataSettingsView: View { @Environment(\.modelContext) private var modelContext @Environment(RingBLEClient.self) private var ble @State private var diagnosticsURL: URL? + /// Mirrors `RawPacketCapture.userOptedIn` so the toggle re-renders; the defaults key is the truth. + @State private var captureRawPackets = RawPacketCapture.userOptedIn /// Which destructive App-data action is awaiting confirmation. @State private var pendingReset: ResetAction? @@ -83,11 +85,37 @@ struct PrivacyDataSettingsView: View { SettingsGroup( header: "Diagnostics", - footer: "A local snapshot for troubleshooting — nothing leaves the device unless you share it." + footer: "A local snapshot for troubleshooting — nothing leaves the device unless you share it. " + + "Bluetooth capture stores the raw packets exchanged with your ring (which encode your " + + "health readings) and includes them in exports; leave it off unless support asked for it." ) { actionRow("Export diagnostics", systemImage: "square.and.arrow.up") { diagnosticsURL = DiagnosticsExporter.exportFile(context: modelContext) } + HStack(spacing: 12) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(PulseFont.body) + .foregroundStyle(PulseColors.textPrimary) + .frame(width: 24) + Toggle(isOn: Binding( + get: { captureRawPackets }, + set: { enabled in + captureRawPackets = enabled + RawPacketCapture.userOptedIn = enabled + } + )) { + Text("Capture Bluetooth diagnostics") + .font(PulseFont.body) + .foregroundStyle(PulseColors.textPrimary) + } + .tint(PulseColors.accent) + } + .padding(.horizontal, 16) + .frame(minHeight: 50) + actionRow("Clear captured packets", systemImage: "trash") { + DebugRepository.pruneRawPackets(maxRows: 0, context: modelContext) + try? modelContext.save() + } } SettingsGroup( diff --git a/PulseLoop/Wearables/WearableCoordinator.swift b/PulseLoop/Wearables/WearableCoordinator.swift index 13d18cce..985ff82e 100644 --- a/PulseLoop/Wearables/WearableCoordinator.swift +++ b/PulseLoop/Wearables/WearableCoordinator.swift @@ -20,6 +20,12 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { /// and `.colmiSmartHealth` (so it shares the whole `YCBT*` stack), kept a separate family because its /// capability set, product art and firmware quirks are its own. See `YCBTCoordinator`. case ycbt + /// RWfit rings (the `com.rw.revivalfit` vendor app; often rebranded — the known unit was sold as + /// a "Colmi" but shares nothing with the Colmi protocol). One family covers both of the vendor's + /// wire framings — legacy `0x7E` and JieLi `0xAB` — because they share the `A00A` GATT and the + /// advertisement cannot tell them apart; the driver picks the framing after service discovery. + /// See `RWfitCoordinator`. + case rwfit /// Human-facing default name when no advertised name is available. var displayName: String { @@ -30,6 +36,7 @@ enum RingDeviceType: String, Codable, CaseIterable, Sendable { case .colmiSmartHealth: return "Colmi ring (SmartHealth)" case .luckRing: return "LuckRing" case .ycbt: return "YCBT / SmartHealth ring" + case .rwfit: return "RWfit ring" } } } diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 1c5a4060..da74ae8f 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -79,6 +79,17 @@ protocol WearableDriver: AnyObject { /// The stateful brain: startup sequence + (for Colmi) the response-driven history machine. func makeSyncEngine() -> RingSyncEngine + + /// Called once per GATT link, immediately after service discovery and before any characteristic + /// I/O, with every service UUID the peripheral exposes — including ones outside `serviceUUIDs`. + /// + /// Exists for the one family whose *wire framing* cannot be known before connect: RWfit rings all + /// share the `A00A`/`B002`/`B003` GATT but speak two different framings, distinguished only by + /// which sibling services (JieLi `AE00`, Telink/PixArt OTA) the firmware exposes. The vendor app + /// makes the same decision in `onServicesDiscovered`. Runs before notify subscription — and so + /// before `.connected`, `immediatePostSubscriptionCommands()` and `runStartup()` — which + /// guarantees framing is fixed before the first outbound frame. Default: no-op. + func servicesDiscovered(_ services: [CBUUID]) } extension WearableDriver { @@ -91,6 +102,8 @@ extension WearableDriver { /// starts notifying. var requiredSubscriptionsBeforeConnected: [CBUUID] { [] } func immediatePostSubscriptionCommands() -> [Data] { [] } + /// Only a driver whose framing depends on the discovered GATT (RWfit) cares. + func servicesDiscovered(_ services: [CBUUID]) {} } /// User-chosen all-day measurement configuration, passed as a plain value from the app layer into a diff --git a/PulseLoop/Wearables/WearableModel.swift b/PulseLoop/Wearables/WearableModel.swift index 23131aff..01afd42f 100644 --- a/PulseLoop/Wearables/WearableModel.swift +++ b/PulseLoop/Wearables/WearableModel.swift @@ -53,7 +53,9 @@ enum RingAppVariant: String, CaseIterable, Identifiable, Sendable { switch family { case .colmiR02: self = .qring case .colmiSmartHealth: self = .smartHealth - case .jring, .tk5, .luckRing, .ycbt: return nil + // RWfit's two firmwares differ in *wire framing*, not app — one family, and the driver + // detects the framing from the GATT, so there is nothing for the user to declare. + case .jring, .tk5, .luckRing, .ycbt, .rwfit: return nil } } @@ -117,6 +119,9 @@ extension RingDeviceType { // Validated end-to-end on an R10M FCF4 running firmware 2.32 — pairing, handshake, reconnect, // activity and history sync, HR/SpO₂/BP, battery, sleep stages and REM. case .ycbt: return .full + // Reconstructed entirely from the vendor app's decompiled source, no hardware seen yet — + // every layout is cited in docs/hardware/rwfit.md and awaits the first diagnostics capture. + case .rwfit: return .limited } } } @@ -203,6 +208,18 @@ extension WearableModel { advertisedNamePatterns: ["^TK18([ _-].*)?$"], imageName: "luckring-tk18" ) + /// RWfit rings (the `com.rw.revivalfit` app) — sold under assorted brands; the known unit was + /// bought as a "Colmi", which is why the blurb names the app, not a brand. `advertisedNamePatterns` + /// is **deliberately empty**: no RWfit hardware has been captured yet, so any pattern would be a + /// guess, and the coordinator recognizes these rings by service/manufacturer data alone — the + /// pattern list is user-facing identity only, and it gets filled in from the first diagnostics + /// export. No `imageName`: `RingArtView`'s generic fallback is the honest choice until then. + static let rwfitRing = WearableModel( + id: "rwfit-ring", displayName: "RWfit ring", brand: "RWfit", family: .rwfit, + tint: PulseColors.spo2, blurb: "HR · SpO₂ · Sleep · Steps — works with RWfit-app rings", + advertisedNamePatterns: [] + ) + // 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}$") @@ -335,6 +352,8 @@ extension WearableModel { r10m, tk5, luckRingTK18, + // Position is irrelevant for matching — the RWfit card has no name patterns to race. + rwfitRing, ] static func model(id: String?) -> WearableModel? { diff --git a/PulseLoopTests/CapabilityGatingTests.swift b/PulseLoopTests/CapabilityGatingTests.swift index a837830c..b8052144 100644 --- a/PulseLoopTests/CapabilityGatingTests.swift +++ b/PulseLoopTests/CapabilityGatingTests.swift @@ -52,6 +52,33 @@ final class CapabilityGatingTests: XCTestCase { } } + /// The RWfit baseline is deliberately narrow — only what both wire framings serve + /// unconditionally — with every per-unit sensor *and* the whole on-demand measurement set + /// bitmap-gated: the manual/realtime commands are JieLi-only, so a legacy link must never + /// render measure buttons that could only time out. + func testRWfitBaselineIsNarrowAndRealtimeIsGated() { + let coordinator = RWfitCoordinator() + XCTAssertEqual(coordinator.capabilities, + [.heartRate, .spo2, .steps, .sleep, .remSleep, .battery]) + for cap: WearableCapability in [.realtimeHeartRate, .manualHeartRate, .manualSpo2, + .bloodPressure, .temperature, .hrv, .stress, .bloodSugar] { + XCTAssertFalse(coordinator.capabilities.contains(cap), cap.rawValue) + XCTAssertTrue(coordinator.bitmapGatedCapabilities.contains(cap), cap.rawValue) + } + } + + /// `refinedCapabilities` folds the JieLi framing grant + the ring's own TLV into the baseline, + /// and refuses anything the family didn't pre-approve. + func testRWfitRefinementAddsOnlyPreApprovedCapabilities() { + let coordinator = RWfitCoordinator() + let granted = RWfitDriver.jieliRealtimeCapabilities.union([.bloodPressure, .findDevice, .powerOff]) + let refined = coordinator.refinedCapabilities(bitmapDerived: granted) + XCTAssertTrue(refined.isSuperset(of: [.heartRate, .realtimeHeartRate, .manualHeartRate, + .manualSpo2, .bloodPressure])) + XCTAssertFalse(refined.contains(.findDevice), "not pre-approved — the bitmap cannot conjure it") + XCTAssertFalse(refined.contains(.powerOff)) + } + func testColmiShowsRichMetrics() throws { let context = try TestSupport.makeContext() let colmi = Device( diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index d049619c..4588fc98 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -677,6 +677,69 @@ final class PairingMatchingTests: XCTestCase { XCTAssertNil(RingAppVariant(family: .luckRing), "single-firmware family — no app picker") } + // MARK: - RWfit + + /// The vendor scanner's three manufacturer-data signatures (`r5/d.java`): company `0x05D6` + /// (little-endian `d6 05`) + `02 00`, the same company + ASCII "AT", and company `0x06D6` + /// ("T-Ring"). Payload tails synthesized — no RWfit hardware has been captured yet. + private var rwfitMfrAdvs: [AdvertisementInfo] { + ["d6050200a1b2c3d4e5f6", "d6054154a1b2c3d4e5f6", "d6060200a1b2c3d4e5f6"].map { + AdvertisementInfo(serviceUUIDs: [], manufacturerData: bytes($0)) + } + } + + func testRWfitClaimedByServiceUUIDInBothForms() { + for uuid in ["A00A", "0000A00A-0000-1000-8000-00805F9B34FB"] { + let adv = AdvertisementInfo(serviceUUIDs: [CBUUID(string: uuid)], manufacturerData: nil) + XCTAssertTrue(RWfitCoordinator.matches(name: nil, advertisement: adv), uuid) + XCTAssertEqual(RingBLEClient.matchDeviceType(name: nil, advertisement: adv), .rwfit, uuid) + } + } + + func testRWfitClaimedByEachManufacturerSignatureAndByNobodyElse() { + for adv in rwfitMfrAdvs { + let hex = adv.manufacturerData!.hexString + XCTAssertEqual(RingBLEClient.matchDeviceType(name: nil, advertisement: adv), .rwfit, hex) + // The full registry walk claiming `.rwfit` above already proves no earlier coordinator + // matched; spell the interesting neighbours out anyway so a matcher loosened later + // fails with a named culprit. + XCTAssertFalse(LuckRingCoordinator.matches(name: nil, advertisement: adv), hex) + XCTAssertFalse(ColmiSmartHealthCoordinator.matches(name: nil, advertisement: adv), hex) + XCTAssertFalse(TK5Coordinator.matches(name: nil, advertisement: adv), hex) + } + } + + func testRWfitNeverClaimsNamesAndNeverCrossClaims() { + // Name-blind by design: rebranders rename rings (the known unit was sold as a "Colmi"), + // so only the service/manufacturer signals may claim — and a bare name never does. + for name in ["RWfit Ring", "R09_00AA", "SMART_RING", "TK18", "Colmi R02"] { + XCTAssertFalse(RWfitCoordinator.matches(name: name, advertisement: noAdv), name) + } + // …and the other families' real advertisements stay theirs. + XCTAssertFalse(RWfitCoordinator.matches(name: nil, advertisement: luckRingAdv)) + XCTAssertFalse(RWfitCoordinator.matches(name: "TK5 24AA", advertisement: tk5Adv)) + XCTAssertFalse(RWfitCoordinator.matches(name: "R09_00AA", advertisement: qringAdv)) + XCTAssertFalse(RWfitCoordinator.matches(name: "R99 54DC", advertisement: smartHealthAdv)) + } + + /// Last is RWfit's documented registry slot — matching only family-exclusive signals and no + /// names, it can neither shadow nor be shadowed, and this pins a re-sort from moving it ahead + /// of coordinators whose matchers it has no need to precede. + func testRWfitIsRegisteredLast() { + XCTAssertEqual(RingBLEClient.coordinators.last?.deviceType, .rwfit) + } + + func testRWfitCardIsNameBlindAndLimited() { + XCTAssertTrue(WearableModel.rwfitRing.advertisedNamePatterns.isEmpty, + "no hardware captured yet — a guessed pattern would mislabel rings") + XCTAssertEqual(RingDeviceType.rwfit.supportLevel, .limited) + XCTAssertNil(RingAppVariant(family: .rwfit), + "the two RWfit framings are GATT-detected, not user-declared") + XCTAssertNil(WearableModel.rwfitRing.imageName, "nil takes the generic-art fallback path") + XCTAssertEqual(WearableModel.resolve(advertisedName: nil, selectedModelID: "rwfit-ring", family: .rwfit)?.id, + "rwfit-ring") + } + // MARK: - Support level func testSupportLevelIsPerFamily() { @@ -686,11 +749,14 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(RingDeviceType.colmiSmartHealth.supportLevel, .full) } - /// 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. + /// Only unproven families get a badge — the TK5 (never connected on hardware), the LuckRing + /// family (only the TK18 unit is proven), and RWfit (reconstructed from decompiled source, no + /// hardware seen yet). 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.luckRingTK18.id] + let limitedByDefault: Set = [ + WearableModel.tk5.id, WearableModel.luckRingTK18.id, WearableModel.rwfitRing.id, + ] for model in WearableModel.catalog { let expected: WearableSupportLevel = limitedByDefault.contains(model.id) ? .limited : .full XCTAssertEqual(model.supportLevel, expected, model.displayName) diff --git a/PulseLoopTests/RWfitDecoderTests.swift b/PulseLoopTests/RWfitDecoderTests.swift new file mode 100644 index 00000000..cf1ed276 --- /dev/null +++ b/PulseLoopTests/RWfitDecoderTests.swift @@ -0,0 +1,286 @@ +import XCTest +@testable import PulseLoop + +/// Record layouts for both RWfit framings, with fixtures assembled from the vendor parsers' +/// byte math (`x5/b.java`, functions cited per test). **Timezone conversion is the priority**: +/// both firmwares stamp local wall-clock epochs, and every fixture asserts the exact UTC `Date` +/// that comes back out. +@MainActor +final class RWfitDecoderTests: XCTestCase { + + /// Kolkata: +05:30, no DST — the offset is deterministic year-round, and the half-hour width + /// catches any whole-hour assumption in the conversion. + private let timeZone = TimeZone(identifier: "Asia/Kolkata")! + private let offset: UInt32 = 19_800 + + /// 2026-08-01 00:00:00 UTC. + private let utc = Date(timeIntervalSince1970: 1_785_542_400) + + private var decoder: RWfitDecoder { + RWfitDecoder(clock: RWfitClock(timeZone: timeZone, now: Date(timeIntervalSince1970: 1_785_542_400))) + } + + /// The ring-stamped legacy epoch for `utc` offset by `delta` seconds. + private func legacyEpoch(_ delta: UInt32 = 0) -> [UInt8] { + RWfitBytes.packU32BE(Int(1_785_542_400 + offset + delta)) + } + + /// The ring-stamped JieLi epoch (seconds since 2000-01-01, local) for `utc` + `delta`. + private func jieliEpoch(_ delta: UInt32 = 0) -> [UInt8] { + RWfitBytes.packU32BE(Int(1_785_542_400 - 946_684_800 + offset + delta)) + } + + private func cat(_ parts: [UInt8]...) -> [UInt8] { parts.flatMap { $0 } } + + // MARK: - Clock + + func testClockSubtractsCapturedOffsetForBothEpochs() { + let clock = RWfitClock(timeZone: timeZone, now: utc) + XCTAssertEqual(clock.date(fromLegacyEpoch: 1_785_542_400 + offset), utc) + XCTAssertEqual(clock.date(fromJieliEpoch: 1_785_542_400 - 946_684_800 + offset), utc) + } + + /// Deliberate divergence from the vendor's DST math: the captured offset is applied to *every* + /// record uniformly (the `JringClock` contract). The vendor's legacy path adds a fixed hour + /// whenever the zone merely *observes* DST — wrong half the year — and its JieLi path uses the + /// offset at parse time, wrong for records that crossed a boundary. Constant-offset is the + /// documented tradeoff; this test pins it. + func testClockUsesCaptureTimeOffsetNotRecordTimeOffset() { + let newYorkSummer = Date(timeIntervalSince1970: 1_785_542_400) // EDT, UTC-4 + let clock = RWfitClock(timeZone: TimeZone(identifier: "America/New_York")!, now: newYorkSummer) + XCTAssertEqual(clock.offsetSeconds, -14_400) + // A record six months out still gets the captured offset — no per-record re-evaluation. + let winterLocal = UInt32(1_800_000_000 - 14_400) + XCTAssertEqual(clock.date(fromLegacyEpoch: winterLocal), + Date(timeIntervalSince1970: 1_800_000_000)) + } + + // MARK: - Legacy history + + func testLegacyHeartRateDaySeries() { + // `w0()` @2914: day hdr `[ts u32][count u16]`, 5-byte items `[ts u32][bpm]`. + let payload = cat( + legacyEpoch(), [0x00, 0x03], + legacyEpoch(0), [72], + legacyEpoch(300), [0], // zero bpm = no sample; dropped + legacyEpoch(600), [95] + ) + let events = decoder.decodeLegacy(cmd: RWfitLegacyCommand.heartRateHistory, payload: payload) + XCTAssertEqual(events.count, 2) + guard case let .historyMeasurement(kind, value, timestamp) = events[0] else { + return XCTFail("expected historyMeasurement, got \(events)") + } + XCTAssertEqual(kind, .heartRate) + XCTAssertEqual(value, 72) + XCTAssertEqual(timestamp, utc, "local wall-clock epoch converted back to UTC") + guard case let .historyMeasurement(_, value2, timestamp2) = events[1] else { return XCTFail("unexpected event shape") } + XCTAssertEqual(value2, 95) + XCTAssertEqual(timestamp2, utc.addingTimeInterval(600)) + } + + func testLegacyBloodPressureSplitsIntoTwoKinds() { + // `s0()`: 6-byte items `[ts u32][systolic][diastolic]`. + let payload = cat(legacyEpoch(), [0x00, 0x01], legacyEpoch(60), [120, 80]) + let events = decoder.decodeLegacy(cmd: RWfitLegacyCommand.bloodPressureHistory, payload: payload) + guard events.count == 2, + case let .historyMeasurement(kind1, sys, ts1) = events[0], + case let .historyMeasurement(kind2, dia, ts2) = events[1] else { + return XCTFail("expected two split measurements, got \(events)") + } + XCTAssertEqual([kind1, kind2], [.bloodPressureSystolic, .bloodPressureDiastolic]) + XCTAssertEqual([sys, dia], [120, 80]) + XCTAssertEqual(ts1, ts2) + XCTAssertEqual(ts1, utc.addingTimeInterval(60)) + } + + func testLegacyTemperatureScaling() { + // `u0()`: `(raw + 200) / 10` °C. + let payload = cat(legacyEpoch(), [0x00, 0x01], legacyEpoch(), [165]) + let events = decoder.decodeLegacy(cmd: RWfitLegacyCommand.temperatureHistory, payload: payload) + guard case let .historyMeasurement(kind, value, _)? = events.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(kind, .temperature) + XCTAssertEqual(value, 36.5, accuracy: 0.001) + } + + func testLegacyStepsPublishesOneDayBucketFromHeaderTotals() { + // `C0()` @397: 15-byte hdr + 8-byte slot items. Slot wall-clock width is unproven, so the + // decoder publishes the header totals as one bucket at the day timestamp. + let payload = cat( + legacyEpoch(), [0x00, 0x1e, 0x0a], // 7690 steps (u24) + [0x00, 0x01, 0x2c], // 300 kcal (u24, unused) + [0x00, 0x14, 0x00], // 5120 distance (u24) + [0x00, 0x02], // 2 slot items follow + [1, 0x00, 0x64, 0, 0, 10, 0x00, 0x20], + [2, 0x00, 0xc8, 0, 0, 20, 0x00, 0x40] + ) + let events = decoder.decodeLegacy(cmd: RWfitLegacyCommand.stepsHistory, payload: payload) + guard case let .activityBucket(timestamp, steps, distance)? = events.first, events.count == 1 else { + return XCTFail("expected exactly one day bucket, got \(events)") + } + XCTAssertEqual(steps, 7_690) + XCTAssertEqual(distance, 5_120) + XCTAssertEqual(timestamp, utc) + } + + func testLegacySleepNightExpandsToPerMinuteStages() { + // `A0()` @180: 16-byte night hdr + 2-byte `[minutes][type]` items running from asleepTime; + // types 0 awake / 1 light / 2 deep / 3 REM (`service/s1.java:1635`). + let payload = cat( + legacyEpoch(), [0x01, 0x2c], // night ts, totalMin 300 (unused) + legacyEpoch(0), // asleep epoch + legacyEpoch(6_000), // awake epoch (unused) + [0x00, 0x04], + [30, 1], [45, 2], [10, 0], [15, 3] + ) + let events = decoder.decodeLegacy(cmd: RWfitLegacyCommand.sleepHistory, payload: payload) + guard case let .sleepTimeline(timestamp, stages)? = events.first else { + return XCTFail("expected sleepTimeline, got \(events)") + } + XCTAssertEqual(timestamp, utc, "the timeline starts at the asleep epoch") + XCTAssertEqual(stages.count, 100) + XCTAssertEqual(stages[0], .light) + XCTAssertEqual(stages[30], .deep) + XCTAssertEqual(stages[75], .awake) + XCTAssertEqual(stages[85], .rem) + XCTAssertEqual(stages[99], .rem) + } + + func testLegacyFeatureBitmapGatesCapabilities() { + // SupportMenuBean byte 0, LSB-first: bit 3 bloodPress, bit 5 bodyTemp (`x5/b.java:1872`). + XCTAssertEqual(RWfitDecoder.capabilities(fromLegacyFeatures: [0b0010_1000]), + [.bloodPressure, .temperature]) + XCTAssertEqual(RWfitDecoder.capabilities(fromLegacyFeatures: [0b0000_0111]), [], + "step/sleep/hr bits are baseline — they gate nothing") + } + + func testLegacyBatteryAndBind() { + let battery = decoder.decodeLegacy(cmd: RWfitLegacyCommand.battery, payload: [0, 1, 87]) + guard case let .battery(percent)? = battery.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(percent, 87) + + let bind = decoder.decodeLegacy(cmd: RWfitLegacyCommand.bindStatus, payload: [1, 2, 0x50, 0x00]) + guard case let .bind(action, state)? = bind.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(action, 1) + XCTAssertEqual(state, 2) + } + + // MARK: - JieLi history + + func testJieliHeartRateSeries() { + // `V()` @1291: 6-byte items from offset 3, `[ts2000 u32][bpm][pad]`; zero bpm dropped. + let payload = cat([0x05, 0x03, 0x10], jieliEpoch(), [64, 0], jieliEpoch(300), [0, 0]) + let events = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x05, key: 0x03, keyFlag: 0x10), payload: payload + ) + XCTAssertEqual(events.count, 1) + guard case let .historyMeasurement(kind, value, timestamp)? = events.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(kind, .heartRate) + XCTAssertEqual(value, 64) + XCTAssertEqual(timestamp, utc, "2000-epoch + local offset both unwound") + } + + func testJieliStepsRecord() { + // `a0()` @1549: 16-byte records; steps u24 at +5, distance u32 at +12 in decimetres. + let payload = cat( + [0x05, 0x02, 0x10], + jieliEpoch(), [0x00], + [0x00, 0x1e, 0x0a], // 7690 steps + [0x00, 0x00, 0x0b, 0xb8], // 3000 (kcal ×10, unused) + [0x00, 0x00, 0xc8, 0x00] // 51200 dm → 5120 m + ) + let events = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x05, key: 0x02, keyFlag: 0x10), payload: payload + ) + guard case let .activityBucket(timestamp, steps, distance)? = events.first else { + return XCTFail("expected activityBucket, got \(events)") + } + XCTAssertEqual(steps, 7_690) + XCTAssertEqual(distance, 5_120, accuracy: 0.001) + XCTAssertEqual(timestamp, utc) + } + + func testJieliSleepEventStreamReconstruction() { + // `Z()` @1520 + `s1.java:1004`: 7-byte `[ts2000][model][pad2]` transition stream; 0x11 + // opens (first segment counts as light), 1 deep, 4 REM, 0x22 closes; durations are the + // deltas between consecutive records. + let payload = cat( + [0x05, 0x05, 0x10], + jieliEpoch(0), [0x11, 0, 0], + jieliEpoch(600), [1, 0, 0], + jieliEpoch(1_800), [4, 0, 0], + jieliEpoch(2_400), [0x22, 0, 0] + ) + let events = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x05, key: 0x05, keyFlag: 0x10), payload: payload + ) + guard case let .sleepTimeline(timestamp, stages)? = events.first else { + return XCTFail("expected sleepTimeline, got \(events)") + } + XCTAssertEqual(timestamp, utc) + XCTAssertEqual(stages.count, 40) + XCTAssertEqual(Array(stages[0..<10]), Array(repeating: SleepStage.light, count: 10)) + XCTAssertEqual(Array(stages[10..<30]), Array(repeating: SleepStage.deep, count: 20)) + XCTAssertEqual(Array(stages[30..<40]), Array(repeating: SleepStage.rem, count: 10)) + } + + func testJieliTemperatureAndBloodSugarScaling() { + // `U()`: u16 ÷ 10 °C. `R()`: u16 ÷ 10 mmol/L → mg/dL. + let temp = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x05, key: 0x08, keyFlag: 0x10), + payload: cat([0x05, 0x08, 0x10], jieliEpoch(), [0x01, 0x6d]) // 365 → 36.5 °C + ) + guard case let .historyMeasurement(kind, celsius, _)? = temp.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(kind, .temperature) + XCTAssertEqual(celsius, 36.5, accuracy: 0.001) + + let sugar = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x05, key: 0x10, keyFlag: 0x10), + payload: cat([0x05, 0x10, 0x10], jieliEpoch(), [0x00, 0x37]) // 5.5 mmol/L + ) + guard case let .historyMeasurement(kind2, mgdl, _)? = sugar.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(kind2, .bloodSugar) + XCTAssertEqual(mgdl, 5.5 * 18.016, accuracy: 0.01) + } + + func testJieliBindReplyGrantsTLVCapabilities() { + // `u()` @2636: `[3]` bindStatus; `(0x05, type)` pairs from offset 8 up to the first NUL. + let payload = cat( + [0x03, 0x01, 0x00], [1], [0, 0, 0, 0], + [0x05, 0x04, 0x05, 0x0a, 0x05, 0x08], [0x00], [0x05, 0x0d] // BP, HRV, temp; stress after NUL + ) + let events = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 0x03, key: 0x01, keyFlag: 0x00), payload: payload + ) + guard case let .supportFunctions(caps)? = events.last else { + return XCTFail("expected supportFunctions, got \(events)") + } + XCTAssertEqual(caps, [.bloodPressure, .manualBloodPressure, .hrv, .manualHrv, .temperature]) + XCTAssertFalse(caps.contains(.stress), "pairs after the first NUL are not capability TLV") + } + + func testJieliBatteryFirmwareAndRealtime() { + let battery = decoder.decodeJieli( + triple: .battery, payload: [0x02, 0x03, 0x10, 76, 0x0e, 0xd8] + ) + guard case let .battery(percent)? = battery.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(percent, 76) + + let info = decoder.decodeJieli( + triple: .deviceInfo, payload: [0x02, 0x04, 0x10, 1, 2, 11] + ) + guard case let .firmware(version)? = info.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(version, "1.2.11") + + // Realtime reply (`x5/b.java:3734`): value = data[5] + 10; type echoed at [3]. + let hr = decoder.decodeJieli( + triple: .realtimeMeasure, payload: [0x06, 0x09, 0x00, 0x03, 0x05, 62] + ) + guard case let .heartRateSample(bpm, _)? = hr.first else { return XCTFail("unexpected event shape") } + XCTAssertEqual(bpm, 72) + + let warmup = decoder.decodeJieli( + triple: .realtimeMeasure, payload: [0x06, 0x09, 0x00, 0x03, 0x05, 0] + ) + guard case .commandAck? = warmup.first else { return XCTFail("zero value = still measuring") } + } +} diff --git a/PulseLoopTests/RWfitDriverTests.swift b/PulseLoopTests/RWfitDriverTests.swift new file mode 100644 index 00000000..daad4bd8 --- /dev/null +++ b/PulseLoopTests/RWfitDriverTests.swift @@ -0,0 +1,214 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// The driver's two family-defining behaviours: **framing selection from the discovered GATT** +/// (the whole reason `WearableDriver.servicesDiscovered` exists) and **ACK-before-decode** on both +/// wire protocols — plus the command gate's single-outstanding discipline. +@MainActor +final class RWfitDriverTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + + private let notify = CBUUID(string: RWfitUUIDs.notify) + private let dataService = CBUUID(string: RWfitUUIDs.service) + + private func deviceFrame(cmd: UInt8, payload: [UInt8], serial: Int = 7) -> Data { + var bytes: [UInt8] = [0x7e, 0x01, cmd, 0x00, UInt8(payload.count)] + bytes += RWfitBytes.packU16BE(serial) + bytes.append(payload.isEmpty ? 0 : RWfitBytes.xorChecksum(payload)) + bytes += payload + return Data(bytes) + } + + // MARK: - Framing selection + + func testDefaultsToLegacyFraming() { + let driver = RWfitDriver(writer: FakeWriter()) + XCTAssertEqual(driver.framing, .legacy) + driver.servicesDiscovered([dataService]) + XCTAssertEqual(driver.framing, .legacy, "A00A alone means the legacy firmware") + } + + func testJieliServiceSelectsJieliFraming() { + let driver = RWfitDriver(writer: FakeWriter()) + driver.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.jieli)]) + XCTAssertEqual(driver.framing, .jieli) + } + + func testTelinkOrPixartOTAAlsoSelectJieli() { + // `r5/b.java:703-727`: the Telink/PixArt OTA services flip the same platform flag as AE00. + let telink = RWfitDriver(writer: FakeWriter()) + telink.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.telinkOTA)]) + XCTAssertEqual(telink.framing, .jieli) + + let pixart = RWfitDriver(writer: FakeWriter()) + pixart.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.pixartOTA)]) + XCTAssertEqual(pixart.framing, .jieli) + } + + func testReconnectRedecidesFraming() { + let driver = RWfitDriver(writer: FakeWriter()) + driver.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.jieli)]) + driver.connectionDidEnd() + driver.connectionDidStart() + driver.servicesDiscovered([dataService]) + XCTAssertEqual(driver.framing, .legacy, "each link's discovery decides afresh") + } + + // MARK: - Legacy ingest + + func testLegacyDeviceFrameIsAckedBeforeDecode() { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + driver.servicesDiscovered([dataService]) + + let events = driver.ingest(deviceFrame(cmd: 0x01, payload: [0, 0, 88], serial: 5), from: notify) + + XCTAssertEqual(writer.sent.count, 1, "a device frame must be ACKed") + let ack = [UInt8](writer.sent[0]) + XCTAssertEqual(ack[2], RWfitLegacyCommand.appAck) + XCTAssertEqual(Array(ack[8...]), [0x00, 0x05, 0x01, 0x00], "[serial, cmd, ok]") + guard case let .battery(percent)? = events.first else { return XCTFail("got \(events)") } + XCTAssertEqual(percent, 88) + } + + func testLegacyChecksumFailureSendsNack() { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + driver.servicesDiscovered([dataService]) + + var corrupted = [UInt8](deviceFrame(cmd: 0x01, payload: [0, 0, 88], serial: 5)) + corrupted[8] ^= 0xff + let events = driver.ingest(Data(corrupted), from: notify) + + XCTAssertTrue(events.isEmpty) + let nack = [UInt8](writer.sent[0]) + XCTAssertEqual(Array(nack[8...]), [0x00, 0x05, 0x01, 0x02], "status 2 asks for a retransmit") + } + + // MARK: - JieLi ingest + + func testJieliFrameIsAckedAndCapabilitiesAnnouncedOnce() { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + driver.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.jieli)]) + + let codec = RWfitJLCodec() + let events = driver.ingest(codec.encode(payload: [0x02, 0x03, 0x10, 76, 0, 0]), from: notify) + + // ACK first: flag 0x11 + echoed triple. + XCTAssertEqual(writer.sent.count, 1) + let ack = [UInt8](writer.sent[0]) + XCTAssertEqual(ack[1], 0x11) + XCTAssertEqual(Array(ack[6...]), [0x02, 0x03, 0x10]) + + // Battery decoded, and the JieLi link's realtime capability grant rides the first ingest. + guard case .battery? = events.first else { return XCTFail("got \(events)") } + guard case let .supportFunctions(caps)? = events.last else { + return XCTFail("expected the framing capability grant, got \(events)") + } + XCTAssertTrue(caps.isSuperset(of: RWfitDriver.jieliRealtimeCapabilities)) + + // Second frame: no repeat announcement. + let more = driver.ingest(codec.encode(payload: [0x02, 0x03, 0x10, 75, 0, 0]), from: notify) + XCTAssertFalse(more.contains { if case .supportFunctions = $0 { true } else { false } }) + } + + func testCapabilityGrantsAccumulateAcrossSources() { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + driver.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.jieli)]) + let codec = RWfitJLCodec() + + _ = driver.ingest(codec.encode(payload: [0x02, 0x03, 0x10, 76, 0, 0]), from: notify) + // Bind reply grants BP via TLV; the announcement must still include the framing grant — + // `applySupportFunctions` recomputes from the latest set, so partial sets would drop it. + let bind: [UInt8] = [0x03, 0x01, 0x00, 1, 0, 0, 0, 0, 0x05, 0x04, 0x00] + let events = driver.ingest(codec.encode(payload: bind), from: notify) + guard case let .supportFunctions(caps)? = events.last else { return XCTFail("got \(events)") } + XCTAssertTrue(caps.isSuperset(of: RWfitDriver.jieliRealtimeCapabilities)) + XCTAssertTrue(caps.isSuperset(of: [.bloodPressure, .manualBloodPressure])) + } + + func testRealtimeAckUsesFourByteQuirk() { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + driver.servicesDiscovered([dataService, CBUUID(string: RWfitUUIDs.jieli)]) + + let codec = RWfitJLCodec() + _ = driver.ingest(codec.encode(payload: [0x06, 0x09, 0x00, 0x03, 0x05, 62]), from: notify) + let ack = [UInt8](writer.sent[0]) + XCTAssertEqual(Array(ack[6...]), [0x06, 0x09, 0x00, 0x00]) + } + + // MARK: - Command gate + + func testGateHoldsSecondCommandUntilDeviceAck() async { + let writer = FakeWriter() + let legacy = RWfitLegacyCodec() + let gate = RWfitCommandGate(writer: writer, legacyCodec: legacy, jlCodec: RWfitJLCodec()) + + gate.submit(.legacy(cmd: 0x01, payload: [])) + gate.submit(.legacy(cmd: 0x02, payload: [])) + XCTAssertEqual(writer.sent.count, 1, "strict single-outstanding") + XCTAssertEqual([UInt8](writer.sent[0])[2], 0x01) + + gate.noteLegacyAck(cmd: 0x01, serial: 1) + try? await Task.sleep(nanoseconds: 250_000_000) // spacing (100 ms) + margin + XCTAssertEqual(writer.sent.count, 2, "device ACK releases the next command") + XCTAssertEqual([UInt8](writer.sent[1])[2], 0x02) + } + + func testGateIgnoresMismatchedAck() async { + let writer = FakeWriter() + let gate = RWfitCommandGate( + writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec() + ) + gate.submit(.legacy(cmd: 0x01, payload: [])) + gate.submit(.legacy(cmd: 0x02, payload: [])) + + gate.noteLegacyAck(cmd: 0x99, serial: 1) // wrong cmd + gate.noteLegacyAck(cmd: 0x01, serial: 42) // wrong serial + try? await Task.sleep(nanoseconds: 200_000_000) + XCTAssertEqual(writer.sent.count, 1, "a mismatched ACK must not release the queue") + gate.cancel() + } + + func testGateRetriesOnceThenDropsOnTimeout() async { + let writer = FakeWriter() + let gate = RWfitCommandGate( + writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), + responseTimeout: 0.05 + ) + gate.submit(.legacy(cmd: 0x01, payload: [])) + gate.submit(.legacy(cmd: 0x02, payload: [])) + + try? await Task.sleep(nanoseconds: 500_000_000) + let cmds = writer.sent.map { [UInt8]($0)[2] } + // 0x02 begins its own attempt/retry cycle once 0x01 is dropped — only the order matters. + XCTAssertEqual(Array(cmds.prefix(3)), [0x01, 0x01, 0x02], + "one retry of 0x01, then the queue moves on") + gate.cancel() + } + + func testGateJieliAckMatchesTriple() async { + let writer = FakeWriter() + let gate = RWfitCommandGate( + writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec() + ) + gate.framing = .jieli + gate.submit(.jieli(payload: [0x02, 0x03, 0x10])) + gate.submit(.jieli(payload: [0x02, 0x04, 0x10])) + XCTAssertEqual(writer.sent.count, 1) + + gate.noteJieliAck(triple: RWfitJLTriple(cmd: 0x02, key: 0x03, keyFlag: 0x10)) + try? await Task.sleep(nanoseconds: 400_000_000) // spacing (230 ms) + margin + XCTAssertEqual(writer.sent.count, 2) + XCTAssertEqual(Array([UInt8](writer.sent[1])[6...]), [0x02, 0x04, 0x10]) + gate.cancel() + } +} diff --git a/PulseLoopTests/RWfitHistorySyncTests.swift b/PulseLoopTests/RWfitHistorySyncTests.swift new file mode 100644 index 00000000..2fed21b2 --- /dev/null +++ b/PulseLoopTests/RWfitHistorySyncTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import PulseLoop + +/// The RWfit history pager: sequential per-type paging with settle/stall advancement (the LuckRing +/// contract), plus the RWfit-specific wrinkle — types the active framing has no stream for are +/// skipped without a request or a timeout. +@MainActor +final class RWfitHistorySyncTests: XCTestCase { + private final class FakeWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + /// Legacy request cmd ids ([2] of each 0x7E frame). + var legacyCommands: [UInt8] { sent.map { [UInt8]($0)[2] } } + } + + private final class Spy { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var stages: [String] = [] + var didFinish: Bool { stages.contains("done") } + } + + private func makeSync( + writer: FakeWriter, spy: Spy, + framing: RWfitFraming = .legacy, + settle: TimeInterval, stall: TimeInterval + ) -> (RWfitHistorySync, RWfitCommandGate) { + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec()) + gate.framing = framing + let sync = RWfitHistorySync(gate: gate, settleSeconds: settle, stallSeconds: stall, progressSink: { + if case let .syncProgress(stage) = $0 { spy.stages.append(stage) } + }) + sync.framing = framing + return (sync, gate) + } + + 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, gate) = makeSync(writer: writer, spy: spy, settle: 0.05, stall: 5) + + sync.start(types: [.steps, .sleep]) + XCTAssertEqual(writer.legacyCommands, [RWfitLegacyCommand.stepsHistory], + "the first type is requested immediately") + + gate.noteLegacyAck(cmd: RWfitLegacyCommand.stepsHistory, serial: 1) // free the gate + sync.noteReceived(type: .steps) + await sleep(0.3) + XCTAssertEqual(writer.legacyCommands, + [RWfitLegacyCommand.stepsHistory, RWfitLegacyCommand.sleepHistory], + "the pass advanced once the steps data settled") + + gate.noteLegacyAck(cmd: RWfitLegacyCommand.sleepHistory, serial: 2) + sync.noteReceived(type: .sleep) + await sleep(0.3) + XCTAssertFalse(sync.isRunning) + XCTAssertTrue(spy.didFinish) + gate.cancel() + } + + func testUnansweredTypeIsSkippedOnStall() async { + let writer = FakeWriter() + let spy = Spy() + let (sync, gate) = makeSync(writer: writer, spy: spy, settle: 5, stall: 0.05) + + sync.start(types: [.temperature]) + await sleep(0.3) + XCTAssertFalse(sync.isRunning, "a type that never answers is skipped on the stall timeout") + XCTAssertTrue(spy.didFinish) + gate.cancel() + } + + func testFramingUnsupportedTypesAreSkippedWithoutRequests() async { + // Legacy has no HRV/stress/blood-sugar stream — the pass must complete without writing a + // single request or burning a stall timeout on them. + let writer = FakeWriter() + let spy = Spy() + let (sync, gate) = makeSync(writer: writer, spy: spy, framing: .legacy, settle: 5, stall: 5) + + sync.start(types: [.hrv, .stress, .bloodSugar]) + XCTAssertTrue(writer.sent.isEmpty, "no request for streams the framing doesn't define") + XCTAssertFalse(sync.isRunning) + XCTAssertTrue(spy.didFinish) + gate.cancel() + } + + func testJieliSkipsBreatheButRequestsHRV() { + let writer = FakeWriter() + let spy = Spy() + let (sync, gate) = makeSync(writer: writer, spy: spy, framing: .jieli, settle: 5, stall: 5) + + sync.start(types: [.breathe, .hrv]) + XCTAssertEqual(writer.sent.count, 1, "breathe is legacy-only; HRV is requested") + XCTAssertEqual(Array([UInt8](writer.sent[0])[6...]), [0x05, 0x0a, 0x10]) + sync.cancel() + gate.cancel() + } + + func testReEntrantStartIsIgnoredWhileRunning() async { + let writer = FakeWriter() + let spy = Spy() + let (sync, gate) = makeSync(writer: writer, spy: spy, settle: 5, stall: 5) + + sync.start(types: [.steps]) + sync.start(types: [.sleep]) // must not interrupt the in-flight pass + XCTAssertEqual(writer.legacyCommands, [RWfitLegacyCommand.stepsHistory]) + sync.cancel() + gate.cancel() + await sleep(0.05) + } + + func testCancelStopsThePass() async { + let writer = FakeWriter() + let spy = Spy() + let (sync, gate) = makeSync(writer: writer, spy: spy, settle: 0.05, stall: 0.05) + + sync.start(types: [.steps, .sleep, .heartRate]) + sync.cancel() + await sleep(0.3) + XCTAssertEqual(writer.legacyCommands, [RWfitLegacyCommand.stepsHistory], + "no request may fire after cancel — timers must be dead") + XCTAssertFalse(spy.didFinish) + gate.cancel() + } +} diff --git a/PulseLoopTests/RWfitJLCodecTests.swift b/PulseLoopTests/RWfitJLCodecTests.swift new file mode 100644 index 00000000..c22e0eb5 --- /dev/null +++ b/PulseLoopTests/RWfitJLCodecTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import PulseLoop + +/// The JieLi (`0xAB`) wire contract: header layout, CRC-16/ARC, the triple-echo ACK (with its +/// `06 09` four-byte quirk), and headerless-continuation reassembly — against `x5/c.java g()` and +/// the inline decoder in `r5/b.java`. +@MainActor +final class RWfitJLCodecTests: XCTestCase { + + func testCRC16ARCReferenceVector() { + // The standard CRC-16/ARC check value — proves the bitwise form matches the vendor's table. + XCTAssertEqual(RWfitBytes.crc16ARC(Array("123456789".utf8)), 0xbb3d) + XCTAssertEqual(RWfitBytes.crc16ARC([]), 0) + } + + func testEncodeHeaderLayout() { + let payload: [UInt8] = [0x02, 0x03, 0x10] + let frame = [UInt8](RWfitJLCodec().encode(payload: payload)) + + XCTAssertEqual(frame[0], 0xab) + XCTAssertEqual(frame[1], 0x01, "requests carry flag 0x01") + XCTAssertEqual(RWfitBytes.u16BE(frame, 2), 3, "dataLen counts the triple") + let crc = RWfitBytes.crc16ARC(payload) + XCTAssertEqual(frame[4], UInt8(crc >> 8), "CRC big-endian in the header") + XCTAssertEqual(frame[5], UInt8(crc & 0xff)) + XCTAssertEqual(Array(frame[6...]), payload) + } + + func testAckEchoesTripleWithFlag11() { + let ack = [UInt8](RWfitJLCodec().ack(triple: RWfitJLTriple(cmd: 0x05, key: 0x03, keyFlag: 0x10))) + XCTAssertEqual(ack[1], 0x11) + XCTAssertEqual(Array(ack[6...]), [0x05, 0x03, 0x10]) + } + + func testRealtimeAckCarriesTrailingZero() { + // `r5/b.java`'s `CMD == 6 && Key == 9` special case: the 06 09 reply is ACKed with 4 bytes. + let ack = [UInt8](RWfitJLCodec().ack(triple: RWfitJLTriple(cmd: 0x06, key: 0x09, keyFlag: 0x00))) + XCTAssertEqual(Array(ack[6...]), [0x06, 0x09, 0x00, 0x00]) + } + + func testDecodeSingleFrameRoundTrip() { + let codec = RWfitJLCodec() + let payload: [UInt8] = [0x02, 0x03, 0x10, 0x5a, 0x0e, 0xd8] + let events = codec.decode(codec.encode(payload: payload)) + XCTAssertEqual(events, [.frame(triple: RWfitJLTriple(cmd: 0x02, key: 0x03, keyFlag: 0x10), + payload: payload)]) + } + + func testDecodeDeviceAck() { + let codec = RWfitJLCodec() + let events = codec.decode(codec.encode(payload: [0x02, 0x01, 0x00], isAck: true)) + XCTAssertEqual(events, [.deviceAck(triple: RWfitJLTriple(cmd: 0x02, key: 0x01, keyFlag: 0x00))]) + } + + func testHeaderlessContinuationReassembly() { + let codec = RWfitJLCodec() + // A 40-byte payload split as the firmware does: header packet with the first bytes, then a + // raw continuation carrying the rest — no header, no magic (`r5/b.java`'s multi-packet arm). + let payload: [UInt8] = [0x05, 0x03, 0x10] + (0..<37).map { UInt8($0) } + let whole = [UInt8](codec.encode(payload: payload)) + let headerPacket = Data(whole[0..<26]) + let continuation = Data(whole[26...]) + + XCTAssertTrue(codec.decode(headerPacket).isEmpty, "nothing surfaces mid-reassembly") + let events = codec.decode(continuation) + XCTAssertEqual(events, [.frame(triple: RWfitJLTriple(cmd: 0x05, key: 0x03, keyFlag: 0x10), + payload: payload)]) + } + + func testCRCFailureIsSurfacedAndDropped() { + let codec = RWfitJLCodec() + var corrupted = [UInt8](codec.encode(payload: [0x02, 0x03, 0x10, 0x42])) + corrupted[9] ^= 0xff + XCTAssertEqual(codec.decode(Data(corrupted)), [.crcFailed]) + } + + func testGarbageIsDropped() { + let codec = RWfitJLCodec() + XCTAssertTrue(codec.decode(Data([0x7e, 0x01, 0x01, 0x00])).isEmpty, + "a legacy frame on a JieLi link is noise, not a crash") + } + + func testResetDropsHalfAssembledFrame() { + let codec = RWfitJLCodec() + let payload: [UInt8] = [0x05, 0x03, 0x10] + (0..<37).map { UInt8($0) } + let whole = [UInt8](codec.encode(payload: payload)) + _ = codec.decode(Data(whole[0..<26])) + codec.reset() + XCTAssertTrue(codec.decode(Data(whole[26...])).isEmpty, + "a continuation from the dropped link must not complete on the new one") + } +} diff --git a/PulseLoopTests/RWfitLegacyCodecTests.swift b/PulseLoopTests/RWfitLegacyCodecTests.swift new file mode 100644 index 00000000..94072682 --- /dev/null +++ b/PulseLoopTests/RWfitLegacyCodecTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import PulseLoop + +/// The legacy (`0x7E`) wire contract: header layout, XOR checksums, serials, the two-sided ACK +/// handshake (device `0xFE` in, app `0xFF` out) and inbound multi-packet reassembly — all +/// byte-for-byte against `x5/d.java`. Fixtures are hand-assembled from that file's header math. +@MainActor +final class RWfitLegacyCodecTests: XCTestCase { + + /// Build an inbound device frame the way the ring's firmware does (single-packet). + private func deviceFrame(cmd: UInt8, payload: [UInt8], serial: Int = 7) -> Data { + var bytes: [UInt8] = [0x7e, 0x01, cmd, 0x00, UInt8(payload.count)] + bytes += RWfitBytes.packU16BE(serial) + bytes.append(payload.isEmpty ? 0 : RWfitBytes.xorChecksum(payload)) + bytes += payload + return Data(bytes) + } + + // MARK: - Encode + + func testEncodeSingleFrameLayout() { + let codec = RWfitLegacyCodec() + let (frame, serial) = codec.encode(cmd: 0x21, payload: [0x07, 0xea, 8, 5, 12, 30, 15]) + + let bytes = [UInt8](frame) + XCTAssertEqual(serial, 1, "serials start at 1") + XCTAssertEqual(Array(bytes[0..<5]), [0x7e, 0x01, 0x21, 0x00, 7], "magic/version/cmd/flags/len") + XCTAssertEqual(RWfitBytes.u16BE(bytes, 5), 1, "serial big-endian at [5..6]") + XCTAssertEqual(bytes[7], RWfitBytes.xorChecksum([0x07, 0xea, 8, 5, 12, 30, 15]), "XOR of payload") + XCTAssertEqual(Array(bytes[8...]), [0x07, 0xea, 8, 5, 12, 30, 15]) + } + + func testEncodeEmptyPayloadHasZeroChecksum() { + let codec = RWfitLegacyCodec() + let (frame, _) = codec.encode(cmd: 0xa3, payload: []) + XCTAssertEqual([UInt8](frame), [0x7e, 0x01, 0xa3, 0x00, 0x00, 0x00, 0x01, 0x00]) + } + + func testSerialsIncrementPerFrame() { + let codec = RWfitLegacyCodec() + XCTAssertEqual(codec.encode(cmd: 0x01, payload: []).serial, 1) + XCTAssertEqual(codec.encode(cmd: 0x01, payload: []).serial, 2) + } + + func testAppAckFrameEchoesInboundSerial() { + let codec = RWfitLegacyCodec() + let ack = [UInt8](codec.ack(cmd: 0xa3, serial: 0x1234, status: 0x00)) + XCTAssertEqual(ack[2], RWfitLegacyCommand.appAck, "app ACKs go out as 0xFF") + XCTAssertEqual(Array(ack[8...]), [0x12, 0x34, 0xa3, 0x00], "[inSerHi, inSerLo, cmd, status]") + } + + // MARK: - Decode + + func testDecodeSingleFrameEmitsAckThenFrame() { + let codec = RWfitLegacyCodec() + let events = codec.decode(deviceFrame(cmd: 0x01, payload: [0, 0, 90], serial: 9)) + XCTAssertEqual(events, [ + .ackNeeded(cmd: 0x01, serial: 9), + .frame(cmd: 0x01, payload: [0, 0, 90]), + ], "ACK request precedes the frame — decode must never delay the ring's retransmit window") + } + + func testDecodeDeviceAckIsNotAckedBack() { + let codec = RWfitLegacyCodec() + let events = codec.decode(deviceFrame(cmd: 0xfe, payload: [0x00, 0x03, 0x21, 0x00])) + XCTAssertEqual(events, [.deviceAck(cmd: 0x21, serial: 3, status: 0)], + "a 0xFE releases the gate and must never generate an ACK of an ACK") + } + + func testChecksumFailureAsksForRetransmit() { + var corrupted = [UInt8](deviceFrame(cmd: 0x01, payload: [0, 0, 90], serial: 9)) + corrupted[8] ^= 0xff + let events = RWfitLegacyCodec().decode(Data(corrupted)) + XCTAssertEqual(events, [.checksumFailed(cmd: 0x01, serial: 9)]) + } + + func testGarbageAndShortFramesAreDropped() { + let codec = RWfitLegacyCodec() + XCTAssertTrue(codec.decode(Data([0xab, 0x01])).isEmpty, "wrong magic") + XCTAssertTrue(codec.decode(Data([0x7e, 0x01, 0x01])).isEmpty, "truncated header") + } + + // MARK: - Multi-packet reassembly + + /// Chunked history reply: each chunk carries the full header + `totalBE currentBE`, each is + /// individually ACKed, and the combined payload surfaces when the last chunk lands — sorted by + /// chunk index, exactly like `x5/d.java h()`. + private func chunk(cmd: UInt8, serial: Int, total: Int, current: Int, body: [UInt8]) -> Data { + var bytes: [UInt8] = [0x7e, 0x01, cmd, 0x08, UInt8(body.count)] + bytes += RWfitBytes.packU16BE(serial) + bytes.append(RWfitBytes.xorChecksum(body)) + bytes += RWfitBytes.packU16BE(total) + bytes += RWfitBytes.packU16BE(current) + bytes += body + return Data(bytes) + } + + func testMultiPacketReassemblyAcksEveryChunkAndJoinsInOrder() { + let codec = RWfitLegacyCodec() + + let first = codec.decode(chunk(cmd: 0xa3, serial: 11, total: 2, current: 1, body: [1, 2, 3])) + XCTAssertEqual(first, [.ackNeeded(cmd: 0xa3, serial: 11)], "no frame until all chunks are in") + + let second = codec.decode(chunk(cmd: 0xa3, serial: 12, total: 2, current: 2, body: [4, 5])) + XCTAssertEqual(second, [ + .ackNeeded(cmd: 0xa3, serial: 12), + .frame(cmd: 0xa3, payload: [1, 2, 3, 4, 5]), + ]) + } + + func testResetDropsHalfAssembledFrames() { + let codec = RWfitLegacyCodec() + _ = codec.decode(chunk(cmd: 0xa3, serial: 11, total: 2, current: 1, body: [1, 2, 3])) + codec.reset() + let events = codec.decode(chunk(cmd: 0xa3, serial: 12, total: 2, current: 2, body: [4, 5])) + XCTAssertEqual(events, [.ackNeeded(cmd: 0xa3, serial: 12)], + "a chunk from the dropped link must not complete a frame on the new one") + } +} diff --git a/docs/hardware/index.md b/docs/hardware/index.md index a11d7a57..7f788f5e 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -76,6 +76,17 @@ section breaks the hardware down by manufacturer. [:octicons-arrow-right-24: LuckRing / TK18](luckring.md) +- :material-flask-outline: __RWfit rings__ + + --- + + 🧪 Limited. The RWfit-app (`com.rw.revivalfit`) family — one `A00A` GATT, + **two wire protocols** (legacy `0x7E` / JieLi `0xAB`), rebuilt from the + vendor's own source with their cooperation. Often rebranded; the known + field unit was sold as a "Colmi". + + [:octicons-arrow-right-24: RWfit rings](rwfit.md) + - :material-help-circle-outline: __SIMSONLAB__ --- @@ -208,6 +219,7 @@ Multiple hardware platforms span from $7 commodity rings to $350 premium devices | **[TK5](tk5.md)** | JieLi (part ❓) | Yucheng YCBT (`be940`) | SmartHealth | ❓ | 🧪 App (limited) | | **[R10M / LittleMeatball](r10m.md)** | ❓ | Yucheng YCBT (`be940`) | SmartHealth | $15–30 | ✅ App (validated on FW 2.32) | | **[LuckRing / TK18](luckring.md)** | ❓ (Coolwear/Kewo OEM) | Custom "K6" (`F618`) | LuckRing | ~$10 | 🧪 App (limited) | +| **[RWfit rings](rwfit.md)** | ❓ (JieLi on the `0xAB` line) | Legacy `0x7E` / JieLi `0xAB` (`A00A`) | RWfit | ❓ | 🧪 App (limited, vendor-assisted) | | **[SIMSONLAB](simsonlab.md)** | Phyplus PHY6222 | Unknown | SIMSONLAB app | ~$10–20 | ❌ | ### Premium Rings diff --git a/docs/hardware/rwfit.md b/docs/hardware/rwfit.md new file mode 100644 index 00000000..989fc193 --- /dev/null +++ b/docs/hardware/rwfit.md @@ -0,0 +1,180 @@ +--- +title: RWfit rings +description: >- + The RWfit-app ring family (com.rw.revivalfit): one A00A GATT, two wire + protocols (legacy 0x7E and JieLi 0xAB), rebuilt for PulseLoop from the vendor + app's source with the vendor's cooperation. Steps, HR, SpO₂, sleep, and — per + ring — BP, HRV, stress, temperature, blood sugar. +--- + +# RWfit rings + +**PulseLoop support: 🧪 Limited — no unit tested on hardware yet** + +A commodity smart-ring family whose companion app is **RWfit** +(`com.rw.revivalfit`, v6.0.5 at the time of analysis). The rings are sold under +assorted storefront brands — the one unit we know of in the field was bought as +a **"Colmi"**, though the family shares nothing with the Colmi/QRing protocol. +Unusually, this integration was built **with the vendor's cooperation**: the +company behind the app shared their source, and every byte layout below is +reconstructed from it rather than from packet captures. + +!!! warning "Limited support — reconstructed, not yet observed" + No RWfit ring has been connected to PulseLoop hardware-in-hand. Every layout + is unit-tested against fixture bytes derived from the vendor parsers, and + every decoded metric is range-gated before storage, so a misdecode is + dropped rather than saved as garbage — but the first real diagnostics + capture is what promotes any of this from "reconstructed" to "observed". + The open items are tagged **[unconfirmed]** below. + +## One GATT, two protocols + +Every ring in the family exposes the same data GATT: + +| UUID | Role | +|---|---| +| `A00A` | Primary data service | +| `B002` | Write (commands; with-response accepted) | +| `B003` | Notify (replies + pushes) | + +But the family spans **two incompatible wire framings**, and the advertisement +does not say which one a given ring speaks. The vendor app decides *after +connecting*, from which sibling services service discovery turns up +(`r5/b.java:684-740` in the decompile): + +- **JieLi `AE00`**, the **Telink OTA** service (`00010203-…-0d1912`), or the + **PixArt OTA** service (`FF00`) present → **JieLi framing** (`0xAB`). +- None of them → **legacy framing** (`0x7E`, "Realtek" in vendor comments). + +PulseLoop does the same: the RWfit family is a single device type, and +`RWfitDriver.servicesDiscovered` picks the codec before the first byte is +written. This is the only family that needed a framework hook for it +(`WearableDriver.servicesDiscovered`). + +### Discovery / advertisement + +The vendor scanner (`r5/d.java:70-134`) recognizes its rings by: + +- the advertised **`A00A` service** (its `pidType 1` raw pattern + `02 01 06 03 03 0a a0` is Flags + a 16-bit service list), or +- **manufacturer data** opening with company ID `0x05D6` (`d6 05 02 00`, or + `d6 05` + ASCII `AT`) or `0x06D6` (`d6 06 02 00` — the "T-Ring" line). + +`RWfitCoordinator` matches exactly these signals and **no names**: rebranders +rename rings, and until a diagnostics export shows a real advertised name, any +name pattern would be a guess. + +## Legacy framing (`0x7E`) + +Source of truth: `x5/d.java` (framing/queue), `x5/b.java` (parsers), +`…/mlkit_vision_common/p.java` (builders — R8 relocated the SDK's `CmdHelper`). + +``` +7E 01 +``` + +Multi-packet frames set flag bit 3 and insert `totalBE(2) currentBE(2)` at +[8..11]. The checksum is XOR over the payload. Every inbound frame must be +ACKed (app → device cmd `0xFF`, payload `[serHi, serLo, cmd, status]`; status +`0x02` = checksum NACK, triggers retransmit), and the device ACKs app commands +with `0xFE` — the queue is strictly one-outstanding-command. + +Commands used: `0x00` device info, `0x01` battery, `0x02`/`0x20` bind status / +bind (userId UTF-16LE), `0x03` feature bitmap, `0x21` set time (local calendar +components), `0x24` units, `0x2E` profile (+ goal), `0x44` unbind, +`0xA0` sync manifest, `0xA1`–`0xA7` history (steps, sleep, HR, BP, SpO₂, +temperature, breathe) — all history requests are empty-payload. + +Record layouts (evidence: `x5/b.java`, function @ line): + +| Stream | Layout | Evidence | Confidence | +|---|---|---|---| +| Steps | day hdr `[ts u32][steps u24][kcal u24][dist u24][n u16]` + n × 8B slots `[idx][steps u16][kcal u24][dist u16]` | `C0()` @397 | slot *width* unknown → PulseLoop publishes the day totals as one bucket **[unconfirmed: slot duration, distance unit]** | +| HR / SpO₂ / breathe | day hdr `[ts u32][n u16]` + n × 5B `[ts u32][value]` | `w0()` @2914, `r0()` @2457, `t0()` | known | +| Blood pressure | 6B items `[ts u32][sys][dia]` | `s0()` | known | +| Temperature | 5B items; °C = `(raw + 200) / 10` | `u0()` | known | +| Sleep | night hdr `[ts u32][totalMin u16][asleep u32][awake u32][n u16]` + n × 2B `[minutes][type]`; 0 awake / 1 light / 2 deep / 3 REM | `A0()` @180, `s1.java:1635` | known | + +The vendor app has **no on-demand measurement command** on this framing — the +measure pages only ever emit the JieLi command — so PulseLoop's manual/live +measurement capabilities are granted only on JieLi links. + +## JieLi framing (`0xAB`) + +Source of truth: `x5/c.java` (encode), `r5/b.java:386-492` (decode), +`y5/c.java` (the 160-entry `{CMD,Key,KeyFlag}` → internal-id map — the Rosetta +Stone), `y5/d.java` (CRC-16/ARC). + +``` +AB +``` + +`flag` `0x01` = request/push, `0x11` = ACK. `len` and the CRC (CRC-16/ARC, +poly `0xA001` reflected, init 0) cover the payload *including* the 3-byte +triple. Continuation packets are **headerless** — raw payload bytes until `len` +have arrived. Inbound frames are ACKed by echoing the triple with flag `0x11` +(the `06 09` realtime reply gets a 4th `0x00` byte). + +Triples used: `02 01 00` set time (year−2000), `02 03 10` battery, `02 04 10` +device info, `02 06 00` profile (height/weight as **little-endian floats** — +the protocol's one LE field), `02 07 00` goal, `02 11 00` units, `03 01 00/20/30` +bind status / bind / unbind, `05 xx 10` history, `06 09 00 05 ` +realtime measure toggle. + +History records all start at payload offset 3 (after the triple), timestamps +are **seconds since 2000-01-01** (+946684800): + +| Stream | Triple | Layout | Evidence | Confidence | +|---|---|---|---|---| +| Steps | `05 02 10` | 16B `[ts][pad][steps u24][kcal×10 u32][dist u32]` | `a0()` @1549 | distance ÷10 → metres inferred from the app's ÷10000 → km **[unconfirmed: distance unit]** | +| HR / SpO₂ / HRV / stress | `05 03/09/0A/0D 10` | 6B `[ts u32][value][pad]` | `V()` @1291, `S()` @1127, `W()`, `Y()` | known | +| Blood pressure | `05 04 10` | 6B `[ts][sys][dia]` | `T()` | known | +| Temperature | `05 08 10` | 6B `[ts][u16 ÷10 °C]` | `U()` | known | +| Blood sugar | `05 10 10` | 6B `[ts][u16 ÷10 mmol/L]` (→ mg/dL in app) | `R()` | known | +| Sleep | `05 05 10` | 7B `[ts][model][pad2]` **transition stream**: `0x11` session start (first segment = light), `0x22` end, 1 deep / 2 light / 3·0 awake / 4 REM; durations = deltas | `Z()` @1520, `s1.java:1004` | known | +| Realtime reply | `06 09 …` | value = `data[5] + 10`, type echoed at `[3]` | `x5/b.java:3734` | **[unconfirmed: the +10 offset]** | + +The bind-status reply (`03 01 00`) carries a trailing `(0x05, type)` TLV run +listing which `05`-group streams the ring supports — the JieLi family's +capability bitmap, which PulseLoop feeds into capability refinement. + +## Timestamps & timezone + +Both firmwares run their RTC on **local wall-clock time** (the app sets it from +local calendar components) and stamp history with local epochs. **PulseLoop +deliberately diverges from the vendor's conversion math**: the vendor's legacy +parsers add a fixed hour whenever the zone merely *observes* DST (wrong half +the year), and its JieLi parsers use the offset at parse time (wrong across a +DST boundary). PulseLoop latches `secondsFromGMT` at clock-push time +(`RWfitClock`, the `JringClock` contract) so encode and decode always agree. + +## Capability policy + +- **Baseline** (every unit): HR, SpO₂, steps, sleep (+REM), battery. +- **Bitmap-gated** (granted per unit): temperature, BP, HRV, stress, blood + sugar — from the legacy `0x03` feature bitmap or the JieLi bind TLV — plus + the whole manual/realtime measurement set, granted only on JieLi links + (the legacy protocol has no measure command). +- The vendor's **delete-acks** (`05 xx 30`), which erase synced records from + the ring, are **never sent** — PulseLoop upserts idempotently, and leaving + the log intact keeps the original app working alongside. + +## Needs on-device confirmation + +1. **Which framing real rings speak** (both are implemented; the tester's unit + decides which one gets validated first). +2. Legacy steps **slot duration** and both framings' **distance units**. +3. The realtime reply's **+10 value offset**. +4. The legacy **bind type byte** (PulseLoop sends `0x01`) and whether binding + is required at all for history to flow. +5. Advertised **names** for the catalog card's patterns (currently empty). + +### The validation loop + +Release builds don't store protocol bytes by default. A remote tester can: +Settings → Privacy & Data → Diagnostics → enable **Capture Bluetooth +diagnostics** → pair/sync → **Export diagnostics** → share the JSON. The +export's `rawPackets` rows carry direction, hex, decoded kind and confidence — +`unknown` rows are undecoded opcodes, and the `device`/`logs` sections carry +the advertisement name and connection timeline. Turning the toggle off and +tapping **Clear captured packets** removes the stored bytes. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1d..1e191572 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,6 +113,7 @@ nav: - R10M / LittleMeatball: hardware/r10m.md - TK5 / SmartHealth: hardware/tk5.md - LuckRing / TK18: hardware/luckring.md + - RWfit rings: hardware/rwfit.md - SIMSONLAB: hardware/simsonlab.md - Premium Rings: hardware/premium.md - Platforms: