diff --git a/PulseLoop/Diagnostics/DiagnosticsSubscriber.swift b/PulseLoop/Diagnostics/DiagnosticsSubscriber.swift index 9e297e40..9626c66b 100644 --- a/PulseLoop/Diagnostics/DiagnosticsSubscriber.swift +++ b/PulseLoop/Diagnostics/DiagnosticsSubscriber.swift @@ -47,6 +47,16 @@ final class DiagnosticsSubscriber { log(.battery, .info, "Battery \(percent)%") case let .syncProgress(stage): log(.sync, .info, "Sync: \(stage)") + case let .rwfitDiagnostic(message, metadata): + log(.sync, .info, message, metadata: metadata) + case let .rwfitInitialization(state): + log(.connection, .info, "RwFit initialization: \(state)") + case let .rwfitMeasurement(type, status): + log(.sync, .info, "RwFit measurement status", metadata: ["type": String(type), "status": String(status)]) + case let .rwfitMeasurementOutcome(outcome): + log(.sync, .info, "RwFit measurement outcome: \(outcome)") + case let .rwfitSyncOutcome(outcome): + recordRWfitOutcome(outcome) case .heartRateComplete: log(.sync, .info, "Heart-rate measurement complete") case .spo2Complete: @@ -56,6 +66,19 @@ final class DiagnosticsSubscriber { } } + private func recordRWfitOutcome(_ outcome: RWfitSyncOutcome) { + switch outcome { + case let .success(records): + log(.sync, .info, "RwFit sync complete", metadata: ["importedRecords": String(records)]) + case let .partial(records, reason): + log(.sync, .warn, "RwFit sync incomplete", metadata: ["importedRecords": String(records), "reason": reason]) + case let .failed(reason): + log(.error, .error, "RwFit sync failed", metadata: ["reason": reason]) + case .cancelled: + log(.sync, .info, "RwFit sync cancelled") + } + } + private func log(_ category: WearableLogCategory, _ level: WearableLogLevel, _ message: String, metadata: [String: String]? = nil) { let json = metadata.flatMap { dict -> String? in guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return nil } diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 8bc85a25..75ffee23 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -13,6 +13,12 @@ enum PulseEvent: Sendable { ) case deviceForgotten case batteryLevel(percent: Int) + case decodedPacket(RingDecodedEvent) + case rwfitSyncOutcome(RWfitSyncOutcome) + case rwfitInitialization(RWfitInitializationState) + case rwfitDiagnostic(message: String, metadata: [String: String]) + case rwfitMeasurementOutcome(RWfitMeasurementOutcome) + case rwfitMeasurement(type: UInt8, status: UInt8) case rawPacket(direction: PacketDirection, data: Data, decoded: RingDecodedEvent) case derivedUpdate(kind: String, entityType: String, entityId: String, payloadJSON: String?) case activityUpdate(timestamp: Date, steps: Int, distanceMeters: Double, calories: Double) @@ -105,6 +111,7 @@ final class EventPersistenceSubscriber { /// woke every `@Query` hundreds of times (the re-render storm). Instead we insert/mutate without /// saving, then flush (one `save()` + one "data changed" signal) after the stream briefly idles /// or a hard cap of pending writes is reached. + private var mergingRWfitSleep = false private var pendingWrites = 0 private var flushTask: Task? /// Idle window after the last event before we flush a batch. @@ -145,6 +152,10 @@ final class EventPersistenceSubscriber { func start() { guard task == nil else { return } + RWfitHistoryPersistence.save = { [weak self] events in + guard let self else { throw RWfitHistoryPersistence.PersistenceError.unavailable } + try self.saveRWfitHistory(events) + } task = Task { let stream = await PulseEventBus.shared.stream() for await event in stream { @@ -156,6 +167,7 @@ final class EventPersistenceSubscriber { } func stop() { + RWfitHistoryPersistence.save = nil flushTask?.cancel() flushNow() task?.cancel() @@ -171,6 +183,68 @@ final class EventPersistenceSubscriber { flushNow() } + /// Destructive history consumption is allowed only after this synchronous durable commit. + func saveRWfitHistory(_ events: [RingDecodedEvent], commit: (() throws -> Void)? = nil) throws { + let typed = try events.flatMap { decoded -> [PulseEvent] in + let mapped = RingEventBridge.events(for: decoded) + guard !mapped.isEmpty else { throw RWfitHistoryPersistence.PersistenceError.rejectedRecord } + return mapped + } + // Existing import helpers intentionally tolerate read errors for live data. Preflight those + // tables here so the destructive history path fails closed instead of treating a failed fetch + // as an empty database. + var measurements = try context.fetch(FetchDescriptor()) + let activity = try RWfitActivityPersistence(context: context) + _ = try context.fetch(FetchDescriptor()) + _ = try context.fetch(FetchDescriptor()) + // Commit unrelated live-event writes first. A failed history transaction can then roll + // back safely without discarding another stream's pending measurements. + try context.save() + pendingWrites = 0 + flushTask?.cancel() + flushTask = nil + mergingRWfitSleep = true + defer { mergingRWfitSleep = false } + do { + for event in typed { + if activity.apply(event) { continue } + if case let .historyMeasurement(kind, value, timestamp) = event { + if let row = measurements.first(where: { + $0.kindRaw == kind.rawValue && $0.timestamp == timestamp && $0.sourceRaw == MeasurementSource.history.rawValue + }) { + row.value = value + } else { + let row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: .history) + context.insert(row) + measurements.append(row) + context.insert(DerivedUpdateRow(kind: "history_measurement", entityType: "measurement", + entityId: row.id.uuidString)) + _ = ActivityRecorderService.linkSample(kind: kind, value: value, timestamp: timestamp, + measurementId: row.id, source: .history, confidence: .known, context: context) + } + continue + } + if case let .sleepTimeline(timestamp, stages) = event { + // Inject throwing reads so the shared best-effort sleep helper never performs + // a swallowed fetch on the destructive-import path. + let sessions = try context.fetch(FetchDescriptor()) + let blocks = try context.fetch(FetchDescriptor()) + persistSleepTimeline(start: timestamp, stages: stages, sessions: sessions, blocks: blocks) + } else { + applyPersist(event) + } + } + if let commit { try commit() } else { try context.save() } + for day in activity.touchedDays { DailyCalorieEstimator.markDirty(day) } + pendingWrites = 0 + PulseDataChange.shared.notify() + } catch { + context.rollback() + seenHistoryKeys.removeAll() + throw error + } + } + func persist(_ event: PulseEvent) { applyPersist(event) scheduleFlush() @@ -214,7 +288,7 @@ final class EventPersistenceSubscriber { device.bleAddressHint = address ?? device.bleAddressHint if state == .connected { device.lastConnectedAt = Date() - device.lastSyncAt = Date() + if device.deviceType != .rwfit { device.lastSyncAt = Date() } } context.insert(device) case let .deviceIdentified(deviceType, wearableModelID, advertisedName, capabilities): @@ -352,7 +426,7 @@ final class EventPersistenceSubscriber { // Stamp the *completion* of a full history sync so the coach freshness gate can tell a // finished sync from a bare CONNECT (`lastSyncAt`, re-stamped every connect). if stage == "done" { - if let device = DeviceRepository.current(context: context) { + if let device = DeviceRepository.current(context: context), device.deviceType != .rwfit { device.lastFullSyncAt = Date() } // The rows are committed by now; the next sync re-checks against the database. @@ -361,6 +435,16 @@ final class EventPersistenceSubscriber { // batched flush below saves the writes and fires the coalesced change signal. DailyCalorieEstimator.flushDirty(context: context) } + case let .rwfitSyncOutcome(outcome): + if case .success = outcome, let device = DeviceRepository.current(context: context) { + let now = Date() + device.lastSyncAt = now + device.lastFullSyncAt = now + } + seenHistoryKeys.removeAll(keepingCapacity: true) + DailyCalorieEstimator.flushDirty(context: context) + case .decodedPacket, .rwfitInitialization, .rwfitDiagnostic, .rwfitMeasurement, .rwfitMeasurementOutcome: + break // `.wearState` is a live condition the measurement flow reacts to, not data — nothing to store. case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused, .workoutResumed, .workoutFinished, .coachTrace, .wearState: @@ -466,7 +550,8 @@ final class EventPersistenceSubscriber { /// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per /// night, so blocks must accumulate into one session rather than spawning a session per /// packet. Mirrors `persistence._on_sleep_timeline`. - private func persistSleepTimeline(start: Date, stages: [SleepStage]) { + private func persistSleepTimeline(start: Date, stages: [SleepStage], + sessions: [SleepSession]? = nil, blocks: [SleepStageBlock]? = nil) { let calendar = Calendar.current // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so a @@ -478,7 +563,7 @@ final class EventPersistenceSubscriber { // day is empty), deduping by block start across every session on the day. Then hand off to // `SleepService.reconcileWakingDay`, which re-splits the day's blocks into distinct sessions // (main night vs. naps separated by a >= 60 min gap) and recomputes each session's bounds. - let allSessions = (try? context.fetch(FetchDescriptor())) ?? [] + let allSessions = sessions ?? ((try? context.fetch(FetchDescriptor())) ?? []) let daySessions = allSessions.filter { calendar.isDate($0.date, inSameDayAs: dateKey) } let container = daySessions.min { $0.startAt < $1.startAt } ?? { @@ -491,8 +576,39 @@ final class EventPersistenceSubscriber { let sessionsForDay = daySessions.contains(where: { $0.id == container.id }) ? daySessions : daySessions + [container] let daySessionIds = Set(sessionsForDay.map { $0.id }) - let existingDayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) + let existingDayBlocks = (blocks ?? ((try? context.fetch(FetchDescriptor())) ?? [])) .filter { daySessionIds.contains($0.sessionId) } + if mergingRWfitSleep { + // A recovered journal may extend or overlap an already-imported session. Normalize by + // minute so a shorter previously saved block cannot suppress the rest of a replay. + var minuteStages: [Date: SleepStage] = [:] + for block in existingDayBlocks { + for minute in 0.. = [] + + init(context: ModelContext) throws { + self.context = context + days = try context.fetch(FetchDescriptor()) + buckets = try context.fetch(FetchDescriptor()) + } + + func apply(_ event: PulseEvent) -> Bool { + switch event { + case let .activityUpdate(timestamp, steps, distanceMeters, calories): + let row = day(timestamp) + row.steps = max(row.steps, steps) + row.distanceMeters = max(row.distanceMeters, distanceMeters) + row.calories = max(row.calories, calories) + row.source = "live" + recordChange(row) + return true + case let .activityBucket(timestamp, steps, distanceMeters): + let epoch = Int(timestamp.timeIntervalSince1970) + if let bucket = buckets.first(where: { $0.startEpoch == epoch }) { + bucket.steps = steps + bucket.distanceMeters = distanceMeters + bucket.updatedAt = Date() + } else { + let bucket = ActivityBucketSample(timestamp: timestamp, steps: steps, distanceMeters: distanceMeters) + context.insert(bucket) + buckets.append(bucket) + } + let row = day(timestamp) + let dayBuckets = buckets.filter { $0.date == row.date } + let totalSteps = dayBuckets.reduce(0) { $0 + $1.steps } + let totalDistance = dayBuckets.reduce(0.0) { $0 + $1.distanceMeters } + // Today's cumulative reading can lead the ring's most recent logged bucket. + if Calendar.current.isDateInToday(row.date), row.steps > totalSteps || row.distanceMeters > totalDistance { + row.steps = max(row.steps, totalSteps) + row.distanceMeters = max(row.distanceMeters, totalDistance) + } else { + row.steps = totalSteps + row.distanceMeters = totalDistance + row.source = ActivityService.ringHistorySource + } + recordChange(row) + return true + default: + return false + } + } + + private func day(_ timestamp: Date) -> ActivityDaily { + let date = Calendar.current.startOfDay(for: timestamp) + if let row = days.first(where: { $0.date == date }) { return row } + let row = ActivityDaily(date: date, source: ActivityService.ringHistorySource) + context.insert(row) + days.append(row) + return row + } + + private func recordChange(_ row: ActivityDaily) { + row.syncedAt = Date() + row.updatedAt = Date() + touchedDays.insert(row.date) + } +} diff --git a/PulseLoop/RingProtocol/RWfitCommandGate.swift b/PulseLoop/RingProtocol/RWfitCommandGate.swift index 8447af87..e5e0d7b5 100644 --- a/PulseLoop/RingProtocol/RWfitCommandGate.swift +++ b/PulseLoop/RingProtocol/RWfitCommandGate.swift @@ -1,46 +1,35 @@ 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). +/// Serial protocol transactions. A GATT completion and a matching protocol response are separate +/// requirements; neither queue residence nor an unsolicited push counts as a response. @MainActor final class RWfitCommandGate { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + nonisolated deinit {} + + private struct Request { + let id: UUID + let command: RWfitOutbound + let needsPayload: Bool + let completion: (Result<[UInt8], Error>) -> Void + } 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`). + private let responseTimeout: TimeInterval? var framing: RWfitFraming = .legacy - - private var queue: [RWfitOutbound] = [] - private var inFlight: RWfitOutbound? - private var inFlightSerial = 0 - private var retried = false + private var queue: [Request] = [] + private var inFlight: Request? + private var serial = 0 + private var attempts = 0 + private var writeConfirmed = false + private var received: [UInt8]? private var timeoutTask: Task? private var spacingTask: Task? + private var attemptID = UUID() - init( - writer: RingCommandWriter?, - legacyCodec: RWfitLegacyCodec, - jlCodec: RWfitJLCodec, - responseTimeout: TimeInterval = 2 - ) { + init(writer: RingCommandWriter?, legacyCodec: RWfitLegacyCodec, + jlCodec: RWfitJLCodec, responseTimeout: TimeInterval? = nil) { self.writer = writer self.legacyCodec = legacyCodec self.jlCodec = jlCodec @@ -49,93 +38,146 @@ final class RWfitCommandGate { var isIdle: Bool { inFlight == nil && queue.isEmpty } - /// Enqueue a logical command; sends immediately when the channel is free. + /// Fire-and-forget callers still receive failure diagnostics. State machines use execute. func submit(_ command: RWfitOutbound) { - queue.append(command) + enqueue(command, needsPayload: false) { result in + if case let .failure(error) = result { rwfitDiagnostic("Command failed", ["reason": error.localizedDescription]) } + } + } + + func execute(_ command: RWfitOutbound, needsPayload: Bool = true) async throws -> [UInt8] { + let id = UUID() + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + enqueue(command, id: id, needsPayload: needsPayload) { continuation.resume(with: $0) } + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancel(id: id) } + } + } + + private func enqueue(_ command: RWfitOutbound, id: UUID = UUID(), needsPayload: Bool, + completion: @escaping (Result<[UInt8], Error>) -> Void) { + queue.append(Request(id: id, command: command, needsPayload: needsPayload, completion: completion)) 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 + attemptID = UUID() + let pending = queue + (inFlight.map { [$0] } ?? []) queue.removeAll() inFlight = nil - retried = false + received = nil + for request in pending { request.completion(.failure(CancellationError())) } + } + + private func cancel(id: UUID) { + if inFlight?.id == id { finish(.failure(CancellationError())); return } + if let index = queue.firstIndex(where: { $0.id == id }) { + queue.remove(at: index).completion(.failure(CancellationError())) + } + } + + func noteLegacyAck(cmd: UInt8, serial: Int, status: UInt8 = 0) { + guard case let .legacy(expected, _)? = inFlight?.command, + expected == cmd, self.serial == serial else { return } + guard status == 0 else { finish(.failure(RWfitSessionError.invalidResponse)); return } + if inFlight?.needsPayload == false { receive([]) } } - // MARK: - ACKs from the device (driver calls these from `ingest`) + func noteLegacyFrame(cmd: UInt8, payload: [UInt8]) { + guard case let .legacy(expected, _)? = inFlight?.command, expected == cmd else { return } + receive(payload) + } - /// 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() + func noteJieliFrame(flag: UInt8, triple: RWfitJLTriple, payload: [UInt8]) { + guard flag != 0x21, case let .jieli(expected)? = inFlight?.command, + expected.count >= 3, expected[0] == triple.cmd, expected[1] == triple.key else { return } + receive(payload) } - /// 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() + noteJieliFrame(flag: 0x11, triple: triple, payload: triple.bytes) + } + + private func receive(_ payload: [UInt8]) { + received = payload + completeIfReady() } - // MARK: - Pump + private func completeIfReady() { + guard writeConfirmed, let received else { return } + finish(.success(received)) + } private func pump() { guard inFlight == nil, spacingTask == nil, !queue.isEmpty else { return } - let command = queue.removeFirst() - inFlight = command - retried = false - send(command) + inFlight = queue.removeFirst() + attempts = 0 + send() } - private func send(_ command: RWfitOutbound) { - switch command { + private func send() { + guard let request = inFlight else { return } + guard let writer else { finish(.failure(RWfitSessionError.unavailable)); return } + attempts += 1 + writeConfirmed = false + received = nil + let token = UUID() + attemptID = token + let frame: Data + switch request.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)) + serial = encoded.serial + frame = encoded.frame + case let .jieli(payload): frame = jlCodec.encode(payload: payload) + } + writer.enqueueTracked(frame) { [weak self] result in + guard let self, self.attemptID == token, self.inFlight?.id == request.id else { return } + switch result { + case .success: + self.writeConfirmed = true + if self.received != nil { self.completeIfReady() } else { self.armTimeout() } + case let .failure(error): self.finish(.failure(error)) + } } - armTimeout() } private func armTimeout() { timeoutTask?.cancel() + let seconds = responseTimeout ?? (framing == .jieli ? 5 : 2) timeoutTask = Task { [weak self] in - let nanos = UInt64((self?.responseTimeout ?? 2) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 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) + let maxAttempts: Int + if case let .jieli(payload)? = self.inFlight?.command, payload.count >= 3, payload[2] == 0x30 { + // A lost delete reply is ambiguous. Repeating a consume could erase the next page. + maxAttempts = 1 + } else { maxAttempts = self.framing == .jieli ? 3 : 2 } + if self.attempts < maxAttempts { + rwfitDiagnostic("Retrying command", ["attempt": String(self.attempts + 1), "framing": self.framing.rawValue]) + self.send() + } else { self.finish(.failure(RWfitSessionError.timeout)) } } } - private func release() { + private func finish(_ result: Result<[UInt8], Error>) { + guard let request = inFlight else { return } 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 + received = nil + attemptID = UUID() + let seconds = framing == .jieli ? 0.23 : 0.1 spacingTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(spacing * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) guard !Task.isCancelled, let self else { return } self.spacingTask = nil self.pump() } + request.completion(result) } } diff --git a/PulseLoop/RingProtocol/RWfitCoordinator.swift b/PulseLoop/RingProtocol/RWfitCoordinator.swift index 165403bf..b7880835 100644 --- a/PulseLoop/RingProtocol/RWfitCoordinator.swift +++ b/PulseLoop/RingProtocol/RWfitCoordinator.swift @@ -43,20 +43,13 @@ final class RWfitCoordinator: WearableCoordinator { } } - /// 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, - ] + /// Until initialization verifies the per-unit feature menu, only battery is advertised. + let capabilities: Set = [.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. + /// Sensor and manual capabilities are granted from the validated feature menu. Legacy rings + /// can expose supported history sensors but never the modern on-demand commands. let bitmapGatedCapabilities: Set = [ + .heartRate, .spo2, .steps, .sleep, .remSleep, .temperature, .bloodPressure, .manualBloodPressure, .hrv, .manualHrv, .stress, .bloodSugar, .realtimeHeartRate, .manualHeartRate, .manualSpo2, diff --git a/PulseLoop/RingProtocol/RWfitDecoder.swift b/PulseLoop/RingProtocol/RWfitDecoder.swift index 75c4b271..cbd64982 100644 --- a/PulseLoop/RingProtocol/RWfitDecoder.swift +++ b/PulseLoop/RingProtocol/RWfitDecoder.swift @@ -206,6 +206,13 @@ struct RWfitDecoder { return [.firmware(version: payload[3...5].map(String.init).joined(separator: "."))] case (0x02, 0x01): return [.timeSyncAck(timestamp: Date())] + case (0x02, 0x63): + guard let menu = RWfitFunctionMenu(payload: payload) else { + return [.unknown(commandId: triple.cmd, raw: Data(payload))] + } + return [.supportFunctions(menu.capabilities)] + case (0x02, _): + return decodeJieliLive(key: triple.key, payload: payload) case (0x03, 0x01): return decodeJieliBind(payload) case (0x05, _): @@ -222,6 +229,8 @@ struct RWfitDecoder { /// project's cyclomatic-complexity limit. nil ⇒ a `05` type we don't decode. private func decodeJieliHistory(key: UInt8, payload: [UInt8]) -> [RingDecodedEvent]? { switch key { + case 0x1a: + return decodeJieliTodaySteps(payload) case RWfitJLDataType.steps: return decodeJieliSteps(payload) case RWfitJLDataType.sleep: @@ -244,11 +253,11 @@ struct RWfitDecoder { ] } case RWfitJLDataType.temperature: - // `[4..5]` u16 BE ÷ 10 °C (`U()`). + // SDK: integer Celsius byte + floor(hundredths byte / 10) / 10. 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)] + let value = Double(item[4]) + Double(item[5] / 10) / 10 + return value > 0 + ? [.historyMeasurement(kind: .temperature, value: value, timestamp: ts)] : [] } case RWfitJLDataType.hrv: @@ -277,34 +286,98 @@ struct RWfitDecoder { /// 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 [.bind(action: payload[3], state: 0)] + } + + enum HistoryDecodeError: Error { + case unsupportedType, malformedPage, incompleteSleep + } + + /// Validate the whole page before allowing destructive history consumption. + func validatedJieliHistory(key: UInt8, payload: [UInt8]) throws -> [RingDecodedEvent] { + guard payload.count >= 3, payload[0] == 0x05, payload[1] == key else { + throw HistoryDecodeError.malformedPage + } + if payload.count == 3 { return [] } + var payload = payload + let bodyCount = payload.count - 3 + switch key { + case 0x1a: + guard bodyCount >= 16, (bodyCount - 16).isMultiple(of: 16) else { + throw HistoryDecodeError.malformedPage + } + case RWfitJLDataType.steps: + guard bodyCount.isMultiple(of: 16) else { throw HistoryDecodeError.malformedPage } + case RWfitJLDataType.sleep: + try Self.validateJieliSleepPage(payload) + payload = normalizedSleepPayload(payload) + try validateSleepSessions(payload) + case 0x03, 0x04, 0x08, 0x09, 0x0a, 0x0d, 0x10: + guard bodyCount.isMultiple(of: 6) else { throw HistoryDecodeError.malformedPage } + default: throw HistoryDecodeError.unsupportedType + } + return (decodeJieliHistory(key: key, payload: payload) ?? []).filter { + if case .commandAck = $0 { return false } + return true } - 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 - } + static func validateJieliSleepPage(_ payload: [UInt8]) throws { + guard payload.count >= 3, payload[0] == 0x05, payload[1] == 0x05, + (payload.count - 3).isMultiple(of: 7) else { throw HistoryDecodeError.malformedPage } + } + + private func normalizedSleepPayload(_ payload: [UInt8]) -> [UInt8] { + // Pagination can overlap across reconnects. Temporary/padding flags may change while the + // timestamp and sleep model still identify the same transition; keep the latest copy. + var unique: [[UInt8]: [UInt8]] = [:] + for offset in stride(from: 3, to: payload.count, by: 7) { + let record = Array(payload[offset..<(offset + 7)]) + unique[Array(record.prefix(5))] = record + } + let records = unique.values.sorted { RWfitBytes.u32BE($0, 0) < RWfitBytes.u32BE($1, 0) } + return Array(payload.prefix(3)) + records.flatMap { $0 } + } + + /// Sleep transitions may cross pages. Call this only after assembling durably journaled pages. + private func validateSleepSessions(_ payload: [UInt8]) throws { + var open = false + var previous: UInt32? + for offset in stride(from: 3, to: payload.count, by: 7) { + let model = payload[offset + 4] + let timestamp = RWfitBytes.u32BE(payload, offset) + if let previous { + guard timestamp > previous else { throw HistoryDecodeError.malformedPage } + if open, timestamp - previous >= 24 * 60 * 60 { throw HistoryDecodeError.malformedPage } + } + previous = timestamp + if model == 0x11 { + guard !open else { throw HistoryDecodeError.incompleteSleep } + open = true + } else { + guard open else { throw HistoryDecodeError.incompleteSleep } + if model == 0x22 { open = false } + else if ![0, 1, 2, 3, 4].contains(model) { throw HistoryDecodeError.malformedPage } } - index += 2 } - return caps + if open { throw HistoryDecodeError.incompleteSleep } + } + + /// SDK 051A contains a daily total followed by hourly records. Preserve the details before + /// consuming the stream; persistence upserts buckets and ratchets the daily cumulative total. + private func decodeJieliTodaySteps(_ payload: [UInt8]) -> [RingDecodedEvent] { + guard payload.count >= 19 else { return [] } + let detail = [UInt8](payload.prefix(3)) + payload.dropFirst(19) + let buckets = decodeJieliSteps(detail).filter { + if case .activityBucket = $0 { return true } + return false + } + return buckets + [.activityUpdate( + timestamp: clock.date(fromJieliEpoch: RWfitBytes.u32BE(payload, 3)), + steps: RWfitBytes.u24BE(payload, 8), + distanceMeters: Double(RWfitBytes.u32BE(payload, 15)) / 10, + calories: Double(RWfitBytes.u32BE(payload, 11)) / 10 + )] } /// JieLi 6-byte-stride series template: items from offset 3, `[ts2000 u32][value][…]` @@ -395,19 +468,34 @@ struct RWfitDecoder { } } - /// 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. + /// 0609 carries progress/completion status, never a vital reading. 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))] + guard payload.count >= 6 else { return [.unknown(commandId: 0x06, raw: Data(payload))] } + return [.rwfitMeasurementStatus(type: payload[3], status: payload[5])] + } + + /// SDK live health notifications use the same six-byte timestamp/value records as history. + private func decodeJieliLive(key: UInt8, payload: [UInt8]) -> [RingDecodedEvent] { + guard payload.count > 3, (payload.count - 3).isMultiple(of: 6) else { + return [.unknown(commandId: 0x02, raw: Data(payload))] + } + return decodeJieliSeries(payload, cmd: key) { timestamp, item in + switch key { + case 0x24: return item[4] > 0 ? [.heartRateSample(bpm: Int(item[4]), timestamp: timestamp)] : [] + case 0x4e: return item[4] > 0 ? [.spo2Result(value: Int(item[4]), timestamp: timestamp)] : [] + case 0x69: return item[4] > 0 ? [.hrvSample(value: Int(item[4]), timestamp: timestamp)] : [] + case 0x4f: return item[4] > 0 ? [.stressSample(value: Int(item[4]), timestamp: timestamp)] : [] + case 0x31: + guard item[4] > 0, item[5] > 0 else { return [] } + return [.bloodPressureSample(systolic: Int(item[4]), diastolic: Int(item[5]), timestamp: timestamp)] + case 0x30: + let value = Double(item[4]) + Double(item[5] / 10) / 10 + return value > 0 ? [.temperatureSample(celsius: value, timestamp: timestamp)] : [] + case 0x6c: + let value = Double(RWfitBytes.u16BE(item, 4)) / 10 * 18.016 + return value > 0 ? [.bloodSugarSample(mgdl: value, timestamp: timestamp)] : [] + default: return [.unknown(commandId: 0x02, raw: Data(payload))] + } } } } diff --git a/PulseLoop/RingProtocol/RWfitDriver.swift b/PulseLoop/RingProtocol/RWfitDriver.swift index 1086fef8..0c2df095 100644 --- a/PulseLoop/RingProtocol/RWfitDriver.swift +++ b/PulseLoop/RingProtocol/RWfitDriver.swift @@ -1,173 +1,144 @@ 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`). +/// Both RwFit protocols share A00A/B002/B003. OTA services are an initial hint, not +/// proof of framing; only a checksum-validated response confirms the active protocol. @MainActor final class RWfitDriver: WearableDriver { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - + nonisolated deinit {} 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 weak var engine: RWfitSyncEngine? 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, - ] + private(set) var framingValidated = false + private var ready = false 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) + decoder = RWfitDecoder(clock: clock) + gate = RWfitCommandGate(writer: writer, legacyCodec: legacyCodec, jlCodec: jlCodec) + historySync = RWfitHistorySync(gate: gate, clock: clock) } - // MARK: - BLE topology - - let serviceUUIDs: [CBUUID] = [CBUUID(string: RWfitUUIDs.service)] + let serviceUUIDs = [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 notifyUUIDs = [CBUUID(string: RWfitUUIDs.notify)] + let batteryServiceUUID: CBUUID? = nil 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 + let markers = [RWfitUUIDs.jieli, RWfitUUIDs.telinkOTA, RWfitUUIDs.pixartOTA].map { CBUUID(string: $0) } + select(services.contains(where: markers.contains) ? .jieli : .legacy) + rwfitDiagnostic("Services discovered", ["services": services.map(\.uuidString).joined(separator: ","), + "initialFraming": framing.rawValue]) + } + + private func select(_ framing: RWfitFraming) { + self.framing = framing gate.framing = framing historySync.framing = framing } - // MARK: - Lifecycle + private func validate(_ framing: RWfitFraming) { + guard !framingValidated else { return } + select(framing) + framingValidated = true + rwfitDiagnostic("Protocol validated", ["framing": framing.rawValue]) + } - /// 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 + connectionDidEnd() + framingValidated = 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() { + ready = false + engine?.cancel() 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)) + guard characteristic == notifyUUIDs[0] else { return [] } + // Until a response validates a framing, feed both bounded codecs. This also handles a + // modern ring with no OTA sibling service and notifications arriving before startup. + if !framingValidated { + let modern = jlCodec.decode(data) + if modern.contains(where: { if case .frame = $0 { return true }; return false }) { + validate(.jieli) + return ingestModern(modern) + } + return ingestLegacy(legacyCodec.decode(data)) } - return events + return framing == .jieli ? ingestModern(jlCodec.decode(data)) : ingestLegacy(legacyCodec.decode(data)) } - private func ingestLegacy(_ data: Data) -> [RingDecodedEvent] { + private func ingestLegacy(_ inbound: [RWfitLegacyInbound]) -> [RingDecodedEvent] { var events: [RingDecodedEvent] = [] - for inbound in legacyCodec.decode(data) { - switch inbound { + for item in inbound { + switch item { case let .ackNeeded(cmd, serial): - // ACK before decode — the ring retransmits until we answer. - writer?.enqueue(legacyCodec.ack(cmd: cmd, serial: serial, status: 0x00)) + writer?.enqueue(legacyCodec.ack(cmd: cmd, serial: serial, status: 0)) 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) + writer?.enqueue(legacyCodec.ack(cmd: cmd, serial: serial, status: 2)) + rwfitDiagnostic("Legacy checksum failed") + case let .deviceAck(cmd, serial, status): + gate.noteLegacyAck(cmd: cmd, serial: serial, status: status) case let .frame(cmd, payload): - if let type = RWfitHistoryType(legacyCommand: cmd) { - historySync.noteReceived(type: type) + validate(.legacy) + if let type = RWfitHistoryType(legacyCommand: cmd), historySync.isRunning { + historySync.noteReceived(type: type, payload: payload) + gate.noteLegacyFrame(cmd: cmd, payload: payload) + continue + } + gate.noteLegacyFrame(cmd: cmd, payload: payload) + events += decoder.decodeLegacy(cmd: cmd, payload: payload).filter { event in + if case .supportFunctions = event { return ready }; return true } - events.append(contentsOf: intercept(decoder.decodeLegacy(cmd: cmd, payload: payload))) } } return events } - private func ingestJieli(_ data: Data) -> [RingDecodedEvent] { + private func ingestModern(_ inbound: [RWfitJLInbound]) -> [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) + for item in inbound { + switch item { + case .crcFailed: rwfitDiagnostic("Modern frame CRC failed") + case let .frame(flag, triple, payload): + if flag == 0x01 { writer?.enqueue(jlCodec.ack(triple: triple)) } + gate.noteJieliFrame(flag: flag, triple: triple, payload: payload) + // History response ownership belongs to the pager's awaited persistence boundary. + // Publishing it here would acknowledge storage before SwiftData had saved it. + if triple.cmd == 0x05 { continue } + events += decoder.decodeJieli(triple: triple, payload: payload).filter { event in + if case .supportFunctions = event { return false } + return true } - 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 - }) + let result = RWfitSyncEngine(gate: gate, historySync: historySync, clock: clock, + framingProvider: { [weak self] in self?.framing ?? .legacy }, + selectFraming: { [weak self] in self?.select($0) }, + readiness: { [weak self] capabilities in + guard let self else { return } + self.ready = capabilities != nil + self.writer?.emit(.supportFunctions(capabilities ?? [])) + }, deviceIdentifier: { [weak self] in self?.writer?.deviceIdentifier }) + engine = result + return result } } diff --git a/PulseLoop/RingProtocol/RWfitEncoder.swift b/PulseLoop/RingProtocol/RWfitEncoder.swift index c91b57f1..641173b5 100644 --- a/PulseLoop/RingProtocol/RWfitEncoder.swift +++ b/PulseLoop/RingProtocol/RWfitEncoder.swift @@ -18,6 +18,24 @@ struct RWfitEncoder { /// stores and echoes it. UTF-16LE on the wire (`y5/b.java m()`). static let bindUserID = "PL" + // SDK startup commands; session key flag is 0x20, password authentication is 0x10. + func sessionInitialize() -> RWfitOutbound { + .jieli(payload: [0x03, 0x02, 0x20, 0, 0, 0, 1]) + } + + func timezone(offsetSeconds: Int) -> RWfitOutbound { + let quarters = Int8(clamping: Int((Double(offsetSeconds) / 900).rounded())) + return .jieli(payload: [0x02, 0x02, 0x00, UInt8(bitPattern: quarters), 0x01]) + } + + func functionMenu() -> RWfitOutbound { .jieli(payload: [0x02, 0x63, 0x10]) } + + func authenticate() -> RWfitOutbound { + .jieli(payload: [0x03, 0x04, 0x10] + Array("0000".utf8)) + } + + func historyDelete(type: UInt8) -> RWfitOutbound { .jieli(payload: [0x05, type, 0x30]) } + // MARK: - Clock /// Set the ring's RTC from **local** calendar components — both firmwares stamp history off this @@ -48,10 +66,9 @@ struct RWfitEncoder { 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`. + /// Capability discovery: legacy SupportMenuBean or the modern SDK 0263 function menu. func features(framing: RWfitFraming) -> RWfitOutbound { - request(framing, legacy: RWfitLegacyCommand.features, jl: .bindStatus) + request(framing, legacy: RWfitLegacyCommand.features, jl: RWfitJLTriple(cmd: 0x02, key: 0x63, keyFlag: 0x10)) } func bindStatus(framing: RWfitFraming) -> RWfitOutbound { diff --git a/PulseLoop/RingProtocol/RWfitHistoryPersistence.swift b/PulseLoop/RingProtocol/RWfitHistoryPersistence.swift new file mode 100644 index 00000000..cb6f14b0 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitHistoryPersistence.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Durable storage boundary for ring history. A missing subscriber must never imply a save. +@MainActor +enum RWfitHistoryPersistence { + private static let maximumJournalBytes = 4 * 1024 * 1024 + static var save: (([RingDecodedEvent]) throws -> Void)? + + enum PersistenceError: LocalizedError { + case unavailable + case rejectedRecord + case invalidDevice + + var errorDescription: String? { + switch self { + case .unavailable: return "History storage is not ready. Please retry syncing." + case .rejectedRecord: return "The ring returned a history record that could not be imported." + case .invalidDevice: return "The ring identity is unavailable. Please reconnect." + } + } + } + + private struct SleepPage: Codable { + let id: UUID + let payload: [UInt8] + } + + /// Injectable only for isolated persistence tests. Production uses backup-eligible Application Support. + static var journalDirectoryOverride: URL? + + private static func journalURL(deviceID: String) throws -> URL { + guard let identifier = UUID(uuidString: deviceID) else { throw PersistenceError.invalidDevice } + let directory: URL + if let override = journalDirectoryOverride { + directory = override + } else { + directory = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + .appendingPathComponent("RWfitSleepJournal", isDirectory: true) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent(identifier.uuidString).appendingPathExtension("json") + } + + private static func pages(deviceID: String) throws -> [SleepPage] { + let url = try journalURL(deviceID: deviceID) + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= maximumJournalBytes else { throw PersistenceError.rejectedRecord } + let stored = try JSONDecoder().decode([SleepPage].self, from: Data(contentsOf: url)) + guard stored.allSatisfy({ validSleepPage($0.payload) }) else { throw PersistenceError.rejectedRecord } + return stored + } + + /// Commit before consumption. Byte-identical replays after ambiguous deletion are deduplicated; + /// record timestamps distinguish real adjacent pages. + static func stageSleepPage(_ payload: [UInt8], deviceID: String, pageID: UUID) throws { + guard validSleepPage(payload) else { throw PersistenceError.rejectedRecord } + var stored = try pages(deviceID: deviceID) + guard !stored.contains(where: { $0.id == pageID || $0.payload == payload }) else { return } + stored.append(SleepPage(id: pageID, payload: payload)) + let data = try JSONEncoder().encode(stored) + guard data.count <= maximumJournalBytes else { throw PersistenceError.rejectedRecord } + try data.write(to: journalURL(deviceID: deviceID), + options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + private static func validSleepPage(_ payload: [UInt8]) -> Bool { + payload.count > 3 && payload.count <= 8 * 1024 && (payload.count - 3).isMultiple(of: 7) + } + + /// Reset pending imports together with the user's health store, so erased sleep cannot reappear. + static func clearAllSleepPages() throws { + let sampleURL = try journalURL(deviceID: UUID().uuidString) + let directory = sampleURL.deletingLastPathComponent() + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + static func sleepPayload(deviceID: String) throws -> [UInt8] { + let stored = try pages(deviceID: deviceID) + guard let first = stored.first else { return [] } + return Array(first.payload.prefix(3)) + stored.flatMap { $0.payload.dropFirst(3) } + } + + /// Call only after the aggregate's decoded sessions have been saved successfully. + static func clearSleepPages(deviceID: String) throws { + let url = try journalURL(deviceID: deviceID) + if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + } +} diff --git a/PulseLoop/RingProtocol/RWfitHistorySync.swift b/PulseLoop/RingProtocol/RWfitHistorySync.swift index aaa7fc1d..f57610a5 100644 --- a/PulseLoop/RingProtocol/RWfitHistorySync.swift +++ b/PulseLoop/RingProtocol/RWfitHistorySync.swift @@ -1,136 +1,206 @@ 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. +/// Response-driven history transfer. Modern history is consumed only after a durable save; +/// sleep pages are journaled because a single session can span multiple ring pages. @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. + nonisolated deinit {} static let catalog: [RWfitHistoryType] = [ - .steps, .sleep, .heartRate, .bloodPressure, .spo2, .temperature, .breathe, - .hrv, .stress, .bloodSugar, + .todaySteps, .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 decoder: RWfitDecoder + private let encoder = RWfitEncoder() 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 task: Task? + private var generation = UUID() + private var legacyFrames: [[UInt8]] = [] + private var lastLegacyFrameAt = Date.distantPast + private var importedRecords = 0 private var currentType: RWfitHistoryType? - private var settleTask: Task? - private var stallTask: Task? + private var atBoundary = true + var isPaused = false + var framing: RWfitFraming = .legacy + private(set) var isRunning = false + var deviceIdentifier: String? + var persist: ([RingDecodedEvent]) throws -> Void = { events in + guard let save = RWfitHistoryPersistence.save else { throw RWfitSessionError.persistence } + try save(events) + } - init( - gate: RWfitCommandGate, - settleSeconds: TimeInterval = 1.5, - stallSeconds: TimeInterval = 6, - progressSink: ((PulseEvent) -> Void)? = nil - ) { + init(gate: RWfitCommandGate, clock: RWfitClock = RWfitClock(), + settleSeconds: TimeInterval = 1.5, stallSeconds: TimeInterval = 6, + progressSink: ((PulseEvent) -> Void)? = nil) { self.gate = gate + self.decoder = RWfitDecoder(clock: clock) 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) } - } + 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() + task = Task { [weak self] in _ = await self?.run(types: types) } } - /// Abandon any in-flight pass (disconnect / teardown). func cancel() { - cancelTimers() + generation = UUID() + task?.cancel(); task = nil + isRunning = false + isPaused = false + atBoundary = true currentType = nil - queue.removeAll() + legacyFrames.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() + func noteReceived(type: RWfitHistoryType, payload: [UInt8] = []) { + guard framing == .legacy, currentType == type else { return } + legacyFrames.append(payload) + lastLegacyFrameAt = Date() } - // 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 + func waitUntilPaused() async throws { + while isRunning && !atBoundary { + try await Task.sleep(nanoseconds: 25_000_000) } - guard let request, let type else { - currentType = nil - publish(.syncProgress(stage: "done")) - return + } + + private func boundary(_ token: UUID) async throws { + atBoundary = true + while isPaused { + try check(token) + try await Task.sleep(nanoseconds: 25_000_000) } - currentType = type - publish(.syncProgress(stage: "Syncing \(type.label)…")) - gate.submit(request) - armStall() + try check(token) + atBoundary = false + } + + private func check(_ token: UUID) throws { + try Task.checkCancellation() + guard token == generation else { throw CancellationError() } } - 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() + @discardableResult + func run(types: [RWfitHistoryType]) async -> RWfitSyncOutcome { + guard !isRunning else { return .cancelled } + isRunning = true + let token = UUID() + generation = token + importedRecords = 0 + let outcome: RWfitSyncOutcome + var failures: [String] = [] + do { + for type in types { + guard encoder.historyRequest(framing: framing, type: type) != nil else { continue } + try await boundary(token) + currentType = type + publish(.syncProgress(stage: "Syncing \(type.label)…")) + do { + if framing == .jieli { + _ = try await modern(type: type, token: token) + } else { + _ = try await legacy(type: type, token: token) + } + } catch is CancellationError { throw CancellationError() } + catch { + failures.append("\(type.label): \(error.localizedDescription)") + rwfitDiagnostic("History stream incomplete", ["type": type.label, "reason": error.localizedDescription]) + } + } + try check(token) + if failures.isEmpty { outcome = .success(records: importedRecords) } + else if importedRecords > 0 { outcome = .partial(records: importedRecords, reason: failures.joined(separator: " ")) } + else { outcome = .failed(reason: failures.joined(separator: " ")) } + } catch is CancellationError { + outcome = .cancelled + } catch { + outcome = importedRecords > 0 ? .partial(records: importedRecords, reason: error.localizedDescription) + : .failed(reason: error.localizedDescription) + } + if generation == token { + isRunning = false + atBoundary = true + currentType = nil + publish(.rwfitSyncOutcome(outcome)) } + return outcome } - 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 modern(type: RWfitHistoryType, token: UUID) async throws -> Int { + guard let key = type.jlType, let request = encoder.historyRequest(framing: .jieli, type: type), + let deviceIdentifier else { throw RWfitSessionError.unavailable } + var combined = RWfitJLTriple.historySync(type: key).bytes + var pages = 0 + let deadline = Date().addingTimeInterval(180) + while true { + try await boundary(token) + guard Date() < deadline else { throw RWfitSessionError.timeout } + let payload = try await gate.execute(request) + try check(token) + guard payload.count >= 3 else { throw RWfitSessionError.invalidResponse } + pages += 1 + // A broken device must not keep the UI alive by replaying pages forever. + guard pages <= 4096, combined.count <= 8 * 1024 * 1024 else { throw RWfitSessionError.invalidResponse } + let terminal = payload.count == 3 + if type == .sleep { + if !terminal { + try RWfitDecoder.validateJieliSleepPage(payload) + try RWfitHistoryPersistence.stageSleepPage(payload, deviceID: deviceIdentifier, pageID: UUID()) + } + // Deleting advances this stream. The raw page is durable even if the session has + // not ended yet; the journal survives disconnection, failed saves, and app restart. + _ = try await gate.execute(encoder.historyDelete(type: key), needsPayload: false) + try check(token) + } else if !terminal { + _ = try decoder.validatedJieliHistory(key: key, payload: payload) + combined.append(contentsOf: payload.dropFirst(3)) + } + publish(.syncProgress(stage: "Syncing \(type.label)…")) + rwfitDiagnostic("History page received", ["type": type.label, "page": String(pages), "bytes": String(payload.count)]) + if terminal { break } } + let payload = type == .sleep ? try RWfitHistoryPersistence.sleepPayload(deviceID: deviceIdentifier) : combined + let events = payload.isEmpty ? [] : try decoder.validatedJieliHistory(key: key, payload: payload) + try persist(events) + importedRecords += events.count + try check(token) + if type == .sleep { + try RWfitHistoryPersistence.clearSleepPages(deviceID: deviceIdentifier) + } else { + _ = try await gate.execute(encoder.historyDelete(type: key), needsPayload: false) + } + rwfitDiagnostic("History saved", ["type": type.label, "records": String(events.count)]) + return events.count } - private func cancelTimers() { - settleTask?.cancel(); settleTask = nil - stallTask?.cancel(); stallTask = nil + private func legacy(type: RWfitHistoryType, token: UUID) async throws -> Int { + guard let command = type.legacyCommand, + let request = encoder.historyRequest(framing: .legacy, type: type) else { return 0 } + legacyFrames.removeAll() + _ = try await gate.execute(request) + // Legacy streams push subsequent frames. Silence after an actual response is a settle + // boundary; silence before any response is a failed transaction, never empty history. + let deadline = Date().addingTimeInterval(60) + repeat { + try await Task.sleep(nanoseconds: UInt64(settleSeconds * 1_000_000_000)) + try check(token) + guard Date() < deadline else { throw RWfitSessionError.timeout } + } while Date().timeIntervalSince(lastLegacyFrameAt) < settleSeconds + let events = legacyFrames.flatMap { decoder.decodeLegacy(cmd: command, payload: $0) } + .filter { if case .commandAck = $0 { return false }; return true } + guard !events.contains(where: { if case .unknown = $0 { return true }; return false }) else { + throw RWfitSessionError.invalidResponse + } + try persist(events) + importedRecords += events.count + return events.count } } diff --git a/PulseLoop/RingProtocol/RWfitJLCodec.swift b/PulseLoop/RingProtocol/RWfitJLCodec.swift index f43287bc..09e73bbb 100644 --- a/PulseLoop/RingProtocol/RWfitJLCodec.swift +++ b/PulseLoop/RingProtocol/RWfitJLCodec.swift @@ -2,19 +2,15 @@ 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) + /// CRC-verified response or push. Payload retains the addressing triple and response body. + case frame(flag: UInt8, triple: RWfitJLTriple, payload: [UInt8]) /// 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`. +/// Matches the official open-source SDK framing and ACK contract (commit pinned below). +/// https://github.com/RWFitSDK/RW_weixi_miniprogram_sdk/blob/8613daec2c08a41fa6c0bf5476af4f125e1532e5/RW_SDK_DEMO/sdk/rw-ble-sdk.min.js /// /// 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 @@ -24,20 +20,10 @@ enum RWfitJLInbound: Equatable { 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 + static let maximumPayloadLength = 8 * 1024 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 - } + func reset() { buffer.removeAll() } // MARK: - Encode @@ -53,47 +39,43 @@ final class RWfitJLCodec { 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). + /// The SDK acknowledges flag-01 frames with flag 11 and exactly the echoed three-byte triple, + /// including measurement status 0609. Pushes and flag-11 responses are never acknowledged. 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) + encode(payload: triple.bytes, 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) + buffer.append(contentsOf: data) + var events: [RWfitJLInbound] = [] + while !buffer.isEmpty { + guard let magic = buffer.firstIndex(of: 0xab) else { + buffer.removeAll() + break + } + if magic > 0 { buffer.removeFirst(magic) } + guard buffer.count >= 6 else { break } + let flag = buffer[1] + let length = RWfitBytes.u16BE(buffer, 2) + guard [0x01, 0x11, 0x21].contains(flag), (3...Self.maximumPayloadLength).contains(length) else { + buffer.removeFirst() + continue + } + guard buffer.count >= length + 6 else { break } + let payload = Array(buffer[6..<(length + 6)]) + let expectedCRC = UInt16(buffer[4]) << 8 | UInt16(buffer[5]) + guard RWfitBytes.crc16ARC(payload) == expectedCRC else { + events.append(.crcFailed) + buffer.removeFirst() + continue + } + buffer.removeFirst(length + 6) + let triple = RWfitJLTriple(cmd: payload[0], key: payload[1], keyFlag: payload[2]) + events.append(.frame(flag: flag, triple: triple, payload: payload)) } - - 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)] + return events } } diff --git a/PulseLoop/RingProtocol/RWfitProtocol.swift b/PulseLoop/RingProtocol/RWfitProtocol.swift index 98c7a429..45e16f22 100644 --- a/PulseLoop/RingProtocol/RWfitProtocol.swift +++ b/PulseLoop/RingProtocol/RWfitProtocol.swift @@ -10,10 +10,9 @@ import Foundation /// - **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`). +/// Service markers are hints; the driver confirms framing using a CRC/checksum-verified response. +/// Modern protocol reference (startup, menu, live values and history consumption): +/// https://github.com/RWFitSDK/RW_weixi_miniprogram_sdk/blob/8613daec2c08a41fa6c0bf5476af4f125e1532e5/RW_SDK_DEMO/sdk/rw-ble-sdk.min.js enum RWfitUUIDs { /// Primary data service, both framings (`y5/a.java f19994a`). static let service = "0000a00a-0000-1000-8000-00805f9b34fb" @@ -32,9 +31,7 @@ enum RWfitUUIDs { 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. +/// The two wire framings served by `RWfitDriver`, confirmed by validated inbound frames. enum RWfitFraming: String, Sendable { case legacy case jieli @@ -117,7 +114,7 @@ enum RWfitJLDataType { /// 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 + case todaySteps, 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). @@ -130,7 +127,7 @@ enum RWfitHistoryType: CaseIterable, Sendable { case .spo2: return RWfitLegacyCommand.spo2History case .temperature: return RWfitLegacyCommand.temperatureHistory case .breathe: return RWfitLegacyCommand.breatheHistory - case .hrv, .stress, .bloodSugar: return nil + case .todaySteps, .hrv, .stress, .bloodSugar: return nil } } @@ -138,6 +135,7 @@ enum RWfitHistoryType: CaseIterable, Sendable { /// (breathe is legacy-only). var jlType: UInt8? { switch self { + case .todaySteps: return 0x1a case .steps: return RWfitJLDataType.steps case .sleep: return RWfitJLDataType.sleep case .heartRate: return RWfitJLDataType.heartRate @@ -153,6 +151,7 @@ enum RWfitHistoryType: CaseIterable, Sendable { var label: String { switch self { + case .todaySteps: return "today’s activity" case .steps: return "activity" case .sleep: return "sleep" case .heartRate: return "heart rate" @@ -181,6 +180,41 @@ enum RWfitHistoryType: CaseIterable, Sendable { } } +/// The SDK's 0263 menu uses bit zero at absolute payload offsets (including the triple). +struct RWfitFunctionMenu { + let historyTypes: Set + let capabilities: Set + let requiresPassword: Bool + + init?(payload: [UInt8]) { + guard payload.count >= 0x5f else { return nil } + requiresPassword = payload[0x2c] & 1 != 0 + var streams: Set = [] + var caps: Set = [] + if payload[0x53] & 1 != 0 { + let offsets: [(Int, RWfitHistoryType)] = [ + (0x54, .steps), (0x55, .heartRate), (0x56, .bloodPressure), (0x57, .sleep), + (0x59, .spo2), (0x5a, .hrv), (0x5b, .stress), (0x5c, .bloodSugar), (0x5e, .temperature), + ] + for (offset, type) in offsets where payload[offset] & 1 != 0 { streams.insert(type) } + } + if streams.contains(.steps) { + streams.insert(.todaySteps) + caps.insert(.steps) + } + if streams.contains(.sleep) { caps.formUnion([.sleep, .remSleep]) } + if streams.contains(.heartRate) { caps.formUnion([.heartRate, .manualHeartRate, .realtimeHeartRate]) } + if streams.contains(.spo2) { caps.formUnion([.spo2, .manualSpo2]) } + if streams.contains(.bloodPressure) { caps.formUnion([.bloodPressure, .manualBloodPressure]) } + if streams.contains(.hrv) { caps.formUnion([.hrv, .manualHrv]) } + if streams.contains(.stress) { caps.insert(.stress) } + if streams.contains(.bloodSugar) { caps.insert(.bloodSugar) } + if streams.contains(.temperature) { caps.insert(.temperature) } + historyTypes = streams + capabilities = caps + } +} + /// 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 diff --git a/PulseLoop/RingProtocol/RWfitSessionTypes.swift b/PulseLoop/RingProtocol/RWfitSessionTypes.swift new file mode 100644 index 00000000..44f51d90 --- /dev/null +++ b/PulseLoop/RingProtocol/RWfitSessionTypes.swift @@ -0,0 +1,40 @@ +import Foundation + +enum RWfitInitializationState: Sendable, Equatable { + case initializing + case ready + case failed(String) +} + +enum RWfitSyncOutcome: Sendable, Equatable { + case success(records: Int) + case partial(records: Int, reason: String) + case failed(reason: String) + case cancelled +} + +enum RWfitMeasurementOutcome: Sendable, Equatable { + case failed(type: UInt8, reason: String) + case completed(type: UInt8, receivedReading: Bool) + case cancelled(type: UInt8) +} + +enum RWfitSessionError: LocalizedError { + case unavailable, timeout, cancelled, invalidResponse, authentication, persistence + + var errorDescription: String? { + switch self { + case .unavailable: return "The ring is not ready. Reconnect and try again." + case .timeout: return "The ring did not respond. Keep it nearby and try syncing again." + case .cancelled: return "The ring operation was cancelled." + case .invalidResponse: return "The ring returned an incomplete or unrecognized response." + case .authentication: return "The ring rejected its default password. Check its password in RwFit and reconnect." + case .persistence: return "History could not be saved. The ring's records have been preserved." + } + } +} + +@MainActor +func rwfitDiagnostic(_ message: String, _ metadata: [String: String] = [:]) { + Task { await PulseEventBus.shared.publish(.rwfitDiagnostic(message: message, metadata: metadata)) } +} diff --git a/PulseLoop/RingProtocol/RWfitSyncEngine.swift b/PulseLoop/RingProtocol/RWfitSyncEngine.swift index d8cd777f..e244eca6 100644 --- a/PulseLoop/RingProtocol/RWfitSyncEngine.swift +++ b/PulseLoop/RingProtocol/RWfitSyncEngine.swift @@ -1,139 +1,322 @@ 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. +/// RwFit initialization and business operations share one serialized transaction gate. Business +/// operations are unavailable until the device has answered its function menu and authentication. @MainActor final class RWfitSyncEngine: RingSyncEngine { - nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) - + nonisolated deinit {} private let gate: RWfitCommandGate private let historySync: RWfitHistorySync private let clock: RWfitClock private let framingProvider: () -> RWfitFraming + private let selectFraming: (RWfitFraming) -> Void + private let readiness: (Set?) -> Void + private let deviceIdentifier: () -> String? 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 startupTask: Task? + private var historyTask: Task? + private var measurementTask: Task? + private var measurementTimeout: Task? + private var measurementType: UInt8? + private var measurementHasValue = false + private var stopping = false + private var measurementStarted = false + private var measurementID = UUID() + private var session = UUID() + private var streams: [RWfitHistoryType] = [] + private var metadataPending = false + private var capabilities: Set = [] + private(set) var isReady = false 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 - ) { + init(gate: RWfitCommandGate, historySync: RWfitHistorySync, clock: RWfitClock, + framingProvider: @escaping () -> RWfitFraming, + selectFraming: @escaping (RWfitFraming) -> Void = { _ in }, + readiness: @escaping (Set?) -> Void = { _ in }, + deviceIdentifier: @escaping () -> String? = { nil }) { self.gate = gate self.historySync = historySync self.clock = clock self.framingProvider = framingProvider + self.selectFraming = selectFraming + self.readiness = readiness + self.deviceIdentifier = deviceIdentifier } private var framing: RWfitFraming { framingProvider() } - // MARK: - Startup + func cancel() { + if let type = measurementType { publish(.rwfitMeasurementOutcome(.cancelled(type: type))) } + session = UUID() + startupTask?.cancel(); startupTask = nil + historyTask?.cancel(); historyTask = nil + measurementTask?.cancel(); measurementTask = nil + measurementTimeout?.cancel(); measurementTimeout = nil + measurementType = nil + stopping = false + historySync.cancel() + gate.cancel() + isReady = false + capabilities = [] + } 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())) + guard startupTask == nil else { return } + if isReady { syncHistory(); return } + let token = session + readiness(nil) + publish(.rwfitInitialization(.initializing)) + startupTask = Task { [weak self] in + guard let self else { return } + do { + self.clock.capture() + try await self.initialize() + try self.check(token) + self.isReady = true + self.metadataPending = true + self.readiness(self.capabilities) + self.publish(.rwfitInitialization(.ready)) + self.startupTask = nil + self.syncHistory() + } catch { + guard self.session == token, !Task.isCancelled else { return } + self.startupTask = nil + self.isReady = false + self.readiness(nil) + self.publish(.rwfitInitialization(.failed(error.localizedDescription))) + } + } + } - // 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) + private func initialize() async throws { + if framing == .legacy { + var legacyIdentity: [UInt8]? + do { + let identity = try await gate.execute(encoder.deviceInfo(framing: .legacy)) + legacyIdentity = identity + } catch { try Task.checkCancellation() } + if let legacyIdentity { + guard !legacyIdentity.isEmpty else { throw RWfitSessionError.invalidResponse } + try await initializeLegacy() + return + } + selectFraming(.jieli) + gate.framing = .jieli + historySync.framing = .jieli + rwfitDiagnostic("Trying modern initialization after legacy identity probe") } - gate.submit(encoder.bindStatus(framing: framing)) + try await initializeModern() + } - gate.submit(encoder.userProfile(framing: framing, profile: userProfile, goalSteps: goalSteps)) - gate.submit(encoder.units(framing: framing, metric: userProfile.metric)) + private func initializeModern() async throws { + _ = try await gate.execute(encoder.sessionInitialize(), needsPayload: false) + _ = try await gate.execute(encoder.timezone(offsetSeconds: Int(clock.offsetSeconds)), needsPayload: false) + _ = try await gate.execute(encoder.setTime(framing: .jieli, components: clock.nowComponents()), needsPayload: false) + let menuPayload = try await gate.execute(encoder.functionMenu()) + guard let menu = RWfitFunctionMenu(payload: menuPayload) else { throw RWfitSessionError.invalidResponse } + if menu.requiresPassword { + let auth = try await gate.execute(encoder.authenticate()) + guard auth.count > 3, auth[3] == 0 else { throw RWfitSessionError.authentication } + } + capabilities = menu.capabilities + streams = RWfitHistorySync.catalog.filter { menu.historyTypes.contains($0) } + // Modern readiness ends at the authenticated function menu (vendor SDK contract). + } - 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)) + private func initializeLegacy() async throws { + let binding = try await gate.execute(encoder.bindStatus(framing: .legacy)) + guard binding.count >= 2 else { throw RWfitSessionError.invalidResponse } + if binding[0] == 0 { + _ = try await gate.execute(encoder.bind(framing: .legacy), needsPayload: false) + let verified = try await gate.execute(encoder.bindStatus(framing: .legacy)) + guard verified.count >= 2, verified[0] != 0 else { throw RWfitSessionError.invalidResponse } } + _ = try await gate.execute(encoder.setTime(framing: .legacy, components: clock.nowComponents()), needsPayload: false) + let features = try await gate.execute(encoder.features(framing: .legacy)) + guard !features.isEmpty else { throw RWfitSessionError.invalidResponse } + capabilities = RWfitDecoder.capabilities(fromLegacyFeatures: features) + let flags: [(UInt8, WearableCapability, RWfitHistoryType)] = [ + (0, .steps, .steps), (1, .sleep, .sleep), (2, .heartRate, .heartRate), + (3, .bloodPressure, .bloodPressure), (4, .spo2, .spo2), (5, .temperature, .temperature), + ] + streams = [] + for (bit, capability, type) in flags where features[0] & (1 << bit) != 0 { + capabilities.insert(capability) + streams.append(type) + } + if capabilities.contains(.sleep) { capabilities.insert(.remSleep) } + if features[0] & 0x80 != 0 { streams.append(.breathe) } + try await configure(framing: .legacy) + } - historySync.start(types: RWfitHistorySync.catalog) + private func configure(framing: RWfitFraming) async throws { + _ = try await gate.execute(encoder.userProfile(framing: framing, profile: userProfile, goalSteps: goalSteps), needsPayload: false) + _ = try await gate.execute(encoder.units(framing: framing, metric: userProfile.metric), needsPayload: false) + _ = try await gate.execute(encoder.deviceInfo(framing: framing)) + _ = try await gate.execute(encoder.battery(framing: framing)) } - /// History is pager-driven — nothing here advances it. - func handle(_ event: RingDecodedEvent) {} + private func check(_ token: UUID) throws { + try Task.checkCancellation() + guard session == token else { throw CancellationError() } + } - // MARK: - History passes (re-entering `start` is a no-op while a pass is in flight) + func syncHistory() { startHistory(streams) } + func syncVitalsHistory() { startHistory(streams.filter { RWfitHistorySync.vitalsTypes.contains($0) }) } - func syncHistory() { - historySync.start(types: RWfitHistorySync.catalog) + private func startHistory(_ types: [RWfitHistoryType]) { + guard isReady, historyTask == nil else { return } + let token = session + historySync.deviceIdentifier = deviceIdentifier() + historyTask = Task { [weak self] in + guard let self else { return } + _ = await self.historySync.run(types: types) + if self.session == token, self.metadataPending, self.measurementType == nil { + self.metadataPending = false + // Optional profile/metadata reads must never prevent a compatible ring from + // becoming ready or importing history. Units already ride modern profile 0206. + for command in [self.encoder.deviceInfo(framing: self.framing), + self.encoder.battery(framing: self.framing), + self.encoder.userProfile(framing: self.framing, profile: self.userProfile, + goalSteps: self.goalSteps)] { + guard self.session == token, !Task.isCancelled, self.measurementType == nil else { break } + self.gate.submit(command) + } + } + if self.session == token { self.historyTask = nil } + } } - func syncVitalsHistory() { - historySync.start(types: RWfitHistorySync.vitalsTypes) + func handle(_ event: RingDecodedEvent) { + guard let type = measurementType else { return } + switch event { + case let .rwfitMeasurementStatus(reported, status) where reported == type: + rwfitDiagnostic("Measurement status", ["type": String(type), "status": String(status)]) + if status == 0 { + // Completion can precede the final sample notification; give queued notifications + // a brief chance to arrive before reporting completion without a reading. + measurementTimeout?.cancel() + measurementTimeout = Task { [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled, let self, self.measurementType == type else { return } + self.publish(.rwfitMeasurementOutcome(.completed(type: type, receivedReading: self.measurementHasValue))) + if !self.measurementHasValue { self.reject(type) } + self.stop(type) + } + } + case .heartRateSample where type == RWfitJLDataType.heartRate, + .spo2Result where type == RWfitJLDataType.spo2, + .hrvSample where type == RWfitJLDataType.hrv, + .bloodPressureSample where type == RWfitJLDataType.bloodPressure: + if !RingEventBridge.events(for: event).isEmpty { measurementHasValue = true } + default: break + } } - // MARK: - Live actions (JieLi-only `06 09` toggles; capability-gated so legacy UIs never call) + func startHeartRate() { start(RWfitJLDataType.heartRate, capability: .manualHeartRate) } + func stopHeartRate() { stop(RWfitJLDataType.heartRate) } + func startSpO2() { start(RWfitJLDataType.spo2, capability: .manualSpo2) } + func stopSpO2() { stop(RWfitJLDataType.spo2) } + func startHRV() { start(RWfitJLDataType.hrv, capability: .manualHrv) } + func stopHRV() { stop(RWfitJLDataType.hrv) } + func startBloodPressure() { start(RWfitJLDataType.bloodPressure, capability: .manualBloodPressure) } + func stopBloodPressure() { stop(RWfitJLDataType.bloodPressure) } - 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) } + private func start(_ type: UInt8, capability: WearableCapability) { + guard isReady, framing == .jieli, capabilities.contains(capability) else { reject(type); return } + if let measurementType { + if measurementType != type { reject(type) } + return + } + measurementID = UUID() + let measurementToken = measurementID + measurementType = type + measurementHasValue = false + measurementStarted = false + historySync.isPaused = true + let token = session + measurementTask = Task { [weak self] in + guard let self else { return } + do { + try await self.historySync.waitUntilPaused() + try self.check(token) + guard self.measurementType == type, self.measurementID == measurementToken, !self.stopping else { return } + _ = try await self.gate.execute(self.encoder.realtimeMeasure(type: type, on: true), needsPayload: false) + try self.check(token) + guard !self.stopping else { return } + self.measurementStarted = true + self.measurementTimeout = Task { [weak self] in + try? await Task.sleep(nanoseconds: 65_000_000_000) + guard !Task.isCancelled, let self, self.measurementType == type else { return } + if !self.measurementHasValue { self.reject(type) } + self.stop(type) + } + } catch { + guard self.session == token, !Task.isCancelled else { return } + self.reject(type) + self.stop(type) + } + } + } - /// 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 awaitMeasurementStart(type: UInt8) async -> Bool { + while measurementType == type && !stopping { + if measurementStarted { return true } + do { try await Task.sleep(nanoseconds: 25_000_000) } catch { return false } + } + return false } - func findDevice() {} // no find-ring command located in the vendor source + private func stop(_ type: UInt8) { + guard measurementType == type, !stopping else { return } + stopping = true + measurementID = UUID() + measurementTimeout?.cancel(); measurementTimeout = nil + let token = session + measurementTask = Task { [weak self] in + guard let self else { return } + // Keep history paused until stop is acknowledged; a lost stop cannot silently resume + // history while the optical measurement may still own the firmware's command channel. + do { + _ = try await self.gate.execute(self.encoder.realtimeMeasure(type: type, on: false), needsPayload: false) + try self.check(token) + self.measurementType = nil + self.stopping = false + self.measurementTask = nil + self.historySync.isPaused = false + } catch { + guard self.session == token, !Task.isCancelled else { return } + self.cancel() + self.readiness(nil) + self.publish(.rwfitInitialization(.failed(error.localizedDescription))) + } + } + } + private func reject(_ type: UInt8) { + publish(.rwfitMeasurementOutcome(.failed(type: type, + reason: "The ring did not provide a reading. Keep it on your finger and try again."))) + } + private func publish(_ event: PulseEvent) { Task { await PulseEventBus.shared.publish(event) } } + func findDevice() {} func setGoal(steps: Int) { goalSteps = steps - gate.submit(encoder.goal(framing: framing, steps: steps, profile: userProfile)) + if isReady { 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() { + guard isReady else { return } clock.capture() + if framing == .jieli { gate.submit(encoder.timezone(offsetSeconds: Int(clock.offsetSeconds))) } gate.submit(encoder.setTime(framing: framing, components: clock.nowComponents())) } - - func requestBattery() { - gate.submit(encoder.battery(framing: framing)) - } - + func requestBattery() { if isReady { 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)) + if isReady { 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) + if isReady { gate.submit(encoder.unbind(framing: framing)) } } } diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index b1829ba4..f7c2cf7d 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -142,7 +142,13 @@ final class RingBLEClient: NSObject { // MARK: Write serialization /// Each queued write carries its already-framed bytes and which characteristic to send it to. - private var writeQueue: [(data: Data, useCommandChannel: Bool)] = [] + private struct PendingWrite { + let data: Data + let useCommandChannel: Bool + var completion: (@MainActor (Result) -> Void)? + } + private var writeQueue: [PendingWrite] = [] + private var inFlightCompletion: (@MainActor (Result) -> Void)? private var writeInFlight = false /// Monotonic id for the in-flight write, so a timeout task only unblocks *its* write (not a newer /// one that has since started). Mirrors the Android write-ACK timeout guard. @@ -325,10 +331,10 @@ final class RingBLEClient: NSObject { /// Queue a logical command for writing. The active driver's framing (padding/checksum) is applied /// here, so callers/engines deal in unframed commands. The driver also decides whether the frame /// goes to the normal write char or the big-data command char. Writes are serialized. - func enqueueWrite(_ data: Data) { + func enqueueWrite(_ data: Data, completion: (@MainActor (Result) -> Void)? = nil) { let framed = activeDriver?.frame(data) ?? data let useCommand = activeDriver?.usesCommandChannel(for: framed) ?? false - writeQueue.append((data: framed, useCommandChannel: useCommand)) + writeQueue.append(PendingWrite(data: framed, useCommandChannel: useCommand, completion: completion)) pumpWrites() } @@ -338,9 +344,9 @@ final class RingBLEClient: NSObject { /// queue is what makes writes serial and ordered, and a caller jumping it would reorder a protocol /// that depends on its own sequence. private func prependWrites(_ commands: [Data]) { - let framed = commands.map { command -> (data: Data, useCommandChannel: Bool) in + let framed = commands.map { command -> PendingWrite in let framed = activeDriver?.frame(command) ?? command - return (data: framed, useCommandChannel: activeDriver?.usesCommandChannel(for: framed) ?? false) + return PendingWrite(data: framed, useCommandChannel: activeDriver?.usesCommandChannel(for: framed) ?? false) } writeQueue.insert(contentsOf: framed, at: 0) } @@ -407,7 +413,7 @@ final class RingBLEClient: NSObject { } writeChar = nil; commandChar = nil; notifyChars = [:]; batteryCharacteristic = nil subscribedNotifyUUIDs = [] - writeInFlight = false; writeQueue = [] + cancelPendingWrites() peripheral = target target.delegate = self // Select the coordinator/driver for this connection: the user's declared family if they made @@ -510,12 +516,14 @@ final class RingBLEClient: NSObject { // Deliberately no `noteActivity()`: an unacknowledged write proves nothing about the link, // and crediting it would blind the watchdog to a zombie connection during a silent sync. peripheral.writeValue(item.data, for: target, type: .withoutResponse) + item.completion?(.success(())) pumpWrites() return } let item = writeQueue.removeFirst() writeInFlight = true + inFlightCompletion = item.completion writeSeq &+= 1 let seq = writeSeq publishRawPacket(direction: .outgoing, data: item.data) @@ -525,12 +533,25 @@ final class RingBLEClient: NSObject { Task { @MainActor in try? await Task.sleep(nanoseconds: writeAckTimeout) if writeInFlight, seq == writeSeq { - writeInFlight = false - pumpWrites() + // ATT callbacks have no request identifier. Retire the link so a late ACK cannot + // accidentally complete the next write after this deadline. + cancelPendingWrites(error: RingWriteError.timedOut) + central.cancelPeripheralConnection(peripheral) } } } + private func cancelPendingWrites(error: Error = CancellationError()) { + let callbacks = [inFlightCompletion].compactMap { $0 } + writeQueue.compactMap(\.completion) + inFlightCompletion = nil + writeChar = nil + commandChar = nil + writeQueue.removeAll() + writeInFlight = false + writeSeq &+= 1 + for callback in callbacks { callback(.failure(error)) } + } + // MARK: - Connection reliability /// Record that the link just proved itself alive (notification / write ACK / read). @@ -666,11 +687,47 @@ final class RingBLEClient: NSObject { // MARK: - RingCommandWriter extension RingBLEClient: RingCommandWriter { + var deviceIdentifier: String? { peripheral?.identifier.uuidString } /// Drivers / sync engines enqueue logical commands through this seam; framing is applied in /// `enqueueWrite`. func enqueue(_ command: Data) { enqueueWrite(command) } + + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { + guard peripheral?.state == .connected, writeChar != nil else { + completion(.failure(RingWriteError.disconnected)) + return + } + let tracked = RingTrackedWrite(timeoutError: RingWriteError.timedOut, onTimeout: { [weak self] in + guard let self else { return } + self.cancelPendingWrites(error: RingWriteError.timedOut) + if let peripheral = self.peripheral { self.central.cancelPeripheralConnection(peripheral) } + }, completion: completion) + enqueueWrite(command, completion: { tracked.finish($0) }) + } + + func emit(_ event: RingDecodedEvent) { + deliverDecoded(event) + } + + private func deliverDecoded(_ decoded: RingDecodedEvent) { + for event in RingNotificationDelivery.events(for: decoded) { publish(event) } + if case let .supportFunctions(derived) = decoded { applySupportFunctions(derived) } + activeSyncEngine?.handle(decoded) + } +} + +private enum RingWriteError: LocalizedError { + case disconnected + case timedOut + + var errorDescription: String? { + switch self { + case .disconnected: return "The ring disconnected before the command was sent." + case .timedOut: return "The ring did not acknowledge the Bluetooth write." + } + } } // MARK: - CBCentralManagerDelegate @@ -709,6 +766,9 @@ extension RingBLEClient: CBCentralManagerDelegate { connectLastKnown() } case .poweredOff, .unauthorized, .unsupported: + cancelPendingWrites() + activeDriver?.connectionDidEnd() + activeSyncEngine?.connectionDidEnd() state = .idle lastError = "Bluetooth unavailable (\(central.state.rawValue))." default: @@ -795,6 +855,7 @@ extension RingBLEClient: CBCentralManagerDelegate { error: Error? ) { MainActor.assumeIsolated { + guard self.peripheral?.identifier == peripheral.identifier else { return } stopReliabilityTimers() lastActivityAt = nil writeChar = nil @@ -802,8 +863,7 @@ extension RingBLEClient: CBCentralManagerDelegate { notifyChars = [:] subscribedNotifyUUIDs = [] batteryCharacteristic = nil - writeInFlight = false - writeQueue = [] + cancelPendingWrites() // Stop the driver's own state machines *now*, not on the next connect: a self-driving one // (the YCBT history transfer's stall watchdog) would otherwise keep stepping through the // reconnect gap and refill the queue we just cleared, and those stale queries would be the @@ -908,7 +968,6 @@ extension RingBLEClient: CBPeripheralDelegate { } else { UserDefaults.standard.removeObject(forKey: Self.lastWearableModelKey) } - publish(.deviceStateChanged(state: .connected, address: nil)) if let type = activeDeviceType { publish(.deviceIdentified( deviceType: type, @@ -917,6 +976,7 @@ extension RingBLEClient: CBPeripheralDelegate { capabilities: activeCapabilities )) } + publish(.deviceStateChanged(state: .connected, address: nil)) noteActivity() startKeepalive() startWatchdog() @@ -954,17 +1014,11 @@ extension RingBLEClient: CBPeripheralDelegate { return } guard let driver = activeDriver, driver.notifyUUIDs.contains(characteristic.uuid) else { return } - for decoded in driver.ingest(value, from: characteristic.uuid) { - publish(.rawPacket(direction: .incoming, data: value, decoded: decoded)) - for event in RingEventBridge.events(for: decoded) { - publish(event) - } - if case let .supportFunctions(derived) = decoded { - applySupportFunctions(derived) - } - // Advance any response-driven sync machine (no-op for jring). - activeSyncEngine?.handle(decoded) - } + // Capture before decoding: fragmented, malformed and multi-frame notifications each + // occupy exactly one trace row. Decoded consumers use their own event channel. + RingNotificationDelivery.receive(value, + decode: { driver.ingest(value, from: characteristic.uuid) }, + publish: { publish($0) }, deliver: { deliverDecoded($0) }) } } @@ -974,8 +1028,12 @@ extension RingBLEClient: CBPeripheralDelegate { error: Error? ) { MainActor.assumeIsolated { - noteActivity() + guard self.peripheral?.identifier == peripheral.identifier, writeInFlight else { return } + if error == nil { noteActivity() } + let completion = inFlightCompletion + inFlightCompletion = nil writeInFlight = false + completion?(error.map { .failure($0) } ?? .success(())) pumpWrites() } } diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index e67817df..bd1d4ec8 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -180,6 +180,10 @@ enum RingEventBridge { private static func extraMetricEvents(for decoded: RingDecodedEvent) -> [PulseEvent] { switch decoded { + case let .rwfitMeasurementStatus(type, status): + return [.rwfitMeasurement(type: type, status: status)] + + case let .bloodPressureSample(systolic, diastolic, timestamp): guard systolicRange.contains(systolic), diastolicRange.contains(diastolic) else { return [] } return [.bloodPressureSample(systolic: systolic, diastolic: diastolic, timestamp: timestamp)] diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index 33ab66c4..31c41714 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -150,6 +150,7 @@ enum RingDecodedEvent: Sendable { /// the owner's R99 refuses HRV (mode `0x0a` → status `0x01`), and without this the app polls a ring /// that already said no for the full 45-second window before reporting a generic failure. case measurementRejected(mode: UInt8) + case rwfitMeasurementStatus(type: UInt8, status: UInt8) /// One frame of a CRP all-day "timing" vital timeline just landed. The ring returns a day in /// fixed-size frames and only sends the next one when asked, so `CRPSyncEngine.handle` uses this /// as a cursor: request `frameIndex + 1` until the vital's terminal frame (the vendor's sequential @@ -191,6 +192,7 @@ enum RingDecodedEvent: Sendable { case .chipScheme: return "chip_scheme" case .wearingStatus: return "wearing_status" case .measurementRejected: return "measurement_rejected" + case .rwfitMeasurementStatus: return "rwfit_measurement_status" case .timingHistoryFrame: return "timing_history_frame" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" diff --git a/PulseLoop/RingProtocol/RingTransportDelivery.swift b/PulseLoop/RingProtocol/RingTransportDelivery.swift new file mode 100644 index 00000000..24267ce0 --- /dev/null +++ b/PulseLoop/RingProtocol/RingTransportDelivery.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Routes notifications independently of CoreBluetooth, preserving capture-before-decode ordering. +@MainActor +enum RingNotificationDelivery { + static func receive(_ data: Data, decode: () -> [RingDecodedEvent], + publish: (PulseEvent) -> Void, deliver: (RingDecodedEvent) -> Void) { + publish(.rawPacket(direction: .incoming, data: data, + decoded: .unknown(commandId: data.first ?? 0, raw: data))) + for event in decode() { deliver(event) } + } + + static func events(for decoded: RingDecodedEvent) -> [PulseEvent] { + [.decodedPacket(decoded)] + RingEventBridge.events(for: decoded) + } +} + +/// Exactly-once completion with a transport deadline that includes time waiting for BLE backpressure. +/// Protocol response deadlines remain separate and begin only after a successful transport result. +@MainActor +final class RingTrackedWrite { + private var callback: (@MainActor (Result) -> Void)? + private var deadline: Task? + + init(timeoutNanoseconds: UInt64 = 10_000_000_000, + timeoutError: Error, + onTimeout: @escaping @MainActor () -> Void, + completion: @escaping @MainActor (Result) -> Void) { + callback = completion + deadline = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds: timeoutNanoseconds) } catch { return } + guard let self, self.callback != nil else { return } + // Retire the transport before the callback is allowed to enqueue another command. + onTimeout() + self.finish(.failure(timeoutError)) + } + } + + deinit { deadline?.cancel() } + + func finish(_ result: Result) { + guard let callback else { return } + self.callback = nil + deadline?.cancel() + deadline = nil + callback(result) + } +} diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 7e3e1a65..569447fd 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -197,6 +197,7 @@ final class RingSyncCoordinator { /// Driven by `.syncProgress` events; cleared on the `"done"` stage, on disconnect, or by a /// safety timeout so a dropped completion signal can't leave the progress bar stuck on. private(set) var syncStage: String? + private(set) var syncError: String? /// Whether a ring data sync is in flight — drives the thin progress bar under the header. /// Stored and mutated only on start/end transitions (not derived from `syncStage`): a computed /// `syncStage != nil` would register observers on `syncStage` itself, invalidating the whole @@ -341,12 +342,13 @@ final class RingSyncCoordinator { if cal.hasBPReference { engine?.setBloodPressureCalibration(systolic: cal.bpReferenceSystolic, diastolic: cal.bpReferenceDiastolic) } + syncError = nil engine?.runStartup() // Refresh the jring GATT battery on every manual sync (jring only pushes battery on connect); // Colmi's battery re-request is part of its `runStartup` handshake. No-op when the GATT // characteristic is absent. client.readBattery() - lastSyncAt = Date() + if client.activeDeviceType != .rwfit { lastSyncAt = Date() } // Show the progress bar immediately; the engine's own `.syncProgress` stages refine the // label and the `"done"` stage (or the stall timeout) clears it. updateSync(stage: "Syncing…") @@ -491,6 +493,8 @@ final class RingSyncCoordinator { @discardableResult func measureHR() async -> Int? { guard hrState != .measuring else { return nil } + if client.activeDeviceType == .rwfit, + [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { hrState = .failed; return nil } hrState = .measuring // NOTE: do *not* clear `latestHRValue` — it's the live value the workout UI shows, so a new @@ -502,6 +506,14 @@ final class RingSyncCoordinator { // continuous stream). Always stop the stream when we're done so the ring doesn't keep measuring. let token = spot.begin(mode: YCBTMeasurementMode.heartRate) engine?.measureHeartRateSpot() + if let rwfit = engine as? RWfitSyncEngine, + !(await rwfit.awaitMeasurementStart(type: RWfitJLDataType.heartRate)) { + engine?.stopHeartRate() + spot.end(token) + hrState = .failed + return nil + } + if client.activeDeviceType == .rwfit { hrWindow.begin() } // Sample the full window in 0.5s steps: `handle(_:)` discards everything inside the warm-up and // collects the rest into `hrSamples`. We break out early only where continuing is pointless — @@ -546,6 +558,8 @@ final class RingSyncCoordinator { @discardableResult func measureSpO2() async -> Int? { guard spo2State != .measuring else { return nil } + if client.activeDeviceType == .rwfit, + [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { spo2State = .failed; return nil } spo2State = .measuring latestSpO2Value = nil @@ -553,6 +567,13 @@ final class RingSyncCoordinator { spo2NotWornReported = false let token = spot.begin(mode: YCBTMeasurementMode.spo2) engine?.startSpO2() + if let rwfit = engine as? RWfitSyncEngine, + !(await rwfit.awaitMeasurementStart(type: RWfitJLDataType.spo2)) { + engine?.stopSpO2() + spot.end(token) + spo2State = .failed + return nil + } let result = await pollForValue( window: spo2MeasureSeconds, value: { self.latestSpO2Value }, @@ -576,11 +597,20 @@ final class RingSyncCoordinator { @discardableResult func measureHRV() async -> Int? { guard hrvState != .measuring else { return nil } + if client.activeDeviceType == .rwfit, + [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { hrvState = .failed; return nil } hrvState = .measuring latestHRVValue = nil let token = spot.begin(mode: YCBTMeasurementMode.hrv) engine?.startHRV() + if let rwfit = engine as? RWfitSyncEngine, + !(await rwfit.awaitMeasurementStart(type: RWfitJLDataType.hrv)) { + engine?.stopHRV() + spot.end(token) + hrvState = .failed + return nil + } let result = await pollForValue( window: hrvMeasureSeconds, value: { self.latestHRVValue }, @@ -600,11 +630,20 @@ final class RingSyncCoordinator { @discardableResult func measureBloodPressure() async -> BloodPressureReading? { guard bpState != .measuring else { return nil } + if client.activeDeviceType == .rwfit, + [hrState, spo2State, hrvState, bpState].contains(.measuring) { return nil } guard client.state == .connected else { bpState = .failed; return nil } bpState = .measuring latestBloodPressureValue = nil let token = spot.begin(mode: YCBTMeasurementMode.bloodPressure) engine?.startBloodPressure() + if let rwfit = engine as? RWfitSyncEngine, + !(await rwfit.awaitMeasurementStart(type: RWfitJLDataType.bloodPressure)) { + engine?.stopBloodPressure() + spot.end(token) + bpState = .failed + return nil + } _ = await pollForValue( window: bpMeasureSeconds, value: { self.latestBloodPressureValue?.systolic }, @@ -669,6 +708,7 @@ final class RingSyncCoordinator { // MARK: - Event handling private func handle(_ event: PulseEvent) { + if handleRWfit(event) { return } switch event { case let .heartRateSample(bpm, _): latestHRValue = bpm @@ -702,7 +742,7 @@ final class RingSyncCoordinator { if flagged { measureNotWorn = true } } case .deviceStateChanged(.connected, _): - lastSyncAt = Date() + if client.activeDeviceType != .rwfit { lastSyncAt = Date() } // Ring came back mid-workout: the new connection's engine doesn't know a stream was // running, so re-issue the live HR command. restartWorkoutHeartRateIfActive() @@ -715,17 +755,54 @@ final class RingSyncCoordinator { } case let .syncProgress(stage): updateSync(stage: stage) - case let .rawPacket(direction, _, decoded): + case let .decodedPacket(decoded): // `.measurementRejected` has no `PulseEvent` of its own — it is a verdict on a command, not // data — so the raw-packet feed (which carries every decoded frame) is where a measurement // hears the ring say no. - guard direction == .incoming, case let .measurementRejected(mode) = decoded else { break } + guard case let .measurementRejected(mode) = decoded else { break } spot.noteRejected(mode: mode) default: mirrorLiveValue(event) } } + private func handleRWfit(_ event: PulseEvent) -> Bool { + switch event { + case let .rwfitInitialization(state): + switch state { + case .initializing: + syncError = nil + updateSync(stage: "Connecting to your ring…") + case .ready: break + case let .failed(reason): + endSync() + syncError = reason + } + case let .rwfitSyncOutcome(outcome): + endSync() + switch outcome { + case .success: + lastSyncAt = Date() + syncError = nil + case let .partial(_, reason), let .failed(reason): syncError = reason + case .cancelled: break + } + case let .rwfitMeasurementOutcome(outcome): + guard case let .failed(type, _) = outcome else { return true } + let mode: UInt8? + switch type { + case RWfitJLDataType.heartRate: mode = YCBTMeasurementMode.heartRate + case RWfitJLDataType.spo2: mode = YCBTMeasurementMode.spo2 + case RWfitJLDataType.hrv: mode = YCBTMeasurementMode.hrv + case RWfitJLDataType.bloodPressure: mode = YCBTMeasurementMode.bloodPressure + default: mode = nil + } + if let mode { spot.noteRejected(mode: mode) } + default: return false + } + return true + } + // MARK: - Sync progress /// Apply a `.syncProgress` stage. The `"done"` sentinel (emitted on history-sync finish) ends @@ -736,7 +813,7 @@ final class RingSyncCoordinator { /// `syncHistory()` is a no-op on jring/Colmi) keeps the label honest for the families whose history /// only comes down with the connect handshake. private func updateSync(stage: String) { - lastSyncAt = Date() + if client.activeDeviceType != .rwfit { lastSyncAt = Date() } guard stage != "done" else { endSync(); return } syncStage = stage // Transition-guarded: Observation notifies on every set (no equality check), so an diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 24f953ef..d13ef0e3 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -84,7 +84,9 @@ struct MeasurementSheet: View { /// Sourced from the coordinator: copying the literal is how the ring and the measurement desync. /// Nil in demo mode too — no 30s window is running there, so a countdown would be pure theatre. private var countdownWindow: Double? { - guard kind == .hr, ble.state == .connected else { return nil } + // RwFit first waits for a history page boundary and command acceptance, so total + // wall-clock duration is variable even though the sampling window remains fixed. + guard kind == .hr, ble.state == .connected, ble.activeDeviceType != .rwfit else { return nil } return Double(coordinator.hrMeasureSeconds) } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index a7822d82..2f82fe22 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -274,6 +274,16 @@ struct MainTabView: View { } } .pulseGlassContainer(spacing: 8) + if let error = coordinator.syncError { + HStack { + Text(error).font(.caption) + Spacer() + Button("Retry") { coordinator.runStartupSequence() } + } + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityElement(children: .contain) + } if #available(iOS 26, *) { // iOS 26+: native TabView renders Apple's stock Liquid Glass tab bar — // real lensing, morphing selection, and content diffusing under the bar. diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 4cb09ad9..ac6998dd 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -6,7 +6,22 @@ import Foundation /// so engines deal in *logical* (unframed) commands and never think about checksums or padding. @MainActor protocol RingCommandWriter: AnyObject { + var deviceIdentifier: String? { get } func enqueue(_ command: Data) + /// Completes when the GATT write is acknowledged (or accepted by the without-response buffer). + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) + /// Deliver a synthetic decoded event without inventing a received packet. + func emit(_ event: RingDecodedEvent) +} + +extension RingCommandWriter { + var deviceIdentifier: String? { nil } + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { + enqueue(command) + completion(.success(())) + } + + func emit(_ event: RingDecodedEvent) {} } /// Connection + protocol handler for one wearable family — the "how do we talk to it" half of the @@ -84,11 +99,11 @@ protocol WearableDriver: AnyObject { /// 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 + /// share the `A00A`/`B002`/`B003` GATT but speak two different framings, initially hinted at 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. + /// provides an initial framing hint; RWfit validates it against received frames. Default: no-op. func servicesDiscovered(_ services: [CBUUID]) } diff --git a/PulseLoopTests/CapabilityGatingTests.swift b/PulseLoopTests/CapabilityGatingTests.swift index b8052144..3fd29008 100644 --- a/PulseLoopTests/CapabilityGatingTests.swift +++ b/PulseLoopTests/CapabilityGatingTests.swift @@ -59,7 +59,7 @@ final class CapabilityGatingTests: XCTestCase { func testRWfitBaselineIsNarrowAndRealtimeIsGated() { let coordinator = RWfitCoordinator() XCTAssertEqual(coordinator.capabilities, - [.heartRate, .spo2, .steps, .sleep, .remSleep, .battery]) + [.battery]) for cap: WearableCapability in [.realtimeHeartRate, .manualHeartRate, .manualSpo2, .bloodPressure, .temperature, .hrv, .stress, .bloodSugar] { XCTAssertFalse(coordinator.capabilities.contains(cap), cap.rawValue) @@ -71,7 +71,8 @@ final class CapabilityGatingTests: XCTestCase { /// and refuses anything the family didn't pre-approve. func testRWfitRefinementAddsOnlyPreApprovedCapabilities() { let coordinator = RWfitCoordinator() - let granted = RWfitDriver.jieliRealtimeCapabilities.union([.bloodPressure, .findDevice, .powerOff]) + let granted: Set = [.heartRate, .realtimeHeartRate, .manualHeartRate, + .manualSpo2, .bloodPressure, .findDevice, .powerOff] let refined = coordinator.refinedCapabilities(bitmapDerived: granted) XCTAssertTrue(refined.isSuperset(of: [.heartRate, .realtimeHeartRate, .manualHeartRate, .manualSpo2, .bloodPressure])) diff --git a/PulseLoopTests/RWfitDecoderTests.swift b/PulseLoopTests/RWfitDecoderTests.swift index cf1ed276..d288692b 100644 --- a/PulseLoopTests/RWfitDecoderTests.swift +++ b/PulseLoopTests/RWfitDecoderTests.swift @@ -224,10 +224,10 @@ final class RWfitDecoderTests: XCTestCase { } func testJieliTemperatureAndBloodSugarScaling() { - // `U()`: u16 ÷ 10 °C. `R()`: u16 ÷ 10 mmol/L → mg/dL. + // SDK: whole Celsius + truncated hundredths. Blood sugar is u16 ÷ 10 mmol/L. let temp = decoder.decodeJieli( triple: RWfitJLTriple(cmd: 0x05, key: 0x08, keyFlag: 0x10), - payload: cat([0x05, 0x08, 0x10], jieliEpoch(), [0x01, 0x6d]) // 365 → 36.5 °C + payload: cat([0x05, 0x08, 0x10], jieliEpoch(), [36, 59]) // 36 + floor(59 / 10) / 10 → 36.5 °C ) guard case let .historyMeasurement(kind, celsius, _)? = temp.first else { return XCTFail("unexpected event shape") } XCTAssertEqual(kind, .temperature) @@ -242,20 +242,17 @@ final class RWfitDecoderTests: XCTestCase { 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 testFunctionMenuUsesDocumentedOffsetsAndGlobalHealthGate() { + var payload = [UInt8](repeating: 0, count: 0x5f) + payload[0] = 2; payload[1] = 0x63; payload[2] = 0x10 + for offset in [0x2c, 0x53, 0x54, 0x55, 0x56, 0x59, 0x5a, 0x5e] { payload[offset] = 1 } + let menu = RWfitFunctionMenu(payload: payload) + XCTAssertEqual(menu?.historyTypes, [.steps, .todaySteps, .heartRate, .bloodPressure, .spo2, .hrv, .temperature]) + XCTAssertEqual(menu?.requiresPassword, true) + XCTAssertTrue(menu?.capabilities.contains(.manualHeartRate) == true) + payload[0x53] = 0 + XCTAssertEqual(RWfitFunctionMenu(payload: payload)?.historyTypes, []) + XCTAssertNil(RWfitFunctionMenu(payload: Array(payload.dropLast()))) } func testJieliBatteryFirmwareAndRealtime() { @@ -271,16 +268,125 @@ final class RWfitDecoderTests: XCTestCase { 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 status = decoder.decodeJieli( + triple: .realtimeMeasure, payload: [0x06, 0x09, 0x00, 0x03, 0x05, 0] + ) + guard case let .rwfitMeasurementStatus(type, value)? = status.first else { + return XCTFail("expected completion status, never a reading") + } + XCTAssertEqual(type, 3) + XCTAssertEqual(value, 0) + } + + func testLiveResultsUseTimestampedSixByteRecords() { let hr = decoder.decodeJieli( - triple: .realtimeMeasure, payload: [0x06, 0x09, 0x00, 0x03, 0x05, 62] + triple: RWfitJLTriple(cmd: 2, key: 0x24, keyFlag: 0), + payload: cat([2, 0x24, 0], jieliEpoch(), [72, 0]) ) - guard case let .heartRateSample(bpm, _)? = hr.first else { return XCTFail("unexpected event shape") } + guard case let .heartRateSample(bpm, timestamp)? = hr.first else { return XCTFail("expected live HR") } XCTAssertEqual(bpm, 72) + XCTAssertEqual(timestamp, utc) + let bp = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 2, key: 0x31, keyFlag: 0), + payload: cat([2, 0x31, 0], jieliEpoch(), [120, 80]) + ) + guard case let .bloodPressureSample(sys, dia, _)? = bp.first else { return XCTFail("expected BP") } + XCTAssertEqual(sys, 120) + XCTAssertEqual(dia, 80) + } - let warmup = decoder.decodeJieli( - triple: .realtimeMeasure, payload: [0x06, 0x09, 0x00, 0x03, 0x05, 0] + func testLiveOxygenHrvAndTemperature() { + let oxygen = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 2, key: 0x4e, keyFlag: 0), + payload: cat([2, 0x4e, 0], jieliEpoch(), [98, 0]) + ) + guard case let .spo2Result(value, _)? = oxygen.first else { return XCTFail("expected SpO2") } + XCTAssertEqual(value, 98) + let hrv = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 2, key: 0x69, keyFlag: 0), + payload: cat([2, 0x69, 0], jieliEpoch(), [45, 0]) + ) + guard case let .hrvSample(ms, _)? = hrv.first else { return XCTFail("expected HRV") } + XCTAssertEqual(ms, 45) + let temperature = decoder.decodeJieli( + triple: RWfitJLTriple(cmd: 2, key: 0x30, keyFlag: 0), + payload: cat([2, 0x30, 0], jieliEpoch(), [36, 59]) + ) + guard case let .temperatureSample(celsius, _)? = temperature.first else { return XCTFail("expected temperature") } + XCTAssertEqual(celsius, 36.5, accuracy: 0.001) + } + + func testTodayStepsEmitsCumulativeTotal() throws { + let payload = cat([5, 0x1a, 0x10], jieliEpoch(), [0, 0, 0x03, 0xe8], + RWfitBytes.packU32BE(1250), RWfitBytes.packU32BE(5000)) + let events = try decoder.validatedJieliHistory(key: 0x1a, payload: payload) + guard case let .activityUpdate(timestamp, steps, meters, calories)? = events.first else { + return XCTFail("expected cumulative current-day activity") + } + XCTAssertEqual(timestamp, utc) + XCTAssertEqual(steps, 1000) + XCTAssertEqual(meters, 500) + XCTAssertEqual(calories, 125) + } + + func testTodayStepsPreservesHourlyDetailsBeforeCumulativeTotal() throws { + let total = cat([5, 0x1a, 0x10], jieliEpoch(), [0, 0, 0x03, 0xe8], + RWfitBytes.packU32BE(1250), RWfitBytes.packU32BE(5000)) + let hour = cat(jieliEpoch(3600), [1, 0, 0, 100], + RWfitBytes.packU32BE(100), RWfitBytes.packU32BE(500)) + let events = try decoder.validatedJieliHistory(key: 0x1a, payload: total + hour) + XCTAssertEqual(events.count, 2) + guard case let .activityBucket(timestamp, steps, meters) = events[0] else { + return XCTFail("expected hourly bucket") + } + XCTAssertEqual(timestamp, utc.addingTimeInterval(3600)) + XCTAssertEqual(steps, 100) + XCTAssertEqual(meters, 50) + guard case let .activityUpdate(_, cumulative, _, _) = events[1] else { + return XCTFail("expected separate cumulative total, never a 1000-step bucket") + } + XCTAssertEqual(cumulative, 1000) + let bridged = events.flatMap { RingEventBridge.events(for: $0, now: utc.addingTimeInterval(7200)) } + XCTAssertEqual(bridged.count, 2) + guard case .activityBucket = bridged[0], case .activityUpdate = bridged[1] else { + return XCTFail("bridge must preserve bucket versus cumulative semantics") + } + } + + func testValidatedHistoryRejectsPartialRecordsAndIncompleteSleep() throws { + XCTAssertThrowsError(try decoder.validatedJieliHistory(key: 3, payload: [5, 3, 0x10, 1])) + XCTAssertTrue(try decoder.validatedJieliHistory(key: 3, payload: [5, 3, 0x10]).isEmpty) + let open = cat([5, 5, 0x10], jieliEpoch(), [0x11, 0, 0]) + XCTAssertNoThrow(try RWfitDecoder.validateJieliSleepPage(open)) + XCTAssertThrowsError(try decoder.validatedJieliHistory(key: 5, payload: open)) + let end = cat(jieliEpoch(600), [0x22, 0, 0]) + let joined = open + end + Array(open.dropFirst(3)) + let events = try decoder.validatedJieliHistory(key: 5, payload: joined) + guard case let .sleepTimeline(_, stages)? = events.first else { return XCTFail("expected assembled sleep") } + XCTAssertEqual(stages.count, 10) + } + + func testSleepDeduplicatesChangedFlagsButRejectsConflictingStages() throws { + let start = cat(jieliEpoch(), [0x11, 0, 0]) + let changedFlags = cat(jieliEpoch(), [0x11, 1, 0xff]) + let end = cat(jieliEpoch(600), [0x22, 0, 0]) + let events = try decoder.validatedJieliHistory( + key: 5, payload: [5, 5, 0x10] + start + end + changedFlags ) - guard case .commandAck? = warmup.first else { return XCTFail("zero value = still measuring") } + guard case let .sleepTimeline(_, stages)? = events.first else { return XCTFail("expected sleep") } + XCTAssertEqual(stages.count, 10) + let conflicting = cat(jieliEpoch(), [1, 0, 0]) + XCTAssertThrowsError(try decoder.validatedJieliHistory( + key: 5, payload: [5, 5, 0x10] + start + conflicting + end + )) + } + + func testSDKStartupCommandBytes() { + let encoder = RWfitEncoder() + XCTAssertEqual(encoder.sessionInitialize(), .jieli(payload: [3, 2, 0x20, 0, 0, 0, 1])) + XCTAssertEqual(encoder.timezone(offsetSeconds: -14400), .jieli(payload: [2, 2, 0, 0xf0, 1])) + XCTAssertEqual(encoder.functionMenu(), .jieli(payload: [2, 0x63, 0x10])) + XCTAssertEqual(encoder.authenticate(), .jieli(payload: [3, 4, 0x10, 48, 48, 48, 48])) + XCTAssertEqual(encoder.historyDelete(type: 5), .jieli(payload: [5, 5, 0x30])) } } diff --git a/PulseLoopTests/RWfitDriverTests.swift b/PulseLoopTests/RWfitDriverTests.swift index daad4bd8..b5480857 100644 --- a/PulseLoopTests/RWfitDriverTests.swift +++ b/PulseLoopTests/RWfitDriverTests.swift @@ -30,7 +30,7 @@ final class RWfitDriverTests: XCTestCase { let driver = RWfitDriver(writer: FakeWriter()) XCTAssertEqual(driver.framing, .legacy) driver.servicesDiscovered([dataService]) - XCTAssertEqual(driver.framing, .legacy, "A00A alone means the legacy firmware") + XCTAssertEqual(driver.framing, .legacy, "A00A alone is only a legacy starting hint") } func testJieliServiceSelectsJieliFraming() { @@ -92,123 +92,136 @@ final class RWfitDriverTests: XCTestCase { // 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)") + func testReplyBodySurvivesBothResponseFlagsWithoutInferredCapabilities() { + for isAck in [false, true] { + let writer = FakeWriter() + let driver = RWfitDriver(writer: writer) + let events = driver.ingest(RWfitJLCodec().encode(payload: [2, 3, 0x10, 76, 0, 0], isAck: isAck), from: notify) + XCTAssertEqual(writer.sent.count, isAck ? 0 : 1) + XCTAssertTrue(driver.framingValidated) + XCTAssertEqual(driver.framing, .jieli) + XCTAssertTrue(events.contains { if case .battery(percent: 76) = $0 { true } else { false } }) + XCTAssertFalse(events.contains { if case .supportFunctions = $0 { true } else { false } }) } - 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() { + func testMalformedModernFrameCannotValidateFramingOrGrantCapabilities() { 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])) + var frame = RWfitJLCodec().encode(payload: [2, 3, 0x10, 76, 0, 0]) + frame[6] ^= 0xff + XCTAssertTrue(driver.ingest(frame, from: notify).isEmpty) + XCTAssertFalse(driver.framingValidated) + XCTAssertTrue(writer.sent.isEmpty) } - func testRealtimeAckUsesFourByteQuirk() { + func testPushIsDecodedWithoutAcknowledgement() { 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]) + var frame = RWfitJLCodec().encode(payload: [2, 3, 0x10, 76, 0, 0]) + frame[1] = 0x21 + let events = driver.ingest(frame, from: notify) + XCTAssertTrue(writer.sent.isEmpty) + XCTAssertTrue(events.contains { if case .battery(percent: 76) = $0 { true } else { false } }) } - // MARK: - Command gate - - func testGateHoldsSecondCommandUntilDeviceAck() async { - let writer = FakeWriter() - let legacy = RWfitLegacyCodec() - let gate = RWfitCommandGate(writer: writer, legacyCodec: legacy, jlCodec: RWfitJLCodec()) + private final class TrackedWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + var confirmations: [@MainActor (Result) -> Void] = [] + func enqueue(_ command: Data) { sent.append(command) } + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { + sent.append(command) + confirmations.append(completion) + } + } - 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) + private func waitForWrites(_ count: Int, writer: TrackedWriter) async { + for _ in 0..<100 { + if writer.sent.count >= count { return } + try? await Task.sleep(nanoseconds: 5_000_000) + } + XCTFail("Expected \(count) writes, got \(writer.sent.count)") + } - 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 testGateRetainsEarlyResponseUntilActualWriteConfirmation() async throws { + let writer = TrackedWriter() + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), responseTimeout: 0.01) + gate.framing = .jieli + var completed = false + let task = Task { @MainActor in + let value = try await gate.execute(.jieli(payload: [2, 3, 0x10])) + completed = true + return value + } + await waitForWrites(1, writer: writer) + gate.noteJieliFrame(flag: 0x11, triple: .init(cmd: 2, key: 3, keyFlag: 0), payload: [2, 3, 0, 76]) + try? await Task.sleep(nanoseconds: 40_000_000) + XCTAssertFalse(completed) + XCTAssertEqual(writer.sent.count, 1, "Response timer must not run while queued for transmission") + writer.confirmations[0](.success(())) + let value = try await task.value + XCTAssertEqual(value, [2, 3, 0, 76]) + gate.cancel() } - 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") + func testGateIgnoresPushAndWrongCommandThenReturnsWriteFailure() async { + let writer = TrackedWriter() + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec()) + let task = Task { try await gate.execute(.jieli(payload: [2, 3, 0x10])) } + await waitForWrites(1, writer: writer) + gate.noteJieliFrame(flag: 0x21, triple: .init(cmd: 2, key: 3, keyFlag: 0), payload: [2, 3, 0, 76]) + gate.noteJieliFrame(flag: 0x11, triple: .init(cmd: 2, key: 4, keyFlag: 0), payload: [2, 4, 0]) + writer.confirmations[0](.failure(RWfitSessionError.unavailable)) + do { _ = try await task.value; XCTFail("Write failure must propagate") } + catch { XCTAssertEqual(error.localizedDescription, RWfitSessionError.unavailable.localizedDescription) } 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") + func testGateRetriesModernRequestTwiceThenFails() async { + let writer = TrackedWriter() + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), responseTimeout: 0.01) + gate.framing = .jieli + let task = Task { try await gate.execute(.jieli(payload: [2, 3, 0x10])) } + for attempt in 0..<3 { + await waitForWrites(attempt + 1, writer: writer) + guard writer.confirmations.count > attempt else { gate.cancel(); return } + writer.confirmations[attempt](.success(())) + } + do { _ = try await task.value; XCTFail("Silence must fail") } + catch { XCTAssertEqual(error.localizedDescription, RWfitSessionError.timeout.localizedDescription) } + XCTAssertEqual(writer.sent.count, 3) gate.cancel() } - func testGateJieliAckMatchesTriple() async { - let writer = FakeWriter() - let gate = RWfitCommandGate( - writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec() - ) + func testLegacyAckDoesNotCompletePayloadReadAndCancellationReleasesWaiter() async { + let writer = TrackedWriter() + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec()) + var completed = false + let task = Task { @MainActor in + _ = try await gate.execute(.legacy(cmd: 1, payload: [])) + completed = true + } + await waitForWrites(1, writer: writer) + writer.confirmations[0](.success(())) + gate.noteLegacyAck(cmd: 1, serial: 1) + await Task.yield() + XCTAssertFalse(completed) + gate.cancel() + do { try await task.value; XCTFail("Cancellation must throw") } + catch { XCTAssertTrue(error is CancellationError) } + } + func testDestructiveDeleteIsNeverAutomaticallyRetried() async { + let writer = TrackedWriter() + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), responseTimeout: 0.01) 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]) + let task = Task { try await gate.execute(.jieli(payload: [5, 5, 0x30]), needsPayload: false) } + await waitForWrites(1, writer: writer) + writer.confirmations[0](.success(())) + do { _ = try await task.value; XCTFail("Lost delete acknowledgement must fail") } + catch { XCTAssertEqual(error.localizedDescription, RWfitSessionError.timeout.localizedDescription) } + XCTAssertEqual(writer.sent.count, 1, "Retry could consume a page that was never journaled") gate.cancel() } + } diff --git a/PulseLoopTests/RWfitHistoryPersistenceTests.swift b/PulseLoopTests/RWfitHistoryPersistenceTests.swift new file mode 100644 index 00000000..c9b7acb5 --- /dev/null +++ b/PulseLoopTests/RWfitHistoryPersistenceTests.swift @@ -0,0 +1,116 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +@MainActor +final class RWfitHistoryPersistenceTests: XCTestCase { + func testHistoryCommitIsDurableAndIdempotent() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let timestamp = Date().addingTimeInterval(-60) + let events: [RingDecodedEvent] = [.historyMeasurement(kind: .heartRate, value: 72, timestamp: timestamp)] + try subscriber.saveRWfitHistory(events) + try subscriber.saveRWfitHistory(events) + XCTAssertFalse(context.hasChanges) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.value, 72) + } + + func testRejectedHistoryPreventsAnyPageImport() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + XCTAssertThrowsError(try subscriber.saveRWfitHistory([ + .historyMeasurement(kind: .heartRate, value: 72, timestamp: Date()), + .historyMeasurement(kind: .heartRate, value: 0, timestamp: Date()) + ])) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + } + + func testOverlappingRecoveredSleepExtendsSavedSessionWithoutInflation() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let start = Date().addingTimeInterval(-6 * 3600) + try subscriber.saveRWfitHistory([.sleepTimeline(timestamp: start, stages: Array(repeating: .light, count: 30))]) + let aggregate: [RingDecodedEvent] = [.sleepTimeline(timestamp: start, stages: Array(repeating: .light, count: 60))] + try subscriber.saveRWfitHistory(aggregate) + try subscriber.saveRWfitHistory(aggregate) + let blocks = try context.fetch(FetchDescriptor()) + XCTAssertEqual(blocks.reduce(0) { $0 + $1.durationMinutes }, 60) + } + + func testRwfitFreshnessRequiresSuccessfulOutcome() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.deviceIdentified(deviceType: .rwfit, wearableModelID: nil, + advertisedName: "SR16", capabilities: [])) + subscriber.persist(.deviceStateChanged(state: .connected, address: nil)) + subscriber.persist(.syncProgress(stage: "done")) + subscriber.persist(.rwfitSyncOutcome(.failed(reason: "Timeout"))) + subscriber.persist(.rwfitSyncOutcome(.partial(records: 1, reason: "Interrupted"))) + subscriber.persist(.rwfitSyncOutcome(.cancelled)) + let device = try XCTUnwrap(DeviceRepository.current(context: context)) + XCTAssertNil(device.lastSyncAt) + XCTAssertNil(device.lastFullSyncAt) + subscriber.persist(.rwfitSyncOutcome(.success(records: 0))) + XCTAssertNotNil(device.lastSyncAt) + XCTAssertEqual(device.lastSyncAt, device.lastFullSyncAt) + } + + func testActivityFailureRollsBackEveryBucketAndRetryImportsOnce() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let start = Calendar.current.startOfDay(for: Date()).addingTimeInterval(-24 * 3600) + let events: [RingDecodedEvent] = [ + .activityBucket(timestamp: start, steps: 1000, distanceMeters: 700), + .activityBucket(timestamp: start.addingTimeInterval(1800), steps: 2000, distanceMeters: 1400) + ] + enum StorageFailure: Error { case unavailable } + XCTAssertThrowsError(try subscriber.saveRWfitHistory(events, commit: { throw StorageFailure.unavailable })) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty, + "An intermediate bucket helper must not commit before the transaction's final save") + try subscriber.saveRWfitHistory(events) + try subscriber.saveRWfitHistory(events) + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 2) + let days = try context.fetch(FetchDescriptor()) + XCTAssertEqual(days.count, 1) + XCTAssertEqual(days.first?.steps, 3000) + } + + func testActivityBucketsPreserveTodaysLargerCumulativeReading() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let now = Date() + try subscriber.saveRWfitHistory([ + .activityUpdate(timestamp: now, steps: 8000, distanceMeters: 5600, calories: 300), + .activityBucket(timestamp: now, steps: 2000, distanceMeters: 1400) + ]) + let row = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(row.steps, 8000) + XCTAssertEqual(row.distanceMeters, 5600) + XCTAssertEqual(row.calories, 300) + } + + func testSleepJournalSurvivesReloadAndDeduplicatesRetriesPerDevice() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + RWfitHistoryPersistence.journalDirectoryOverride = directory + defer { + RWfitHistoryPersistence.journalDirectoryOverride = nil + try? FileManager.default.removeItem(at: directory) + } + let device = UUID().uuidString + let other = UUID().uuidString + let pageID = UUID() + let first: [UInt8] = [3, 4, 5, 11, 12, 13, 14, 2, 0, 0] + let second: [UInt8] = [3, 4, 5, 21, 22, 23, 24, 3, 0, 0] + try RWfitHistoryPersistence.stageSleepPage(first, deviceID: device, pageID: pageID) + try RWfitHistoryPersistence.stageSleepPage(first, deviceID: device, pageID: pageID) + try RWfitHistoryPersistence.stageSleepPage(first, deviceID: device, pageID: UUID()) + try RWfitHistoryPersistence.stageSleepPage(second, deviceID: device, pageID: UUID()) + XCTAssertEqual(try RWfitHistoryPersistence.sleepPayload(deviceID: device), first + second.dropFirst(3)) + XCTAssertEqual(try RWfitHistoryPersistence.sleepPayload(deviceID: other), []) + try RWfitHistoryPersistence.clearSleepPages(deviceID: device) + XCTAssertEqual(try RWfitHistoryPersistence.sleepPayload(deviceID: device), []) + } +} diff --git a/PulseLoopTests/RWfitHistorySyncTests.swift b/PulseLoopTests/RWfitHistorySyncTests.swift index 2fed21b2..fb5e42c4 100644 --- a/PulseLoopTests/RWfitHistorySyncTests.swift +++ b/PulseLoopTests/RWfitHistorySyncTests.swift @@ -1,129 +1,225 @@ 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 { + private final class Writer: 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] } } + var payloads: [[UInt8]] = [] + var onWrite: (([UInt8]) -> Void)? + func enqueue(_ command: Data) {} + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { + let payload = Array(command.dropFirst(6)) + payloads.append(payload) + completion(.success(())) + onWrite?(payload) + } } - 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 + private func setup(_ writer: Writer) -> (RWfitHistorySync, RWfitCommandGate) { + let gate = RWfitCommandGate(writer: writer, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), responseTimeout: 0.02) + gate.framing = .jieli + let sync = RWfitHistorySync(gate: gate, progressSink: { _ in }) + sync.framing = .jieli + sync.deviceIdentifier = UUID().uuidString + sync.persist = { _ in } return (sync, gate) } - private func sleep(_ seconds: TimeInterval) async { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + private func respond(_ payload: [UInt8], gate: RWfitCommandGate) { + gate.noteJieliFrame(flag: 0x11, triple: .init(cmd: payload[0], key: payload[1], keyFlag: payload[2]), payload: payload) } - 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) + func testMultiplePagesAreSavedBeforeConsumption() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + var order: [String] = [] + sync.persist = { events in + XCTAssertEqual(events.count, 2) + order.append("save") + } + writer.onWrite = { payload in + if payload[2] == 0x10 { + reads += 1 + order.append("get") + self.respond(reads < 3 ? [5, 3, 0x10, 0, 0, 0, UInt8(reads), 70, 0] : [5, 3, 0x10], gate: gate) + } else { + order.append("delete") + self.respond(payload, gate: gate) + } + } + let outcome = await sync.run(types: [.heartRate]) + XCTAssertEqual(outcome, .success(records: 2)) + XCTAssertEqual(order, ["get", "get", "get", "save", "delete"]) 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) + func testStorageFailurePreservesRingHistory() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + sync.persist = { _ in throw RWfitSessionError.persistence } + writer.onWrite = { payload in + reads += 1 + self.respond(reads == 1 ? [5, 3, 0x10, 0, 0, 0, 1, 70, 0] : [5, 3, 0x10], gate: gate) + } + let outcome = await sync.run(types: [.heartRate]) + guard case .failed = outcome else { return XCTFail("Expected failed persistence, got \(outcome)") } + XCTAssertFalse(writer.payloads.contains { $0[2] == 0x30 }) 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) + func testMalformedHistoryIsNotConsumed() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + writer.onWrite = { _ in + reads += 1 + self.respond(reads == 1 ? [5, 3, 0x10, 99] : [5, 3, 0x10], gate: gate) + } + let outcome = await sync.run(types: [.heartRate]) + guard case .failed = outcome else { return XCTFail("Malformed record must fail") } + XCTAssertFalse(writer.payloads.contains { $0[2] == 0x30 }) 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() + func testSilenceFailsButExplicitEmptyResponseSucceeds() async { + let silentWriter = Writer() + let (silentSync, silentGate) = setup(silentWriter) + let failure = await silentSync.run(types: [.heartRate]) + guard case .failed = failure else { return XCTFail("Silence is not successful empty history") } + XCTAssertEqual(silentWriter.payloads.count, 3) + XCTAssertFalse(silentSync.isRunning) + silentGate.cancel() + + let writer = Writer() + let (sync, gate) = setup(writer) + writer.onWrite = { self.respond($0, gate: gate) } + let success = await sync.run(types: [.heartRate]) + XCTAssertEqual(success, .success(records: 0)) gate.cancel() } - func testReEntrantStartIsIgnoredWhileRunning() async { - let writer = FakeWriter() - let spy = Spy() - let (sync, gate) = makeSync(writer: writer, spy: spy, settle: 5, stall: 5) + func testPauseStopsAtPageBoundaryAndResumes() async throws { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + writer.onWrite = { payload in + if payload[2] == 0x10 { + reads += 1 + if reads == 1 { sync.isPaused = true } + self.respond(reads == 1 ? [5, 3, 0x10, 0, 0, 0, 1, 70, 0] : payload, gate: gate) + } else { self.respond(payload, gate: gate) } + } + let task = Task { await sync.run(types: [.heartRate]) } + for _ in 0..<100 where !sync.isPaused { try await Task.sleep(nanoseconds: 5_000_000) } + try await sync.waitUntilPaused() + try await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(reads, 1) + sync.isPaused = false + let outcome = await task.value + XCTAssertEqual(outcome, .success(records: 1)) + gate.cancel() + } - sync.start(types: [.steps]) - sync.start(types: [.sleep]) // must not interrupt the in-flight pass - XCTAssertEqual(writer.legacyCommands, [RWfitLegacyCommand.stepsHistory]) - sync.cancel() + func testSleepPageIsDurableBeforeDeleteAndRetainedOnSaveFailure() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + RWfitHistoryPersistence.journalDirectoryOverride = directory + defer { + RWfitHistoryPersistence.journalDirectoryOverride = nil + try? FileManager.default.removeItem(at: directory) + } + let writer = Writer() + let (sync, gate) = setup(writer) + let identifier = try XCTUnwrap(sync.deviceIdentifier) + let page: [UInt8] = [5, 5, 0x10, 0, 0, 0, 1, 2, 0, 1] + var reads = 0 + var checkedJournal = false + sync.persist = { _ in throw RWfitSessionError.persistence } + writer.onWrite = { payload in + if payload[2] == 0x10 { + reads += 1 + self.respond(reads == 1 ? page : payload, gate: gate) + } else { + XCTAssertEqual(try? RWfitHistoryPersistence.sleepPayload(deviceID: identifier), page) + checkedJournal = true + self.respond(payload, gate: gate) + } + } + let outcome = await sync.run(types: [.sleep]) + guard case .failed = outcome else { return XCTFail("Incomplete sleep or failed save must fail") } + XCTAssertTrue(checkedJournal) + XCTAssertEqual(try RWfitHistoryPersistence.sleepPayload(deviceID: identifier), page) 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) + func testCancellationWhilePausedDoesNotIssueHistoryRequest() async { + let writer = Writer() + let (sync, gate) = setup(writer) + sync.isPaused = true + let task = Task { await sync.run(types: [.heartRate]) } + await Task.yield() + task.cancel() + let outcome = await task.value + XCTAssertEqual(outcome, .cancelled) + XCTAssertTrue(writer.payloads.isEmpty) + gate.cancel() + } + func testDeleteFailureAfterDurableSaveReportsPartialImport() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + var saved = 0 + sync.persist = { saved += $0.count } + writer.onWrite = { payload in + guard payload[2] == 0x10 else { return } + reads += 1 + self.respond(reads == 1 ? [5, 3, 0x10, 0, 0, 0, 1, 70, 0] : payload, gate: gate) + } + let outcome = await sync.run(types: [.heartRate]) + XCTAssertEqual(saved, 1) + guard case let .partial(records, _) = outcome else { return XCTFail("Saved records must remain visible in partial outcome") } + XCTAssertEqual(records, 1) + XCTAssertEqual(writer.payloads.filter { $0[2] == 0x30 }.count, 1) + gate.cancel() + } - 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) + func testIncompleteSleepDoesNotPreventLaterHeartRateImport() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + RWfitHistoryPersistence.journalDirectoryOverride = directory + defer { + RWfitHistoryPersistence.journalDirectoryOverride = nil + try? FileManager.default.removeItem(at: directory) + } + let writer = Writer() + let (sync, gate) = setup(writer) + let identifier = try XCTUnwrap(sync.deviceIdentifier) + let sleepPage: [UInt8] = [5, 5, 0x10, 0, 0, 0, 1, 0x11, 0, 0] + var sleepReads = 0 + var heartReads = 0 + var saved = 0 + sync.persist = { saved += $0.count } + writer.onWrite = { payload in + var response = payload + if payload[2] == 0x10 && payload[1] == 5 { + sleepReads += 1 + if sleepReads == 1 { response = sleepPage } + } else if payload[2] == 0x10 && payload[1] == 3 { + heartReads += 1 + if heartReads == 1 { response = [5, 3, 0x10, 0, 0, 0, 1, 70, 0] } + } + self.respond(response, gate: gate) + } + let outcome = await sync.run(types: [.sleep, .heartRate]) + guard case let .partial(records, reason) = outcome else { return XCTFail("Expected partial import, got \(outcome)") } + XCTAssertEqual(records, 1) + XCTAssertEqual(saved, 1) + XCTAssertFalse(reason.isEmpty) + XCTAssertEqual(heartReads, 2) + XCTAssertEqual(try RWfitHistoryPersistence.sleepPayload(deviceID: identifier), sleepPage, + "Unfinished sleep session must remain recoverable") gate.cancel() } + } diff --git a/PulseLoopTests/RWfitJLCodecTests.swift b/PulseLoopTests/RWfitJLCodecTests.swift index c22e0eb5..6e3169f0 100644 --- a/PulseLoopTests/RWfitJLCodecTests.swift +++ b/PulseLoopTests/RWfitJLCodecTests.swift @@ -2,8 +2,7 @@ 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`. +/// exact three-byte payload), and headerless continuation reassembly against the official SDK. @MainActor final class RWfitJLCodecTests: XCTestCase { @@ -32,24 +31,26 @@ final class RWfitJLCodecTests: XCTestCase { 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. + func testRealtimeAckEchoesOnlyTriple() { + // SDK ACKs all commands uniformly, including 0609 measurement state. let ack = [UInt8](RWfitJLCodec().ack(triple: RWfitJLTriple(cmd: 0x06, key: 0x09, keyFlag: 0x00))) - XCTAssertEqual(Array(ack[6...]), [0x06, 0x09, 0x00, 0x00]) + XCTAssertEqual(Array(ack[6...]), [0x06, 0x09, 0x00]) + XCTAssertEqual(ack.count, 9) } 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), + XCTAssertEqual(events, [.frame(flag: 0x01, 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))]) + XCTAssertEqual(events, [.frame(flag: 0x11, triple: RWfitJLTriple(cmd: 0x02, key: 0x01, keyFlag: 0x00), + payload: [0x02, 0x01, 0x00])]) } func testHeaderlessContinuationReassembly() { @@ -63,7 +64,7 @@ final class RWfitJLCodecTests: XCTestCase { 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), + XCTAssertEqual(events, [.frame(flag: 0x01, triple: RWfitJLTriple(cmd: 0x05, key: 0x03, keyFlag: 0x10), payload: payload)]) } @@ -89,4 +90,40 @@ final class RWfitJLCodecTests: XCTestCase { XCTAssertTrue(codec.decode(Data(whole[26...])).isEmpty, "a continuation from the dropped link must not complete on the new one") } + func testEveryHeaderSplitPreservesResponseBodyAndPushFlag() { + for flag in [UInt8(0x01), 0x11, 0x21] { + for split in 1...6 { + let codec = RWfitJLCodec() + let payload: [UInt8] = [0x02, 0x03, 0x10, 76] + var frame = [UInt8](codec.encode(payload: payload)) + frame[1] = flag + XCTAssertTrue(codec.decode(Data(frame.prefix(split))).isEmpty) + XCTAssertEqual(codec.decode(Data(frame.dropFirst(split))), [ + .frame(flag: flag, triple: .battery, payload: payload), + ]) + } + } + } + + func testMultipleFramesAndCRCRecoveryInOneNotification() { + let codec = RWfitJLCodec() + let payload: [UInt8] = [0x02, 0x03, 0x10, 76] + let valid = codec.encode(payload: payload) + var broken = [UInt8](valid) + broken[4] ^= 1 + XCTAssertEqual(codec.decode(Data(broken) + valid + valid), [ + .crcFailed, .frame(flag: 0x01, triple: .battery, payload: payload), + .frame(flag: 0x01, triple: .battery, payload: payload), + ]) + } + + func testOversizedHeaderCannotBlockNextValidFrame() { + let codec = RWfitJLCodec() + let payload: [UInt8] = [0x02, 0x03, 0x10, 76] + let invalid = Data([0xab, 0x01, 0x20, 0x01, 0, 0]) + XCTAssertEqual(codec.decode(invalid + codec.encode(payload: payload)), [ + .frame(flag: 0x01, triple: .battery, payload: payload), + ]) + } + } diff --git a/PulseLoopTests/RWfitSyncEngineTests.swift b/PulseLoopTests/RWfitSyncEngineTests.swift new file mode 100644 index 00000000..1bf7ed9b --- /dev/null +++ b/PulseLoopTests/RWfitSyncEngineTests.swift @@ -0,0 +1,153 @@ +import XCTest +@testable import PulseLoop + +@MainActor +final class RWfitSyncEngineTests: XCTestCase { + private final class Fixture: RingCommandWriter { + nonisolated deinit {} + var frames: [Data] = [] + var framing = RWfitFraming.jieli + var menu = [UInt8](repeating: 0, count: 0x5f) + var authenticationStatus: UInt8 = 0 + var answerModern = true + var answerOptionalCommands = true + var readiness: [Bool] = [] + lazy var gate = RWfitCommandGate(writer: self, legacyCodec: RWfitLegacyCodec(), jlCodec: RWfitJLCodec(), responseTimeout: 0.01) + lazy var history = RWfitHistorySync(gate: gate, progressSink: { _ in }) + lazy var engine = RWfitSyncEngine(gate: gate, historySync: history, clock: RWfitClock(), + framingProvider: { [weak self] in self?.framing ?? .legacy }, + selectFraming: { [weak self] in self?.framing = $0 }, + readiness: { [weak self] in self?.readiness.append($0 != nil) }, + deviceIdentifier: { UUID().uuidString }) + init() { + menu[0] = 2; menu[1] = 0x63; menu[2] = 0x10 + } + func enqueue(_ command: Data) { frames.append(command) } + func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { + frames.append(command) + completion(.success(())) + guard command.first == 0xab, answerModern else { return } + let payload = Array(command.dropFirst(6)) + if !answerOptionalCommands && payload[0] == 2 && [0x03, 0x04, 0x06, 0x11].contains(payload[1]) { return } + var response = payload + if payload[0] == 2 && payload[1] == 0x63 { response = menu } + if payload[0] == 3 && payload[1] == 4 { response = Array(payload.prefix(3)) + [authenticationStatus] } + gate.noteJieliFrame(flag: 0x11, triple: .init(cmd: payload[0], key: payload[1], keyFlag: payload[2]), payload: response) + } + var modernPayloads: [[UInt8]] { frames.filter { $0.first == 0xab }.map { Array($0.dropFirst(6)) } } + func prepare() { + gate.framing = framing + history.framing = framing + history.persist = { _ in } + } + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<800 { + if predicate() { return } + try? await Task.sleep(nanoseconds: 5_000_000) + } + XCTFail("Timed out waiting for session state") + } + + func testStartupIsSequentialAndReadinessFollowsAuthentication() async { + let fixture = Fixture() + fixture.menu[0x2c] = 1 + fixture.prepare() + fixture.engine.runStartup() + fixture.engine.startHeartRate() + fixture.engine.syncHistory() + await waitUntil { fixture.engine.isReady } + XCTAssertEqual(fixture.modernPayloads.prefix(5).map { Array($0.prefix(2)) }, + [[3, 2], [2, 2], [2, 1], [2, 0x63], [3, 4]]) + XCTAssertEqual(fixture.readiness, [false, true]) + XCTAssertFalse(fixture.modernPayloads.contains { $0[0] == 6 || $0[0] == 5 }) + fixture.engine.cancel() + } + + func testPasswordRejectionPreventsReadinessAndHistory() async { + let fixture = Fixture() + fixture.menu[0x2c] = 1 + fixture.authenticationStatus = 1 + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.readiness.count == 2 } + XCTAssertFalse(fixture.engine.isReady) + XCTAssertEqual(fixture.readiness, [false, false]) + XCTAssertFalse(fixture.modernPayloads.contains { $0[0] == 5 }) + XCTAssertEqual(Array(fixture.modernPayloads.last?.prefix(2) ?? []), [3, 4]) + fixture.engine.cancel() + } + + func testMalformedMenuPreventsReadiness() async { + let fixture = Fixture() + fixture.menu = [2, 0x63, 0x10] + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.readiness.count == 2 } + XCTAssertFalse(fixture.engine.isReady) + XCTAssertEqual(fixture.modernPayloads.count, 4) + fixture.engine.cancel() + } + + func testLegacyProbeSilenceFallsBackToModernHandshake() async { + let fixture = Fixture() + fixture.framing = .legacy + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.engine.isReady } + XCTAssertEqual(fixture.frames.filter { $0.first == 0x7e }.count, 2) + XCTAssertEqual(fixture.framing, .jieli) + XCTAssertEqual(Array(fixture.modernPayloads.first?.prefix(2) ?? []), [3, 2]) + fixture.engine.cancel() + } + + func testTotalSilenceFailsReadinessAndReconnectStartsFresh() async { + let fixture = Fixture() + fixture.answerModern = false + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.readiness.count == 2 } + XCTAssertFalse(fixture.engine.isReady) + XCTAssertEqual(fixture.modernPayloads.count, 3) + fixture.engine.cancel() + fixture.answerModern = true + fixture.engine.runStartup() + await waitUntil { fixture.engine.isReady } + XCTAssertEqual(fixture.readiness, [false, false, false, true]) + fixture.engine.cancel() + } + func testStopBeforeStartTaskRunsCannotTurnMeasurementOnAfterward() async { + let fixture = Fixture() + fixture.menu[0x53] = 1 + fixture.menu[0x55] = 1 + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.engine.isReady } + fixture.engine.startHeartRate() + fixture.engine.stopHeartRate() + await waitUntil { fixture.modernPayloads.contains { $0[0] == 6 && $0[1] == 9 } } + try? await Task.sleep(nanoseconds: 400_000_000) + let measurements = fixture.modernPayloads.filter { $0[0] == 6 && $0[1] == 9 } + XCTAssertEqual(measurements.count, 1, "The pending start must not send ON after STOP") + XCTAssertEqual(measurements.first?.last, 0) + XCTAssertFalse(fixture.history.isPaused) + fixture.engine.cancel() + } + + func testUnansweredOptionalConfigurationCannotBlockModernReadiness() async { + let fixture = Fixture() + fixture.answerOptionalCommands = false + fixture.prepare() + fixture.engine.runStartup() + await waitUntil { fixture.engine.isReady } + await waitUntil { fixture.modernPayloads.contains { $0[0] == 2 && $0[1] == 4 } } + try? await Task.sleep(nanoseconds: 500_000_000) + XCTAssertTrue(fixture.engine.isReady, "Optional metadata timeouts must not invalidate authenticated readiness") + XCTAssertEqual(fixture.readiness, [false, true]) + XCTAssertEqual(fixture.modernPayloads.prefix(4).map { Array($0.prefix(2)) }, [[3, 2], [2, 2], [2, 1], [2, 0x63]]) + XCTAssertFalse(fixture.modernPayloads.contains { $0[0] == 2 && $0[1] == 0x11 }, "Legacy units configuration is not part of the modern handshake") + fixture.engine.cancel() + } + +} diff --git a/PulseLoopTests/RingTransportDeliveryTests.swift b/PulseLoopTests/RingTransportDeliveryTests.swift new file mode 100644 index 00000000..78d4241e --- /dev/null +++ b/PulseLoopTests/RingTransportDeliveryTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import PulseLoop + +@MainActor +final class RingTransportDeliveryTests: XCTestCase { + func testFragmentsAndRejectedNotificationsAreCapturedBeforeDecode() { + var events: [PulseEvent] = [] + for data in [Data([0xAB]), Data([0x00, 0xFF])] { + let before = events.count + RingNotificationDelivery.receive(data, decode: { + XCTAssertEqual(events.count, before + 1, "Raw bytes must be emitted before decode begins") + return [] + }, publish: { events.append($0) }, deliver: { _ in XCTFail("No decoded frame") }) + } + XCTAssertEqual(events.count, 2) + guard case let .rawPacket(direction, data, _) = events[0] else { return XCTFail("Missing raw capture") } + XCTAssertEqual(direction, .incoming) + XCTAssertEqual(data, Data([0xAB])) + } + + func testMultipleDecodedFramesHaveOneRawCaptureAndFullTypedFanout() { + let decoded: [RingDecodedEvent] = [ + .heartRateSample(bpm: 72, timestamp: Date()), + .measurementRejected(mode: 0x09) + ] + var events: [PulseEvent] = [] + RingNotificationDelivery.receive(Data([0xAB, 1, 2]), decode: { decoded }, + publish: { events.append($0) }, + deliver: { events.append(contentsOf: RingNotificationDelivery.events(for: $0)) }) + XCTAssertEqual(events.filter { if case .rawPacket = $0 { return true }; return false }.count, 1) + XCTAssertEqual(events.filter { if case .decodedPacket = $0 { return true }; return false }.count, 2) + XCTAssertEqual(events.filter { if case .heartRateSample = $0 { return true }; return false }.count, 1) + } + + func testTrackedWriteSuccessCompletesExactlyOnceAndDisarmsDeadline() async throws { + var completions = 0 + let tracked = RingTrackedWrite(timeoutNanoseconds: 1_000_000, timeoutError: TestFailure.timeout, + onTimeout: { XCTFail("A completed write must not time out") }, completion: { result in + if case .failure = result { XCTFail("Expected successful transport") } + completions += 1 + }) + tracked.finish(.success(())) + tracked.finish(.failure(TestFailure.timeout)) + try await Task.sleep(nanoseconds: 5_000_000) + XCTAssertEqual(completions, 1) + } + + func testTrackedWriteErrorOrDisconnectCompletesExactlyOnce() { + var completions = 0 + let tracked = RingTrackedWrite(timeoutError: TestFailure.timeout, onTimeout: {}, completion: { result in + guard case .failure = result else { return XCTFail("Expected transport failure") } + completions += 1 + }) + tracked.finish(.failure(CancellationError())) + tracked.finish(.success(())) + XCTAssertEqual(completions, 1) + } + + func testTrackedWriteDeadlineIncludesAnUnsentBackpressuredWrite() async throws { + var retired = false + var completions = 0 + let tracked = RingTrackedWrite(timeoutNanoseconds: 1_000_000, timeoutError: TestFailure.timeout, + onTimeout: { retired = true }, completion: { result in + XCTAssertTrue(retired, "Retire the old transport before notifying the engine") + guard case .failure = result else { return XCTFail("Expected transport deadline") } + completions += 1 + }) + try await Task.sleep(nanoseconds: 10_000_000) + tracked.finish(.success(())) // A late ATT callback cannot overwrite the timeout. + XCTAssertEqual(completions, 1) + } + + private enum TestFailure: Error { case timeout } +} diff --git a/docs/hardware/rwfit.md b/docs/hardware/rwfit.md index 989fc193..a5a989b4 100644 --- a/docs/hardware/rwfit.md +++ b/docs/hardware/rwfit.md @@ -1,180 +1,84 @@ --- 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. +description: Native support for the legacy and modern RWfit protocols, with vendor SDK references and hardware validation status. --- # 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. +PulseLoop implements the RWfit protocol in Swift; it does not bundle the vendor SDK. +RwFit rings use the `A00A` service, `B002` write characteristic and `B003` notification +characteristic. Rebranded model names alone are not reliable protocol identifiers. + +## Compatibility status + +**Hardware validation is pending for SR16 and SY01.** Reports from PulseLoop 2.6.0 +included pairing without data and manual-reading timeouts. The supplied SR16 export +(firmware `001.0B`) contains connection and sync-stage logs, but no raw packets. +Its approximately six-second stage changes match the old timeout-based pager; +“done” in that export is not evidence of a successful history transfer. + +The fixes have SDK-derived regression coverage. They do not establish universal +compatibility with every ring sold for the RwFit app. + +## Protocol references + +- [Vendor invitation, issue #135](https://github.com/saksham2001/PulseLoopiOS/issues/135) +- [Executable SDK, pinned revision](https://github.com/RWFitSDK/RW_weixi_miniprogram_sdk/blob/8613daec2c08a41fa6c0bf5476af4f125e1532e5/RW_SDK_DEMO/sdk/rw-ble-sdk.min.js) +- [iOS guide, pinned revision](https://github.com/RWFitSDK/RW_iOS_SDK/blob/69b35c2471d0e531556b77a51bdc116f2a218171/doc/blesdkios_en.md) +- [iOS command constants](https://github.com/RWFitSDK/RW_iOS_SDK/blob/69b35c2471d0e531556b77a51bdc116f2a218171/Frameworks/DHBleSDK.xcframework/ios-arm64/DHBleSDK.framework/Headers/DHBleCommandEnums.h) + +Modern behavior follows the published SDK. Legacy `7E` support retains the older +vendor-app-derived layouts. OTA sibling services are framing hints; a validated +response establishes the protocol. With no modern hint, a bounded legacy identity +read precedes an attempt at modern initialization. + +## Initialization and responses + +Modern frames are `AB flag lengthBE crc16ARC payload`. The payload starts with +command, key, and operation bytes. `01` responses require an echoed-triple `11` ACK; +`11` replies can carry data and are not acknowledged. `21` pushes carry unsolicited +data and never complete a pending transaction. The parser supports split headers, +continuations and coalesced frames with an 8 KiB payload limit. + +Initialization runs in order: `0302` session setup, `0202` timezone (signed quarter +hours and iOS platform `01`), `0201` clock, then `0263` function menu. Password-capable +rings require `0304` authentication using the SDK default `0000`. Authentication +failure is reported without resetting the password. History and manual actions +remain unavailable until initialization succeeds. + +Capabilities come from the function menu, not from the presence of an OTA service. +Commands wait for GATT transmission confirmation and a matching response; modern +transactions allow five seconds per response attempt and two retries. + +## History and manual measurements + +Only supported streams are queried. Modern history is requested repeatedly until +an explicit empty page; timers do not turn an unanswered request into success. +Current-day steps (`051A`) and historical steps (`0502`) are reconciled through the +existing cumulative/bucket persistence paths. + +**Sync consumes transferred history from the ring.** Non-sleep streams are deleted +only after the complete response has been validated and durably imported. Sleep +requires deletion after each page, so raw pages are first atomically saved to a +bounded, per-device journal in Application Support. Complete sleep sessions are +assembled and imported before removing that journal. It survives interruption and +is cleared with the user's app data. RwFit may no longer be able to import records +that PulseLoop has consumed. + +Live values arrive on `02`-group commands: HR `0224`, SpO2 `024E`, HRV `0269`, BP +`0231`, temperature `0230`, stress `024F`, and glucose `026C`. `0609` is measurement +status, never a reading: raw status zero means completion; other vendor statuses +are retained as diagnostics because their meanings are undocumented. A measurement +pauses history at a page boundary; stop must complete before history resumes. + +Successful empty sync, imported data, partial import, failure and cancellation are +separate outcomes. Only successful completion updates successful-sync freshness. +The greeting “Burning the midnight oil” follows phone time and is unrelated to BLE. + +## Tester verification + +On SR16 and SY01, verify initial connection and populated history, HR and SpO2 +spot readings, reconnect, and sync interrupted mid-transfer. Record firmware and +whether the same ring works in RwFit. Enable **Privacy & Data → Diagnostics → raw +packet capture** before reproducing, then export diagnostics. Raw health packets +remain opt-in in release builds; protocol stages and failures are logged normally.