From 0441a3a00a57c00acefdbddd9a073cdb35fd6e4c Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 8 Nov 2026 16:21:03 -0500 Subject: [PATCH 1/5] Erase ring history after a durable sync, matching each vendor app --- PulseLoop/Events/PulseEventBus.swift | 24 + PulseLoop/RingProtocol/LuckRingDriver.swift | 9 +- PulseLoop/RingProtocol/LuckRingProtocol.swift | 7 +- .../RWfitActivityPersistence.swift | 2 + PulseLoop/RingProtocol/RWfitDecoder.swift | 12 +- PulseLoop/RingProtocol/RWfitHistorySync.swift | 51 ++- PulseLoop/RingProtocol/RingBLEClient.swift | 6 + .../RingProtocol/RingHistoryCommit.swift | 42 ++ PulseLoop/RingProtocol/YCBTDriver.swift | 6 + PulseLoop/RingProtocol/YCBTEncoder.swift | 13 +- .../RingProtocol/YCBTHealthRecords.swift | 25 +- .../RingProtocol/YCBTHistoryTransfer.swift | 173 ++++++- PulseLoop/RingProtocol/YCBTProtocol.swift | 45 +- .../RingProtocol/YCBTSettingsEncoder.swift | 2 +- PulseLoop/RingProtocol/YCBTSyncEngine.swift | 2 + PulseLoop/Wearables/WearableDriver.swift | 6 + PulseLoopTests/CRPSyncEngineTests.swift | 18 + PulseLoopTests/LuckRingDriverTests.swift | 43 +- PulseLoopTests/RWfitDecoderTests.swift | 16 + .../RWfitHistoryPersistenceTests.swift | 18 + PulseLoopTests/RWfitHistorySyncTests.swift | 92 +++- PulseLoopTests/RingHistoryCommitTests.swift | 64 +++ PulseLoopTests/YCBTEncoderTests.swift | 37 ++ PulseLoopTests/YCBTHistoryEraseTests.swift | 433 ++++++++++++++++++ docs/hardware/luckring.md | 11 +- docs/hardware/r10m.md | 8 +- docs/hardware/rwfit.md | 17 +- docs/hardware/tk5.md | 37 +- 28 files changed, 1132 insertions(+), 87 deletions(-) create mode 100644 PulseLoop/RingProtocol/RingHistoryCommit.swift create mode 100644 PulseLoopTests/RingHistoryCommitTests.swift create mode 100644 PulseLoopTests/YCBTHistoryEraseTests.swift diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 75ffee23..d9502629 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -15,6 +15,9 @@ enum PulseEvent: Sendable { case batteryLevel(percent: Int) case decodedPacket(RingDecodedEvent) case rwfitSyncOutcome(RWfitSyncOutcome) + /// Durable-commit barrier: the persistence subscriber saves everything published before it and + /// resolves `RingHistoryCommit` with the outcome. Gates erasing history from the ring. + case historyCommitBarrier(UUID) case rwfitInitialization(RWfitInitializationState) case rwfitDiagnostic(message: String, metadata: [String: String]) case rwfitMeasurementOutcome(RWfitMeasurementOutcome) @@ -246,10 +249,29 @@ final class EventPersistenceSubscriber { } func persist(_ event: PulseEvent) { + if case let .historyCommitBarrier(id) = event { + commitBarrier(id) + return + } applyPersist(event) scheduleFlush() } + /// Every event published before the barrier has been applied by now (the stream is ordered), so a + /// successful throwing save here proves that history is durable and may be erased from the ring. + func commitBarrier(_ id: UUID) { + flushTask?.cancel() + flushTask = nil + do { + if context.hasChanges { try context.save() } + if pendingWrites > 0 { PulseDataChange.shared.notify() } + pendingWrites = 0 + RingHistoryCommit.resolve(id, committed: true) + } catch { + RingHistoryCommit.resolve(id, committed: false) + } + } + /// Debounced batch flush: reset the idle timer each event; flush immediately if too many writes /// have piled up. Coalesces a sync burst into a handful of saves + change signals. private func scheduleFlush() { @@ -443,6 +465,8 @@ final class EventPersistenceSubscriber { } seenHistoryKeys.removeAll(keepingCapacity: true) DailyCalorieEstimator.flushDirty(context: context) + case .historyCommitBarrier: + break // handled in `persist` before `applyPersist` (it must save, not batch) case .decodedPacket, .rwfitInitialization, .rwfitDiagnostic, .rwfitMeasurement, .rwfitMeasurementOutcome: break // `.wearState` is a live condition the measurement flow reacts to, not data — nothing to store. diff --git a/PulseLoop/RingProtocol/LuckRingDriver.swift b/PulseLoop/RingProtocol/LuckRingDriver.swift index 1c734c8b..b4971f60 100644 --- a/PulseLoop/RingProtocol/LuckRingDriver.swift +++ b/PulseLoop/RingProtocol/LuckRingDriver.swift @@ -66,8 +66,13 @@ final class LuckRingDriver: WearableDriver { func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] { guard let frame = assembler.append(data) else { return [] } - // ACK a device-initiated SEND before decoding — the ring retransmits until we do. - if frame.cmdType == .send { + // ACK every completed device frame before decoding, exactly as the vendor SDK does: the K6 proxy's + // `CEProtocolB.Analysis` tags each reassembled non-ACK frame `CMD_APP_ACK_TYPE` whatever its + // cmdType (SEND *and* SEND_NO_ACK/REQUEST — history arrives as SEND_NO_ACK), except dataType 144, + // and `CEDevQueue` pushes the ACK. The ring retransmits an un-ACKed SEND and treats an ACKed + // history frame as delivered — this is how the vendor app lets the ring move past data it has + // already handed over (it never sends the destructive `cleanData` 207 after a sync). + if frame.cmdType != .ack, frame.dataType != LuckRingDataType.noAckData { writer?.enqueue(packetizer.ack(dataType: frame.dataType, seq: frame.seq, devType: frame.devType)) } diff --git a/PulseLoop/RingProtocol/LuckRingProtocol.swift b/PulseLoop/RingProtocol/LuckRingProtocol.swift index 3138e46f..323bb300 100644 --- a/PulseLoop/RingProtocol/LuckRingProtocol.swift +++ b/PulseLoop/RingProtocol/LuckRingProtocol.swift @@ -38,9 +38,8 @@ enum LuckRingUUIDs { static let deviceType: UInt8 = 1 } -/// The `cmdType` byte (`CEBC.K6.CMD_TYPE_*`). The app ACKs a device-initiated **SEND**; it never ACKs an -/// ACK or a SEND_NO_ACK (which by definition expects none), so ACKing only `.send` can neither stall the -/// ring nor spam the wire. +/// The `cmdType` byte (`CEBC.K6.CMD_TYPE_*`). Despite the name, the vendor app ACKs every completed +/// device frame except an ACK (and dataType 144) — SEND_NO_ACK included (`CEProtocolB.Analysis`). enum LuckRingCmdType: UInt8, Sendable { case send = 1 case sendNoAck = 2 @@ -76,6 +75,8 @@ enum LuckRingDataType { static let stressHistory: UInt8 = 53 // body-recovery / stress (stored) static let userInfo: UInt8 = 102 // 9B profile static let language: UInt8 = 103 + /// The one dataType the vendor never ACKs (`CEProtocolB.Analysis` → `CMD_APP_NO_ACK_TYPE`). + static let noAckData: UInt8 = 144 static let time: UInt8 = 104 // 9B clock static let dataSwitch: UInt8 = 109 // 1 enables the real-time pushes static let mixInfo: UInt8 = 110 // the binding / startup TLV bundle diff --git a/PulseLoop/RingProtocol/RWfitActivityPersistence.swift b/PulseLoop/RingProtocol/RWfitActivityPersistence.swift index bcbdb20c..91f27115 100644 --- a/PulseLoop/RingProtocol/RWfitActivityPersistence.swift +++ b/PulseLoop/RingProtocol/RWfitActivityPersistence.swift @@ -3,6 +3,8 @@ import SwiftData /// A history transaction's activity snapshot. Reads throw; mutations never save internally. /// The caller commits all buckets and derived daily totals together before consuming ring history. +/// RWfit commits one page per transaction: buckets upsert by start epoch and day totals are +/// re-derived from every stored bucket of that day, so a day split across pages sums once. @MainActor final class RWfitActivityPersistence { nonisolated deinit {} diff --git a/PulseLoop/RingProtocol/RWfitDecoder.swift b/PulseLoop/RingProtocol/RWfitDecoder.swift index cbd64982..0d36265b 100644 --- a/PulseLoop/RingProtocol/RWfitDecoder.swift +++ b/PulseLoop/RingProtocol/RWfitDecoder.swift @@ -294,14 +294,20 @@ struct RWfitDecoder { } /// Validate the whole page before allowing destructive history consumption. - func validatedJieliHistory(key: UInt8, payload: [UInt8]) throws -> [RingDecodedEvent] { + /// + /// `continuation` marks a later page of the same stream. Only `051A` differs: the vendor reads + /// the 16-byte daily-total header only until an hourly record has arrived this sync + /// (`x5/b.java:3514`, counter reset per sync at `service/u1.java:488`), so later pages are bare + /// hourly records with the `0502` layout. + func validatedJieliHistory(key: UInt8, payload: [UInt8], continuation: Bool = false) 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 { + let layout = key == 0x1a && continuation ? RWfitJLDataType.steps : key + switch layout { case 0x1a: guard bodyCount >= 16, (bodyCount - 16).isMultiple(of: 16) else { throw HistoryDecodeError.malformedPage @@ -316,7 +322,7 @@ struct RWfitDecoder { guard bodyCount.isMultiple(of: 6) else { throw HistoryDecodeError.malformedPage } default: throw HistoryDecodeError.unsupportedType } - return (decodeJieliHistory(key: key, payload: payload) ?? []).filter { + return (decodeJieliHistory(key: layout, payload: payload) ?? []).filter { if case .commandAck = $0 { return false } return true } diff --git a/PulseLoop/RingProtocol/RWfitHistorySync.swift b/PulseLoop/RingProtocol/RWfitHistorySync.swift index f57610a5..bb1660a1 100644 --- a/PulseLoop/RingProtocol/RWfitHistorySync.swift +++ b/PulseLoop/RingProtocol/RWfitHistorySync.swift @@ -1,6 +1,6 @@ import Foundation -/// Response-driven history transfer. Modern history is consumed only after a durable save; +/// Response-driven history transfer. Each modern page is consumed only after a durable save; /// sleep pages are journaled because a single session can span multiple ring pages. @MainActor final class RWfitHistorySync { @@ -134,11 +134,17 @@ final class RWfitHistorySync { return outcome } + /// Mirrors the vendor pager (`blesdk/service/p.java:62-79` HR; `v.java` steps, `t.java` sleep, + /// `o.java` BP, `n.java` SpO2, `x.java` temperature, `q.java` HRV, `s.java` stress, `m.java` + /// blood sugar, `w.java:96-118` today's steps): request `05 xx 10`; a page with records is + /// consumed with `05 xx 30` before the next request; a page without records ends the stream and + /// is never deleted. Unlike the vendor, which deletes before handing the page to its listeners, + /// every page is durable (saved, or journaled for sleep) before its delete is sent. 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 + var saved = 0 let deadline = Date().addingTimeInterval(180) while true { try await boundary(token) @@ -148,37 +154,46 @@ final class RWfitHistorySync { 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 + guard pages <= 4096 else { throw RWfitSessionError.invalidResponse } + // `051A`'s first page leads with the 16-byte daily total; one carrying no hourly records + // is the vendor's empty list too (`x5/b.java:3514-3571`, `w.java:66-92`): import the + // total, but end the stream without consuming it. + let terminal = payload.count == 3 || (key == 0x1a && pages == 1 && payload.count == 3 + 16) if type == .sleep { if !terminal { try RWfitDecoder.validateJieliSleepPage(payload) + // A session can span pages, so the raw page is journaled rather than imported; + // the journal survives disconnection, failed saves, and app restart. 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. + } else if payload.count > 3 { + let events = try decoder.validatedJieliHistory(key: key, payload: payload, continuation: pages > 1) + try persist(events) + importedRecords += events.count + saved += events.count + try check(token) + } + if !terminal { + // Deleting advances the stream. A lost reply fails the type (the gate never repeats + // a delete); the ring then re-sends at most this already-saved page next sync. _ = 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 { + let payload = try RWfitHistoryPersistence.sleepPayload(deviceID: deviceIdentifier) + let events = payload.isEmpty ? [] : try decoder.validatedJieliHistory(key: key, payload: payload) + try persist(events) + importedRecords += events.count + saved = events.count + try check(token) 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 + rwfitDiagnostic("History saved", ["type": type.label, "records": String(saved)]) + return saved } private func legacy(type: RWfitHistoryType, token: UUID) async throws -> Int { diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 6cf238e2..0e4c3f1d 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -729,6 +729,12 @@ extension RingBLEClient: RingCommandWriter { deliverDecoded(event) } + /// Published through the same `publish` path as every decoded history event, so the persistence + /// subscriber reaches the barrier only after applying all of them. + func commitDeliveredHistory(_ completion: @escaping @MainActor (Bool) -> Void) { + publish(.historyCommitBarrier(RingHistoryCommit.register(completion))) + } + private func deliverDecoded(_ decoded: RingDecodedEvent) { for event in RingNotificationDelivery.events(for: decoded) { publish(event) } if case let .supportFunctions(derived) = decoded { applySupportFunctions(derived) } diff --git a/PulseLoop/RingProtocol/RingHistoryCommit.swift b/PulseLoop/RingProtocol/RingHistoryCommit.swift new file mode 100644 index 00000000..95ab74da --- /dev/null +++ b/PulseLoop/RingProtocol/RingHistoryCommit.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Durable-commit barrier for destructive history consumption. +/// +/// Vendor apps erase a ring's stored history once it has been imported (SmartHealth/YCBT deletes each +/// type only after its database insert succeeds; RWfit deletes page by page). PulseLoop's normal +/// history path is asynchronous — decoded events hop through `PulseEventBus` and are saved by +/// `EventPersistenceSubscriber` on a debounce — so the moment a transfer completes, nothing is on disk +/// yet. Erasing then would lose the data on a crash or a failed save. +/// +/// A barrier closes that gap: the writer publishes `.historyCommitBarrier(id)` on the same ordered path +/// as the history events it follows, the persistence subscriber reaches it only after applying every +/// earlier event, performs a throwing `context.save()`, and resolves the waiter with the outcome. The +/// caller erases ring history only on `true`. Every failure mode — no subscriber running, a failed save, +/// the timeout — resolves `false`, so the ring keeps its data and the next sync simply re-imports it +/// (history rows are upserted, so the replay is harmless). +@MainActor +enum RingHistoryCommit { + /// Generous: the subscriber normally answers within one main-actor turn, but a large sync burst can + /// queue thousands of events ahead of the barrier. + nonisolated static let timeoutNanoseconds: UInt64 = 20_000_000_000 + + private static var waiters: [UUID: @MainActor (Bool) -> Void] = [:] + + /// Register a waiter and return the barrier id to publish. The completion runs exactly once. + static func register(timeoutNanoseconds: UInt64 = timeoutNanoseconds, + _ completion: @escaping @MainActor (Bool) -> Void) -> UUID { + let id = UUID() + waiters[id] = completion + Task { @MainActor in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + resolve(id, committed: false) + } + return id + } + + /// Called by the persistence subscriber once everything published before the barrier is saved (or + /// the save failed). A no-op for an unknown or already-resolved id. + static func resolve(_ id: UUID, committed: Bool) { + waiters.removeValue(forKey: id)?(committed) + } +} diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift index a036eafc..1ee135e0 100644 --- a/PulseLoop/RingProtocol/YCBTDriver.swift +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -170,6 +170,8 @@ final class YCBTDriver: WearableDriver { /// catalog while we're disconnected — one `05 xx` query every 10 s — into the write queue, and the /// reconnect would flush those stale queries before the handshake, desyncing the fresh transfer /// against dumps it never asked for. Killing it at disconnect is the only place that can't race. + /// The same goes for the erase phase: its delete watchdog stops here, and a commit verdict that + /// lands after the drop is ignored, so nothing is deleted on behalf of a link that's gone. func connectionDidEnd() { assembler.reset() transfer.cancel() @@ -212,9 +214,13 @@ final class YCBTDriver: WearableDriver { /// Fold the ring's own `02 01` reply into what we believe it has. Additive only, exactly as the /// coordinator does for the UI-facing set — the bitmap can grant a gated capability, never revoke a /// baseline one. + /// + /// The transfer is told too: from here on `isSupported` filters with the ring's real set, which is + /// what makes a saved history log complete enough to erase (`YCBTHistoryTransfer`'s erase phase). private func updateCapabilities(from events: [RingDecodedEvent]) { for case let .supportFunctions(derived) in events { capabilities = profile.refined(bitmapDerived: derived) + transfer.noteCapabilityBitmapReceived() } } diff --git a/PulseLoop/RingProtocol/YCBTEncoder.swift b/PulseLoop/RingProtocol/YCBTEncoder.swift index b38577b1..8fd62ece 100644 --- a/PulseLoop/RingProtocol/YCBTEncoder.swift +++ b/PulseLoop/RingProtocol/YCBTEncoder.swift @@ -42,8 +42,11 @@ struct YCBTEncoder { /// **Never add these to it** — each was once here, and each was a different kind of wrong: /// • **No `05 xx`.** The Health group is the *history* protocol: `YCBTHistoryTransfer` owns those /// queries and a stray one here would race it. Worse, `05 40…4E` are the Health **Delete** - /// opcodes — they erase the ring's stored log. The commands that actually make the ring *record* - /// between syncs are the five `01 xx {enable, interval}` monitors below. + /// opcodes — they erase the ring's stored log. The transfer sends them itself, and only for a type + /// it has just fetched *and* whose records `commitDeliveredHistory` has proved are on disk; a + /// handshake has fetched nothing, so a delete from here could only ever destroy unsynced data. + /// The commands that actually make the ring *record* between syncs are the five + /// `01 xx {enable, interval}` monitors below. /// • **No `04 xx`.** Group 4 is DevControl, the *device→app* push channel. The app's only /// legitimate `04` write is an ACK for a push it received (`YCBTDriver.acknowledgePush`) — the /// `04 0e 00` seen in a capture was SmartHealth ACKing a `MeasurementResult`, not a handshake @@ -148,6 +151,12 @@ struct YCBTEncoder { YCBTHealthCommand.historyBlockAck(status: status) } + /// Erase one type's stored log: `05 {02}`. Exposed for the byte pins only — the sole + /// sender is `YCBTHistoryTransfer`'s post-commit erase phase. `nil` for a type with no delete opcode. + func healthHistoryDelete(_ type: YCBTHistoryType) -> [UInt8]? { + YCBTHealthCommand.historyDelete(type) + } + // MARK: - Live actions /// Live measurement start/stop via `03 2f` with a `[enable:1][mode:1]` payload. The **mode byte diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift index ecc2fbc9..69140486 100644 --- a/PulseLoop/RingProtocol/YCBTHealthRecords.swift +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -39,9 +39,21 @@ enum YCBTHealthRecords { /// /// These are **interval** buckets (each covers start→end), not a running total, so they ride /// `.activityBucket`: upsert by start epoch, day total = sum of distinct buckets — idempotent across - /// re-syncs even though we never delete records from the ring. The All record's step field is the - /// opposite (a cumulative daily counter → `.activityUpdate`, a per-day `max` ratchet); the queue asks - /// for sport *before* all, so the cumulative counter always has the last word on a day's total. + /// re-syncs, whether the ring replays its log (a sync whose erase was skipped) or not. + /// + /// A re-reported bucket **replaces** the stored one, which is exactly what SmartHealth does: its + /// `step_data` table has a unique `(start_timestamp, device_mac_address)` index and `StepDao` writes + /// with `INSERT OR REPLACE` (`AppDatabase_Impl.java:133`, `StepDao_Impl.java:29`), and it erases the + /// sport log after every sync (`DataSyncUtils.java:255-256`). The vendor's whole step history therefore + /// rests on the firmware never re-reporting an erased bucket's start with a smaller count — if it did, + /// SmartHealth's own totals would drop on every sync. We match it rather than diverge (a per-bucket + /// `max` would also change the Colmi path, which shares `applyActivityBucket`), and today's visible + /// total is floored regardless, by `applyActivityBucket`'s same-day `max` and by the cumulative + /// counter below. (Unverified on hardware: whether an *open* bucket is stored before it closes.) + /// + /// The All record's step field is the opposite (a cumulative daily counter → `.activityUpdate`, a + /// per-day `max` ratchet); the queue asks for sport *before* all, so the cumulative counter always + /// has the last word on a day's total. /// /// Calories are deliberately dropped: `.activityBucket` has no calorie channel (the ring's estimate /// is unverified, and the live status stream already feeds the day's calorie ratchet). @@ -72,9 +84,10 @@ enum YCBTHealthRecords { /// `[ts:u32][isInflated@4][systolic@5][diastolic@6][heartRate@7]` (`DataUnpack` case 8). /// /// Emitted as two `.historyMeasurement`s rather than a `.bloodPressureSample`: history rows **upsert** - /// on (kind, timestamp) while live samples **append**, and the ring replays its whole log on every - /// sync (we never send the Health-Delete opcodes). A `.bloodPressureSample` here would therefore - /// duplicate every stored reading on every re-sync. Live/spot BP keeps `.bloodPressureSample`. + /// on (kind, timestamp) while live samples **append**, and the ring replays its whole log on any sync + /// whose Health-Delete was skipped (no durable commit, no bitmap yet, an unanswered delete). A + /// `.bloodPressureSample` here would therefore duplicate every stored reading on each such replay. + /// Live/spot BP keeps `.bloodPressureSample`. /// /// `isInflated` flags the ring's own cuff-style sweep; it doesn't gate validity, so it isn't read. static func bloodPressure(_ buffer: [UInt8]) -> [RingDecodedEvent] { diff --git a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift index c95bc8f3..c93ad61c 100644 --- a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift +++ b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift @@ -1,4 +1,7 @@ import Foundation +import os + +private let transferLog = Logger(subsystem: "xyz.sakshambhutani.pulseloop2", category: "YCBTHistoryTransfer") /// The YCBT history state machine — protocol-driven, not timer-driven. /// @@ -17,6 +20,31 @@ import Foundation /// only*: it never ACKs (an ACK without a verified terminal block claims data we don't hold) and is /// never a completion signal — it just abandons a type the ring has gone silent on. /// +/// **Erase phase.** Like SmartHealth (`DataSyncUtils.setDBListener$lambda$4`, `DataSyncUtils.java:251-276`), +/// a type whose log was read successfully is then erased from the ring with `05 {02}`, so the +/// next sync is small instead of a replay of the whole log. The vendor deletes once its database insert +/// succeeds; our equivalent is `RingCommandWriter.commitDeliveredHistory`, a throwing save of everything +/// delivered so far. The rules, each of which exists because the delete is irreversible: +/// +/// • A type is *eligible* only when its transfer ended in a CRC-accepted terminal block **and** carried +/// at least one record. A no-data header, a rejection, a CRC failure or a watchdog skip earns nothing +/// — the same cases the vendor's `code == 0 && result != null` check excludes. +/// • Deletes go out only **after the last query** — where the vendor's land too: `syncData` queues every +/// query up front (`DataSyncUtils.java:99-143`) and each delete is issued later, from that type's +/// insert callback, onto the tail of the SDK's serial queue (`deleteWatchesHistoryData`, :356-372) — +/// and only after the commit completed `true`. `false` (a failed save, a missing persistence +/// subscriber, the barrier timeout) sends none. +/// • One at a time, in completion order: write, wait for the reply on that key (or a 1-byte error, or +/// the watchdog), move on. No retry — the vendor ignores the result too, and a type left undeleted +/// simply replays next sync, which every history sink already upserts. +/// • Nothing is erased before the ring's `02 01` capability bitmap has arrived on this connection. The +/// driver drops samples for sensors it doesn't yet believe the ring has, so a type decoded on the +/// baseline set may have been saved with fields missing; the bitmap re-queues those types (see +/// `YCBTSyncEngine.applySupportFunctions`), and a re-queued type loses its eligibility until the +/// re-run completes. The vendor never syncs without the bitmap at all. +/// • `.historySyncFinished` waits for the erase phase, so a `start` can't interleave a query with it, +/// and `cancel()` (disconnect) abandons it — a commit verdict from a dead link is ignored. +/// /// Full spec, with a byte-level example: `docs/YCBT-Protocol.md` §4. @MainActor final class YCBTHistoryTransfer { @@ -28,10 +56,16 @@ final class YCBTHistoryTransfer { case requestSent(YCBTHistoryType) /// Header seen; accumulating data frames until the terminal block. case receiving(YCBTHistoryType) + /// Every query is done; waiting for `commitDeliveredHistory` to prove the walk is on disk. + case awaitingCommit + /// `05 {02}` written for this type; waiting for the ring's reply. + case deleting(YCBTHistoryType) + /// The type being *fetched*. `nil` while committing or erasing, so no query-phase frame can be + /// charged to anything then. var type: YCBTHistoryType? { switch self { - case .idle: return nil + case .idle, .awaitingCommit, .deleting: return nil case let .requestSent(type), let .receiving(type): return type } } @@ -50,6 +84,15 @@ final class YCBTHistoryTransfer { /// Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. private var unsupported: Set = [] + /// Types eligible for the erase phase, in completion order: CRC-accepted, ≥1 record, not re-queued + /// since. See the type doc. + private var pendingDeletes: [YCBTHistoryType] = [] + /// Whether the ring's `02 01` bitmap has arrived on this connection — the precondition for any erase. + private var capabilityBitmapReceived = false + /// Bumped by `cancel()`. The commit verdict is asynchronous; one captured on a link that has since + /// dropped must not authorize an erase on the next. + private var generation = 0 + /// Ceiling on the reassembled buffer. Sized from the header when we have one so a desynced stream /// (data frames with no header, or a type that never terminates) can't grow it without bound. private static let defaultBufferCap = 64 * 1024 @@ -68,7 +111,8 @@ final class YCBTHistoryTransfer { // MARK: - Driving the queue - /// True while a type is being requested or received. + /// True while a type is being requested or received, or the erase phase that follows the last one is + /// running — the walk isn't over until its deletes are. var isActive: Bool { state != .idle } /// Seed the queue and request the first type. Types the ring already rejected this session are @@ -78,7 +122,9 @@ final class YCBTHistoryTransfer { /// post-workout vitals backfill, the 30-minute periodic pass), and a second `start` would abandon the /// in-flight type mid-dump: the ring keeps streaming its data frames regardless, so they would land in /// the *new* type's buffer and fail its terminal CRC. Deferring costs nothing — the watchdog - /// guarantees an in-flight transfer always completes or is abandoned. + /// guarantees an in-flight transfer always completes or is abandoned, and the erase phase is bounded + /// by the commit timeout plus one watchdog per delete. The erase phase counts as in flight: a query + /// interleaved with a delete could be answered out of order against either. func start(types: [YCBTHistoryType]) { guard !isActive else { return } queue = types.filter { !unsupported.contains($0.queryKey) } @@ -96,6 +142,10 @@ final class YCBTHistoryTransfer { /// Duplicates are dropped rather than re-queued: asking twice would re-dump a log we already hold and /// re-upsert every record in it. Idle is handled too, so a late bitmap after a finished walk still /// pulls its types. + /// + /// A re-queued type loses any pending erase: it is re-fetched *because* the first read is suspect + /// (decoded before the bitmap unlocked some of its fields), so only the re-run can earn it back. While + /// the erase phase is running the additions simply wait; the phase hands over to them when it ends. func append(types: [YCBTHistoryType]) { let additions = types.filter { type in !unsupported.contains(type.queryKey) @@ -103,20 +153,31 @@ final class YCBTHistoryTransfer { && state.type != type } guard !additions.isEmpty else { return } + pendingDeletes.removeAll { additions.contains($0) } queue.append(contentsOf: additions) guard !isActive else { return } publishOutOfBand(advance()) } - /// Abandon any in-flight transfer (disconnect / teardown). + /// Abandon any in-flight transfer (disconnect / teardown). Also forgets the erase phase and the + /// bitmap: both belong to one link, and a commit verdict still in flight is orphaned by `generation`. func cancel() { cancelWatchdog() state = .idle queue.removeAll() buffer.removeAll() + pendingDeletes.removeAll() + capabilityBitmapReceived = false + generation &+= 1 } - /// Request the next type, or report completion when the queue drains. Returns the events the + /// The driver saw this connection's `02 01` reply, so the capability set its sample filter uses is + /// final. Until then nothing is erased (see the type doc). + func noteCapabilityBitmapReceived() { + capabilityBitmapReceived = true + } + + /// Request the next type, or move to the erase phase when the queue drains. Returns the events the /// caller should surface (only ever the completion signal — progress comes from the header). @discardableResult private func advance() -> [RingDecodedEvent] { @@ -126,10 +187,7 @@ final class YCBTHistoryTransfer { retriedCurrentType = false expectedPackets = 0 expectedBytes = 0 - guard !queue.isEmpty else { - state = .idle - return [.historySyncFinished] - } + guard !queue.isEmpty else { return beginErasePhase() } sendQuery(queue.removeFirst()) return [] } @@ -148,8 +206,14 @@ final class YCBTHistoryTransfer { /// Feed every validated Health-group (`type == 0x05`) frame here. Frames arriving while idle are /// ignored — the ring occasionally trails a stray block after we've moved on. func handle(cmd: UInt8, payload: [UInt8]) -> [RingDecodedEvent] { + if case let .deleting(type) = state { return handleDeleteReply(type, cmd: cmd, payload: payload) } guard let type = state.type else { return [] } + // Only a frame on one of *this* type's keys can speak for it. The error check below used to run + // on any Health frame, so a stray rejection — a late reply to a delete the watchdog gave up on — + // would have advanced past a type mid-dump. + guard cmd == type.queryKey || cmd == type.ackKey || cmd == YCBTHealth.terminalBlock else { return [] } + // A 1-byte 0xFB…0xFF payload is a rejection, not data — check before anything reads offsets. if let error = YCBTFrameError.detect(in: payload) { if error.isPermanent { unsupported.insert(type.queryKey) } @@ -230,7 +294,13 @@ final class YCBTHistoryTransfer { ))) guard matches else { return retryOrSkip(type) } - return YCBTHealthRecords.decode(buffer, type: type) + advance() + let records = YCBTHealthRecords.decode(buffer, type: type) + // Eligibility is judged on what the ring *sent*, not on what decoded: a log of unworn `hr == 0` + // samples is still a log the vendor would erase, and keeping it only replays junk. + if buffer.count >= (type.recordStride ?? 1), type.deleteKey != nil, !pendingDeletes.contains(type) { + pendingDeletes.append(type) + } + return records + advance() } /// One re-request per type on a corrupt transfer; if that also fails, drop the type rather than @@ -243,6 +313,84 @@ final class YCBTHistoryTransfer { return [] } + // MARK: - Erase phase + + /// The queue has drained. With nothing eligible — or no bitmap yet — the walk is simply finished. + /// Otherwise ask for the durable commit. + /// + /// The commit request is deferred to the next main-actor turn on purpose. This usually runs inside + /// `handle`, whose return value carries the *last* type's decoded records back to the driver; they + /// are published only after `handle` returns. Requesting the barrier synchronously would publish it + /// ahead of them, and the commit would vouch for a save that didn't include them. + private func beginErasePhase() -> [RingDecodedEvent] { + guard capabilityBitmapReceived, !pendingDeletes.isEmpty, writer != nil else { + pendingDeletes.removeAll() + state = .idle + return [.historySyncFinished] + } + state = .awaitingCommit + let generation = self.generation + Task { @MainActor [weak self] in + guard let self, self.generation == generation, self.state == .awaitingCommit else { return } + guard let writer = self.writer else { return self.resolveCommit(false, generation: generation) } + writer.commitDeliveredHistory { [weak self] committed in + self?.resolveCommit(committed, generation: generation) + } + } + return [] + } + + /// `true` releases the deletes; anything else drops them — the ring keeps its data and the next sync + /// re-imports it. A verdict from a cancelled link (`generation` moved on) is ignored outright. + private func resolveCommit(_ committed: Bool, generation: Int) { + guard generation == self.generation, state == .awaitingCommit else { return } + if !committed { pendingDeletes.removeAll() } + publishOutOfBand(sendNextDelete()) + } + + /// Write the next eligible delete, or — when none remain — hand back to the queue: types appended + /// during the phase (a late bitmap) are fetched now, and an empty queue finishes the walk. + private func sendNextDelete() -> [RingDecodedEvent] { + cancelWatchdog() + while !pendingDeletes.isEmpty { + let type = pendingDeletes.removeFirst() + guard let command = YCBTHealthCommand.historyDelete(type) else { continue } + state = .deleting(type) + writer?.enqueue(Data(command)) + armDeleteWatchdog(for: type) + return [] + } + state = .idle + return advance() + } + + /// The ring answers a delete on the same key (`0x40…0x52` = done) or with a 1-byte `0xFB…0xFF` + /// error. Either way the type is finished with: the vendor only logs the result + /// (`DataSyncUtils.deleteWatchesHistoryData$lambda$5/6`), and an undeleted log just replays next sync. + /// Frames on any other key are not ours to interpret. + private func handleDeleteReply(_ type: YCBTHistoryType, cmd: UInt8, payload: [UInt8]) -> [RingDecodedEvent] { + guard cmd == type.deleteKey else { return [] } + if let error = YCBTFrameError.detect(in: payload) { + transferLog.info("Delete of \(type.label, privacy: .public) rejected: 0x\(String(error.rawValue, radix: 16), privacy: .public)") + } else { + transferLog.info("Deleted \(type.label, privacy: .public) history from the ring") + } + return sendNextDelete() + } + + /// A ring that never answers a delete must not hold the walk open. No retry: the type is simply left + /// for the next sync to read (and erase) again. + private func armDeleteWatchdog(for type: YCBTHistoryType) { + watchdog?.cancel() + let delay = inactivitySeconds + watchdog = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled, let self, self.state == .deleting(type) else { return } + transferLog.info("Delete of \(type.label, privacy: .public) unanswered; moving on") + self.publishOutOfBand(self.sendNextDelete()) + } + } + // MARK: - Stall watchdog (safety net) /// Inactivity window, re-armed by every history frame for the in-flight type. @@ -280,8 +428,9 @@ final class YCBTHistoryTransfer { /// walk that the watchdog ended would otherwise leave today's steps stale until the next sync. var onOutOfBandEvents: (([RingDecodedEvent]) -> Void)? - /// `handle` returns its events to the driver, which publishes them. `start` and the watchdog have no - /// such return channel, so the one event they can produce — completion — is surfaced here. + /// `handle` returns its events to the driver, which publishes them. `start`, the watchdogs and the + /// commit verdict have no such return channel, so the one event they can produce — completion — is + /// surfaced here. private func publishOutOfBand(_ events: [RingDecodedEvent]) { guard events.contains(where: { if case .historySyncFinished = $0 { return true } else { return false } }) else { return } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index 3cca23d1..68697250 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -215,16 +215,33 @@ struct YCBTHistoryType: Equatable, Hashable, Sendable { let recordStride: Int? /// Human label for the sync-progress UI ("Syncing sleep…"). let label: String + /// `05 {02}` erases every stored record of this type (`YCBTClient.deleteHealthHistoryData`). + /// Sent **only** by `YCBTHistoryTransfer`'s erase phase, after `RingCommandWriter.commitDeliveredHistory` + /// has proved this type's records are on disk. `nil` ⇒ never deleted. + /// + /// The keys are `Constants.DATATYPE.Health_Delete*` (`Constants.java:265-286`) minus the `0x0500` group + /// prefix. They are *not* a fixed offset from the query key, and two neighbours are traps: `0x46` is + /// temperature-**and-humidity** (`05 1c`, a log we never fetch) — plain temperature (`05 1e`) is `0x47` + /// — and `0x4D` is the background-reminder log, not body data (`0x4E`). + let deleteKey: UInt8? + + init(queryKey: UInt8, ackKey: UInt8, recordStride: Int?, label: String, deleteKey: UInt8? = nil) { + self.queryKey = queryKey + self.ackKey = ackKey + self.recordStride = recordStride + self.label = label + self.deleteKey = deleteKey + } - static let sport = YCBTHistoryType(queryKey: 0x02, ackKey: 0x11, recordStride: 14, label: "activity") - static let sleep = YCBTHistoryType(queryKey: 0x04, ackKey: 0x13, recordStride: nil, label: "sleep") - static let heart = YCBTHistoryType(queryKey: 0x06, ackKey: 0x15, recordStride: 6, label: "heart rate") - static let blood = YCBTHistoryType(queryKey: 0x08, ackKey: 0x17, recordStride: 8, label: "blood pressure") - static let all = YCBTHistoryType(queryKey: 0x09, ackKey: 0x18, recordStride: 20, label: "vitals") - static let spo2 = YCBTHistoryType(queryKey: 0x1a, ackKey: 0x22, recordStride: 6, label: "blood oxygen") - static let temperature = YCBTHistoryType(queryKey: 0x1e, ackKey: 0x26, recordStride: 7, label: "temperature") - static let comprehensive = YCBTHistoryType(queryKey: 0x2f, ackKey: 0x30, recordStride: 44, label: "metabolic") - static let bodyData = YCBTHistoryType(queryKey: 0x33, ackKey: 0x34, recordStride: 28, label: "body data") + static let sport = YCBTHistoryType(queryKey: 0x02, ackKey: 0x11, recordStride: 14, label: "activity", deleteKey: 0x40) + static let sleep = YCBTHistoryType(queryKey: 0x04, ackKey: 0x13, recordStride: nil, label: "sleep", deleteKey: 0x41) + static let heart = YCBTHistoryType(queryKey: 0x06, ackKey: 0x15, recordStride: 6, label: "heart rate", deleteKey: 0x42) + static let blood = YCBTHistoryType(queryKey: 0x08, ackKey: 0x17, recordStride: 8, label: "blood pressure", deleteKey: 0x43) + static let all = YCBTHistoryType(queryKey: 0x09, ackKey: 0x18, recordStride: 20, label: "vitals", deleteKey: 0x44) + static let spo2 = YCBTHistoryType(queryKey: 0x1a, ackKey: 0x22, recordStride: 6, label: "blood oxygen", deleteKey: 0x45) + static let temperature = YCBTHistoryType(queryKey: 0x1e, ackKey: 0x26, recordStride: 7, label: "temperature", deleteKey: 0x47) + static let comprehensive = YCBTHistoryType(queryKey: 0x2f, ackKey: 0x30, recordStride: 44, label: "metabolic", deleteKey: 0x4c) + static let bodyData = YCBTHistoryType(queryKey: 0x33, ackKey: 0x34, recordStride: 28, label: "body data", deleteKey: 0x4e) /// Every type the SDK's `DataSyncUtils` can request, in its own ascending-key sync order — and every /// type `YCBTHealthRecords` decodes. Both YCBT families query the whole catalog, whatever their @@ -313,6 +330,8 @@ enum YCBTHealth { static let headerPayloadLength = 10 /// The terminal block's payload length. static let terminalPayloadLength = 6 + /// The single payload byte of every Health-Delete command (`deleteHealthHistoryData` sends `{2}`). + static let deletePayload: UInt8 = 0x02 } /// Logical (unframed) Health-group commands. Shared so the transfer machine and a family's encoder @@ -329,6 +348,14 @@ enum YCBTHealthCommand { static func historyBlockAck(status: UInt8) -> [UInt8] { [YCBTGroup.health, YCBTHealth.terminalBlock, status] } + + /// Erase every stored record of one type: `05 {02}` (`YCBTClient.deleteHealthHistoryData`, + /// `YCBTClient.java:949-951`). **Destructive** — the only caller is `YCBTHistoryTransfer`'s erase phase, + /// which sends it only after a durable commit. `nil` for a type with no delete opcode. + static func historyDelete(_ type: YCBTHistoryType) -> [UInt8]? { + guard let key = type.deleteKey else { return nil } + return [YCBTGroup.health, key, YCBTHealth.deletePayload] + } } /// Device-side rejection of a command. The SDK's `isError` treats **any** 1-byte response payload in diff --git a/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift index 2e9bccc9..26ad88fb 100644 --- a/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift +++ b/PulseLoop/RingProtocol/YCBTSettingsEncoder.swift @@ -82,7 +82,7 @@ struct YCBTSettingsEncoder { /// The five background samplers, each `{enable, intervalMinutes}`. **These — not the `05 4x` burst /// PulseLoop used to send — are what make the ring record anything between syncs.** `05 40…4E` are - /// the Health *Delete* opcodes. + /// the Health *Delete* opcodes; only `YCBTHistoryTransfer` sends them, after a durable commit. /// /// `MeasurementSettings` has no blood-pressure flag (no other family has an all-day BP sampler), and /// the ring derives BP from the same PPG sweep as heart rate, so BP rides the HR toggle. diff --git a/PulseLoop/RingProtocol/YCBTSyncEngine.swift b/PulseLoop/RingProtocol/YCBTSyncEngine.swift index efb7b1c5..ffca5a94 100644 --- a/PulseLoop/RingProtocol/YCBTSyncEngine.swift +++ b/PulseLoop/RingProtocol/YCBTSyncEngine.swift @@ -134,6 +134,8 @@ final class YCBTSyncEngine: RingSyncEngine { // The `05 09` combined record carries BP, HRV, temperature and blood sugar as *optional fields* of // a record we always fetch — so unlike the others, a late unlock of one of these doesn't add a new // query, it means the one we already ran was decoded with those fields dropped. Re-run it once. + // Re-queuing also withdraws the type's pending erase (`YCBTHistoryTransfer.append`): the copy we + // saved is the incomplete one, so the ring's must survive until the re-run is on disk. if added.contains(where: { [.bloodPressure, .hrv, .temperature, .bloodSugar].contains($0) }) { newTypes.append(.all) } diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index 8b87b27b..f84924e5 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -12,10 +12,16 @@ protocol RingCommandWriter: AnyObject { func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) /// Deliver a synthetic decoded event without inventing a received packet. func emit(_ event: RingDecodedEvent) + /// Completes `true` once every history event delivered before this call has been durably saved — + /// the precondition for erasing that history from the ring (see `RingHistoryCommit`). Any failure + /// or timeout completes `false`; callers must then leave the ring's data in place. + func commitDeliveredHistory(_ completion: @escaping @MainActor (Bool) -> Void) } extension RingCommandWriter { var deviceIdentifier: String? { nil } + /// Fail closed: a writer that cannot prove persistence never authorizes an erase. + func commitDeliveredHistory(_ completion: @escaping @MainActor (Bool) -> Void) { completion(false) } func enqueueTracked(_ command: Data, completion: @escaping @MainActor (Result) -> Void) { enqueue(command) completion(.success(())) diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 1ae96a8e..2bd8aa43 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -290,6 +290,24 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertEqual(sleepDays, [0], "periodic history pulls today, not the per-link backfill") } + /// The CRP ring SDK (`com.crrepa.ble`) sends nothing after the last history frame — no ack, no erase: + /// the ring keeps a fixed per-day store (days 0…14). So a full sync must never write a destructive + /// power-group frame (factory reset / shut down / restart). + func testHistoryPassNeverSendsDestructiveFrames() { + let w = FakeWriter() + let engine = CRPSyncEngine(writer: w) + engine.setUserProfile(UserProfileValues(metric: true, sex: "male", age: 30, heightCm: 180, weightKg: 75)) + engine.runStartup() + finishTimingHistory(engine) + engine.syncHistory() + finishTimingHistory(engine) + + XCTAssertFalse(w.opcodes.isEmpty) + let destructive: Set = [CRPCommands.cmdFactoryReset, CRPCommands.cmdShutDown, CRPCommands.cmdRestart] + XCTAssertFalse(w.opcodes.contains { $0[0] == Int(CRPCommands.groupPower) && destructive.contains($0[1]) }, + "a sync must never reset, shut down or restart the ring") + } + func testAllTerminalTimingStreamsPublishExactlyOneDone() { let w = FakeWriter() var stages: [String] = [] diff --git a/PulseLoopTests/LuckRingDriverTests.swift b/PulseLoopTests/LuckRingDriverTests.swift index 88379b3b..a2fc4cf2 100644 --- a/PulseLoopTests/LuckRingDriverTests.swift +++ b/PulseLoopTests/LuckRingDriverTests.swift @@ -2,9 +2,10 @@ import XCTest import CoreBluetooth @testable import PulseLoop -/// The driver's inbound routing, and the one thing no decoder can do: **acknowledging a device-initiated -/// SEND**. The ring retransmits an un-ACKed SEND until the app answers, so a missing ACK wedges the link; -/// an ACK for an ACK, or for a SEND_NO_ACK, is wrong the other way. +/// The driver's inbound routing, and the one thing no decoder can do: **acknowledging device frames** +/// the way the vendor SDK does — every completed non-ACK frame except dataType 144. The ring retransmits +/// an un-ACKed SEND until the app answers, so a missing ACK wedges the link; an ACK for an ACK is wrong +/// the other way. @MainActor final class LuckRingDriverTests: XCTestCase { private final class FakeWriter: RingCommandWriter { @@ -57,16 +58,46 @@ final class LuckRingDriverTests: XCTestCase { XCTAssertTrue(writer.sent.isEmpty, "an ACK must never be ACKed") } - func testSendNoAckIsNotAcked() { + /// The vendor's `CEProtocolB.Analysis` ACKs SEND_NO_ACK frames too — history arrives that way, and + /// the ACK is what tells the ring the records were delivered. + func testSendNoAckIsAckedLikeTheVendor() { let writer = FakeWriter() let driver = LuckRingDriver(writer: writer) - let frame = LuckRingFrame(cmdType: .sendNoAck, dataType: LuckRingDataType.realHeart, + let frame = LuckRingFrame(cmdType: .sendNoAck, dataType: LuckRingDataType.historyHeart, payload: cat(LuckRingBytes.le16(1), [1], LuckRingBytes.le32(1_700_000_000), [70]), + seq: 7, devType: 1) + _ = driver.ingest(packets(frame)[0], from: notify) + + XCTAssertEqual(writer.sent.count, 1, "a completed SEND_NO_ACK frame is ACKed") + let ack = [UInt8](writer.sent[0]) + XCTAssertEqual(ack[4], 4, "ACK cmdType") + XCTAssertEqual(ack[5], LuckRingDataType.historyHeart, "ACK echoes the frame's dataType") + XCTAssertEqual(ack[3], 7, "ACK echoes the frame's seq") + } + + func testDataType144IsNeverAcked() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + + let frame = LuckRingFrame(cmdType: .send, dataType: LuckRingDataType.noAckData, payload: [1], seq: 0, devType: 1) _ = driver.ingest(packets(frame)[0], from: notify) - XCTAssertTrue(writer.sent.isEmpty, "a SEND_NO_ACK expects no reply") + XCTAssertTrue(writer.sent.isEmpty, "the vendor tags dataType 144 CMD_APP_NO_ACK_TYPE") + } + + /// Clearing the ring is a manual, confirmed action in the vendor app (`MyRingActivity` → "Clear + /// data?"), never part of a sync — so nothing we ever write may carry `cleanData` (207). + func testHistoryPassNeverSendsCleanData() { + let writer = FakeWriter() + let driver = LuckRingDriver(writer: writer) + let engine = driver.makeSyncEngine() + engine.runStartup() + engine.syncHistory() + XCTAssertFalse(writer.sent.isEmpty) + XCTAssertFalse(writer.sent.contains { $0.count > 5 && $0[0] == 0 && $0[5] == 207 }, + "a sync must never send the destructive cleanData opcode") } // MARK: Reassembly diff --git a/PulseLoopTests/RWfitDecoderTests.swift b/PulseLoopTests/RWfitDecoderTests.swift index d288692b..e5edaf6b 100644 --- a/PulseLoopTests/RWfitDecoderTests.swift +++ b/PulseLoopTests/RWfitDecoderTests.swift @@ -353,6 +353,22 @@ final class RWfitDecoderTests: XCTestCase { } } + /// `x5/b.java:3514`: only the first `051A` page carries the daily total; later pages are bare + /// hourly records and must not have their first record mistaken for a header. + func testTodayStepsContinuationPageIsBareHourlyRecords() throws { + let hour = cat([5, 0x1a, 0x10], jieliEpoch(3600), [1, 0, 0, 100], + RWfitBytes.packU32BE(100), RWfitBytes.packU32BE(500)) + let events = try decoder.validatedJieliHistory(key: 0x1a, payload: hour, continuation: true) + XCTAssertEqual(events.count, 1) + guard case let .activityBucket(timestamp, steps, meters)? = events.first else { + return XCTFail("expected hourly bucket, never a cumulative total") + } + XCTAssertEqual(timestamp, utc.addingTimeInterval(3600)) + XCTAssertEqual(steps, 100) + XCTAssertEqual(meters, 50) + XCTAssertThrowsError(try decoder.validatedJieliHistory(key: 0x1a, payload: hour + [0], continuation: true)) + } + 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) diff --git a/PulseLoopTests/RWfitHistoryPersistenceTests.swift b/PulseLoopTests/RWfitHistoryPersistenceTests.swift index c9b7acb5..2b78b75f 100644 --- a/PulseLoopTests/RWfitHistoryPersistenceTests.swift +++ b/PulseLoopTests/RWfitHistoryPersistenceTests.swift @@ -78,6 +78,24 @@ final class RWfitHistoryPersistenceTests: XCTestCase { XCTAssertEqual(days.first?.steps, 3000) } + /// RWfit saves each ring page in its own transaction; a day split across pages must sum once, + /// even when a page whose delete reply was lost is re-sent and saved again. + func testActivityDaySplitAcrossPageSavesSumsOnce() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + let start = Calendar.current.startOfDay(for: Date()).addingTimeInterval(-24 * 3600) + let first: [RingDecodedEvent] = [.activityBucket(timestamp: start, steps: 1000, distanceMeters: 700)] + let second: [RingDecodedEvent] = [.activityBucket(timestamp: start.addingTimeInterval(3600), steps: 2000, distanceMeters: 1400)] + try subscriber.saveRWfitHistory(first) + try subscriber.saveRWfitHistory(second) + try subscriber.saveRWfitHistory(second) + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 2) + let days = try context.fetch(FetchDescriptor()) + XCTAssertEqual(days.count, 1) + XCTAssertEqual(days.first?.steps, 3000) + XCTAssertEqual(days.first?.distanceMeters, 2100) + } + func testActivityBucketsPreserveTodaysLargerCumulativeReading() throws { let context = try TestSupport.makeContext() let subscriber = EventPersistenceSubscriber(context: context) diff --git a/PulseLoopTests/RWfitHistorySyncTests.swift b/PulseLoopTests/RWfitHistorySyncTests.swift index fb5e42c4..9b8fde91 100644 --- a/PulseLoopTests/RWfitHistorySyncTests.swift +++ b/PulseLoopTests/RWfitHistorySyncTests.swift @@ -36,7 +36,7 @@ final class RWfitHistorySyncTests: XCTestCase { var reads = 0 var order: [String] = [] sync.persist = { events in - XCTAssertEqual(events.count, 2) + XCTAssertEqual(events.count, 1) order.append("save") } writer.onWrite = { payload in @@ -51,7 +51,93 @@ final class RWfitHistorySyncTests: XCTestCase { } let outcome = await sync.run(types: [.heartRate]) XCTAssertEqual(outcome, .success(records: 2)) - XCTAssertEqual(order, ["get", "get", "get", "save", "delete"]) + // Vendor order (`blesdk/service/p.java:62-79`): each page with records is consumed before the + // next request; the empty page ends the stream unconsumed. Each save precedes its delete. + XCTAssertEqual(order, ["get", "save", "delete", "get", "save", "delete", "get"]) + gate.cancel() + } + + func testEmptyStreamIsNeverDeleted() 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) + writer.onWrite = { self.respond($0, gate: gate) } + let outcome = await sync.run(types: [.sleep, .heartRate]) + XCTAssertEqual(outcome, .success(records: 0)) + XCTAssertEqual(writer.payloads, [[5, 5, 0x10], [5, 3, 0x10]]) + gate.cancel() + } + + func testSaveFailureOnLaterPageConsumesOnlyEarlierPage() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + var saves = 0 + var order: [String] = [] + sync.persist = { _ in + saves += 1 + if saves == 2 { throw RWfitSessionError.persistence } + 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]) + guard case let .partial(records, _) = outcome else { return XCTFail("Expected partial import, got \(outcome)") } + XCTAssertEqual(records, 1) + XCTAssertEqual(order, ["get", "save", "delete", "get"], "The unsaved page must stay on the ring") + gate.cancel() + } + + func testTodayStepsLaterPagesAreBareHourlyRecords() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var reads = 0 + var saved: [[RingDecodedEvent]] = [] + sync.persist = { saved.append($0) } + let record: [UInt8] = [0x30, 0, 0, 0, 0, 0, 0, 100] + RWfitBytes.packU32BE(100) + RWfitBytes.packU32BE(500) + writer.onWrite = { payload in + guard payload[2] == 0x10 else { return self.respond(payload, gate: gate) } + reads += 1 + switch reads { + case 1: self.respond([5, 0x1a, 0x10] + record + record, gate: gate) + case 2: self.respond([5, 0x1a, 0x10] + record, gate: gate) + default: self.respond(payload, gate: gate) + } + } + let outcome = await sync.run(types: [.todaySteps]) + XCTAssertEqual(outcome, .success(records: 3)) + XCTAssertEqual(saved.count, 2) + guard saved.count == 2, case .activityBucket = saved[1].first, saved[1].count == 1 else { + return XCTFail("A continuation page holds hourly records only, got \(saved)") + } + XCTAssertEqual(writer.payloads.filter { $0[2] == 0x30 }.count, 2) + gate.cancel() + } + + func testTodayStepsTotalWithoutHourlyRecordsIsImportedButNotConsumed() async { + let writer = Writer() + let (sync, gate) = setup(writer) + var saved: [RingDecodedEvent] = [] + sync.persist = { saved += $0 } + let header: [UInt8] = [0x30, 0, 0, 0, 0, 0, 0x03, 0xe8] + RWfitBytes.packU32BE(1250) + RWfitBytes.packU32BE(5000) + writer.onWrite = { _ in self.respond([5, 0x1a, 0x10] + header, gate: gate) } + let outcome = await sync.run(types: [.todaySteps]) + XCTAssertEqual(outcome, .success(records: 1)) + guard case .activityUpdate? = saved.first else { return XCTFail("Daily total must still be imported") } + XCTAssertEqual(writer.payloads, [[5, 0x1a, 0x10]], "Vendor ends on an empty item list without deleting") gate.cancel() } @@ -181,7 +267,9 @@ final class RWfitHistorySyncTests: XCTestCase { XCTAssertEqual(saved, 1) guard case let .partial(records, _) = outcome else { return XCTFail("Saved records must remain visible in partial outcome") } XCTAssertEqual(records, 1) + // The lost delete reply is never retried and ends the stream: no further page is requested. XCTAssertEqual(writer.payloads.filter { $0[2] == 0x30 }.count, 1) + XCTAssertEqual(reads, 1) gate.cancel() } diff --git a/PulseLoopTests/RingHistoryCommitTests.swift b/PulseLoopTests/RingHistoryCommitTests.swift new file mode 100644 index 00000000..0859ecf6 --- /dev/null +++ b/PulseLoopTests/RingHistoryCommitTests.swift @@ -0,0 +1,64 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The durable-commit barrier is the only thing that authorizes erasing history from a ring, so every +/// path that can't prove a save must answer `false`. +@MainActor +final class RingHistoryCommitTests: XCTestCase { + private final class BareWriter: RingCommandWriter { + nonisolated deinit {} + func enqueue(_ command: Data) {} + } + + private func heartRateRows(_ context: ModelContext) throws -> [PulseLoop.Measurement] { + let kindRaw = MeasurementKind.heartRate.rawValue + return try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.kindRaw == kindRaw })) + } + + /// History applied before the barrier is saved (not left to the debounce) before the waiter hears + /// `true`. + func testBarrierSavesPendingHistoryThenCommits() throws { + let context = try TestSupport.makeContext() + let subscriber = EventPersistenceSubscriber(context: context) + subscriber.persist(.historyMeasurement(kind: .heartRate, value: 61, + timestamp: Date(timeIntervalSince1970: 1_700_000_000))) + XCTAssertTrue(context.hasChanges, "precondition: the sample is only batched, not saved") + + var outcome: Bool? + let id = RingHistoryCommit.register { outcome = $0 } + subscriber.persist(.historyCommitBarrier(id)) + + XCTAssertEqual(outcome, true) + XCTAssertFalse(context.hasChanges, "the barrier must leave nothing unsaved") + XCTAssertEqual(try heartRateRows(context).count, 1) + } + + /// A waiter is answered exactly once — a late duplicate resolve (or the timeout firing after the + /// subscriber answered) must not re-run the erase. + func testWaiterIsResolvedExactlyOnce() { + var outcomes: [Bool] = [] + let id = RingHistoryCommit.register { outcomes.append($0) } + RingHistoryCommit.resolve(id, committed: true) + RingHistoryCommit.resolve(id, committed: false) + XCTAssertEqual(outcomes, [true]) + } + + /// No persistence subscriber ever reaches the barrier ⇒ the timeout answers `false`. + func testUnansweredBarrierTimesOutAsNotCommitted() { + let done = expectation(description: "timeout") + _ = RingHistoryCommit.register(timeoutNanoseconds: 10_000_000) { committed in + XCTAssertFalse(committed) + done.fulfill() + } + wait(for: [done], timeout: 2) + } + + /// A writer that can't route a barrier to persistence (tests, previews) fails closed. + func testDefaultWriterNeverAuthorizesAnErase() { + var outcome: Bool? + BareWriter().commitDeliveredHistory { outcome = $0 } + XCTAssertEqual(outcome, false) + } +} diff --git a/PulseLoopTests/YCBTEncoderTests.swift b/PulseLoopTests/YCBTEncoderTests.swift index ae3bbe32..8c5bc8a0 100644 --- a/PulseLoopTests/YCBTEncoderTests.swift +++ b/PulseLoopTests/YCBTEncoderTests.swift @@ -203,6 +203,43 @@ final class YCBTEncoderTests: XCTestCase { XCTAssertEqual(encoder.historyBlockAck(status: 0x04), [0x05, 0x80, 0x04]) } + // MARK: Health-Delete (erase after a durable sync) + + /// Every delete frame, pinned on the wire. The keys are `Constants.DATATYPE.Health_Delete*` and the + /// payload is the `{2}` `YCBTClient.deleteHealthHistoryData` sends; length 7 and CRC16 CCITT-FALSE as + /// every other YCBT frame. These bytes erase the ring's only copy, so each one is written out in full. + func testHealthDeleteFramesMatchTheVendorOpcodes() { + let expected: [(YCBTHistoryType, String)] = [ + (.sport, "054007000215f9"), + (.sleep, "0541070002a18f"), + (.heart, "05420700027d14"), + (.blood, "0543070002c962"), + (.all, "0544070002e433"), + (.spo2, "05450700025045"), + (.temperature, "054707000238a8"), + (.comprehensive, "054c07000227b6"), + (.bodyData, "054e0700024f5b"), + ] + XCTAssertEqual(expected.map(\.0), YCBTHistoryType.catalog, "every fetched type has exactly one pinned delete") + for (type, wire) in expected { + let logical = encoder.healthHistoryDelete(type) + XCTAssertEqual(logical.map { YCBTFrame.frame($0).hexString }, wire, "delete for \(type.label)") + } + } + + /// The trap next door: `0x46` is Health_DeleteTempAndHumidity (the `05 1c` log we never fetch). Plain + /// temperature (`05 1e`) is `0x47`. + func testTemperatureDeleteIsNotTheTempAndHumidityOpcode() { + XCTAssertEqual(encoder.healthHistoryDelete(.temperature), [0x05, 0x47, 0x02]) + XCTAssertFalse(YCBTHistoryType.catalog.contains { $0.deleteKey == 0x46 }) + } + + /// A type without a delete opcode can never produce one. + func testATypeWithoutADeleteKeyBuildsNoDelete() { + let custom = YCBTHistoryType(queryKey: 0x2d, ackKey: 0x2e, recordStride: nil, label: "sport mode") + XCTAssertNil(encoder.healthHistoryDelete(custom)) + } + // MARK: Live measurement func testLiveMeasurementModesUseDistinctSensors() { diff --git a/PulseLoopTests/YCBTHistoryEraseTests.swift b/PulseLoopTests/YCBTHistoryEraseTests.swift new file mode 100644 index 00000000..84d7357a --- /dev/null +++ b/PulseLoopTests/YCBTHistoryEraseTests.swift @@ -0,0 +1,433 @@ +import XCTest +import CoreBluetooth +@testable import PulseLoop + +/// The erase phase: after a walk, every type whose log was read intact is deleted off the ring with +/// `05 {02}` — exactly as SmartHealth does after its database insert — but **only** once +/// `commitDeliveredHistory` has proved the records are on disk. A delete is irreversible, so most of +/// these tests are about the cases that must *not* send one. +@MainActor +final class YCBTHistoryEraseTests: XCTestCase { + /// Records outbound commands and parks every commit request so the test decides the verdict. + private final class CommitWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var sent: [Data] = [] + var commits: [@MainActor (Bool) -> Void] = [] + func enqueue(_ command: Data) { sent.append(command) } + func commitDeliveredHistory(_ completion: @escaping @MainActor (Bool) -> Void) { + commits.append(completion) + } + + /// Logical Health-Delete commands written so far (`05 40…4E`). + var deletes: [Data] { sent.filter { $0.count == 3 && $0[0] == 0x05 && (0x40...0x4e).contains($0[1]) } } + } + + private let heartQuery = Data([0x05, 0x06]) + private let spo2Query = Data([0x05, 0x1a]) + private let allQuery = Data([0x05, 0x09]) + private let heartDelete = Data([0x05, 0x42, 0x02]) + private let spo2Delete = Data([0x05, 0x45, 0x02]) + private let allDelete = Data([0x05, 0x44, 0x02]) + + /// Two 6-byte records — valid for heart (`05 06`) and SpO₂ (`05 1a`), which share the stride. + private let sixByteBuffer: [UInt8] = [ + 0x1c, 0xf0, 0xde, 0x31, 0x00, 0x47, + 0x1a, 0xfe, 0xde, 0x31, 0x00, 0x42, + ] + /// One 20-byte `05 09` combined record. + private let allBuffer: [UInt8] = [0x1c, 0xf0, 0xde, 0x31] + [UInt8](repeating: 0, count: 16) + + private func header(packets: Int, bytes: Int) -> [UInt8] { + [0x02, 0x00, UInt8(packets & 0xff), UInt8(packets >> 8), 0, 0, + UInt8(bytes & 0xff), UInt8(bytes >> 8), 0, 0] + } + + private func terminal(packets: Int, buffer: [UInt8], crc: UInt16? = nil) -> [UInt8] { + let checksum = crc ?? YCBTFrame.crc16(buffer) + return [UInt8(packets & 0xff), UInt8(packets >> 8), + UInt8(buffer.count & 0xff), UInt8((buffer.count >> 8) & 0xff), + UInt8(checksum & 0xff), UInt8((checksum >> 8) & 0xff)] + } + + /// Header → one data frame → terminal, all accepted. Returns the terminal's events. + @discardableResult + private func complete(_ type: YCBTHistoryType, _ buffer: [UInt8], on transfer: YCBTHistoryTransfer, + crc: UInt16? = nil) -> [RingDecodedEvent] { + _ = transfer.handle(cmd: type.queryKey, payload: header(packets: 1, bytes: buffer.count)) + _ = transfer.handle(cmd: type.ackKey, payload: buffer) + return transfer.handle(cmd: YCBTHealth.terminalBlock, payload: terminal(packets: 1, buffer: buffer, crc: crc)) + } + + private func isFinished(_ events: [RingDecodedEvent]) -> Bool { + events.contains { if case .historySyncFinished = $0 { return true } else { return false } } + } + + /// The commit request is deliberately deferred a main-actor turn (see `beginErasePhase`), so wait for + /// it rather than assert synchronously. + private func waitForCommit(_ writer: CommitWriter, count: Int = 1) async throws { + for _ in 0..<500 where writer.commits.count < count { + try await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertEqual(writer.commits.count, count, "the erase phase must ask for a durable commit") + } + + private func waitFor(_ condition: () -> Bool, _ message: String) async throws { + for _ in 0..<500 { + if condition() { return } + try await Task.sleep(nanoseconds: 1_000_000) + } + XCTFail(message) + } + + /// A transfer whose bitmap has arrived and whose walk of heart + SpO₂ has both completed with data. + private func walkedTransfer(_ writer: CommitWriter, inactivitySeconds: TimeInterval = 10) -> YCBTHistoryTransfer { + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: inactivitySeconds) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .spo2]) + complete(.heart, sixByteBuffer, on: transfer) + complete(.spo2, sixByteBuffer, on: transfer) + return transfer + } + + // MARK: Happy path + + /// Deletes go out after the **last** query, only on a `true` commit, one at a time in completion order — + /// and the walk isn't finished until the last one is answered. + func testDeletesFollowTheLastQueryAndACommittedSaveOneAtATime() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .spo2]) + + complete(.heart, sixByteBuffer, on: transfer) + XCTAssertEqual(writer.sent.last, spo2Query, "the next query, not a delete, follows a completed type") + XCTAssertTrue(writer.deletes.isEmpty) + + let last = complete(.spo2, sixByteBuffer, on: transfer) + XCTAssertFalse(isFinished(last), "the walk is not finished until its erase phase is") + XCTAssertTrue(transfer.isActive) + try await waitForCommit(writer) + XCTAssertTrue(writer.deletes.isEmpty, "nothing may be deleted before the commit says the data is on disk") + + writer.commits[0](true) + XCTAssertEqual(writer.deletes, [heartDelete], "one delete at a time, in completion order") + + XCTAssertTrue(transfer.handle(cmd: 0x45, payload: [0x00]).isEmpty, "a reply on another key is not ours") + XCTAssertEqual(writer.deletes, [heartDelete]) + + XCTAssertFalse(isFinished(transfer.handle(cmd: 0x42, payload: [0x00]))) + XCTAssertEqual(writer.deletes, [heartDelete, spo2Delete]) + + XCTAssertTrue(isFinished(transfer.handle(cmd: 0x45, payload: [0x00]))) + XCTAssertFalse(transfer.isActive) + XCTAssertEqual(writer.deletes, [heartDelete, spo2Delete], "each type is deleted exactly once") + } + + // MARK: No commit, no delete + + func testAFailedCommitSendsNoDeleteAndFinishesTheWalk() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer) + var outOfBand: [RingDecodedEvent] = [] + transfer.onOutOfBandEvents = { outOfBand.append(contentsOf: $0) } + try await waitForCommit(writer) + + writer.commits[0](false) + + XCTAssertTrue(writer.deletes.isEmpty, "a failed save must leave the ring's data in place") + XCTAssertFalse(transfer.isActive) + XCTAssertTrue(isFinished(outOfBand), "the walk still finishes") + } + + /// The default writer (no persistence proof) fails closed. + func testTheDefaultWriterNeverAuthorizesADelete() async throws { + final class PlainWriter: RingCommandWriter { + nonisolated deinit {} + var sent: [Data] = [] + func enqueue(_ command: Data) { sent.append(command) } + } + let writer = PlainWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart]) + complete(.heart, sixByteBuffer, on: transfer) + + try await waitFor({ !transfer.isActive }, "the walk must finish") + XCTAssertFalse(writer.sent.contains { $0.count == 3 && (0x40...0x4e).contains($0[1]) }) + } + + // MARK: Ineligible types + + /// A no-data header and a twice-failed CRC both leave the type undeleted; only the type actually read + /// intact is erased. + func testNoDeleteForAnEmptyHeaderOrACRCFailure() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .all, .spo2]) + + _ = transfer.handle(cmd: 0x06, payload: [0x00]) // heart: "no data" + complete(.all, allBuffer, on: transfer, crc: 0xdead) // all: CRC fails… + complete(.all, allBuffer, on: transfer, crc: 0xdead) // …and fails the retry + complete(.spo2, sixByteBuffer, on: transfer) + try await waitForCommit(writer) + writer.commits[0](true) + + XCTAssertEqual(writer.deletes, [spo2Delete]) + } + + /// When nothing is eligible there is nothing to commit: the walk finishes synchronously, as before. + func testAWalkWithNothingEligibleFinishesWithoutACommit() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart]) + + XCTAssertTrue(isFinished(transfer.handle(cmd: 0x06, payload: [0x00]))) + try await Task.sleep(nanoseconds: 20_000_000) + XCTAssertTrue(writer.commits.isEmpty) + XCTAssertTrue(writer.deletes.isEmpty) + } + + /// A type the watchdog abandoned was never verified, so it is never erased. + func testNoDeleteForAWatchdogSkippedType() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .spo2]) + _ = transfer.handle(cmd: 0x06, payload: header(packets: 1, bytes: sixByteBuffer.count)) + _ = transfer.handle(cmd: 0x15, payload: sixByteBuffer) + // Heart's terminal never comes. + try await waitFor({ writer.sent.contains(self.spo2Query) }, "the watchdog never skipped heart") + + complete(.spo2, sixByteBuffer, on: transfer) + try await waitForCommit(writer) + writer.commits[0](true) + + XCTAssertEqual(writer.deletes, [spo2Delete]) + } + + /// Until this connection's `02 01` bitmap arrives, the driver filters samples on the baseline set, so + /// a saved log may be missing fields the ring does have. Nothing is erased then. + func testNoDeleteBeforeTheCapabilityBitmap() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .all]) + complete(.heart, sixByteBuffer, on: transfer) + + XCTAssertTrue(isFinished(complete(.all, allBuffer, on: transfer))) + try await Task.sleep(nanoseconds: 20_000_000) + XCTAssertTrue(writer.commits.isEmpty) + XCTAssertTrue(writer.deletes.isEmpty, "neither heart nor all may be erased without the bitmap") + } + + // MARK: Re-fetch (late bitmap) withdraws the erase + + /// A type re-queued mid-walk (the engine re-runs `all` when the bitmap unlocks one of its optional + /// fields) loses its pending delete: the copy already saved is the incomplete one. If the re-run comes + /// back empty, `all` stays on the ring. + func testARequeuedTypeIsNotDeletedUnlessItsRerunCompletes() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.all, .heart]) + + complete(.all, allBuffer, on: transfer) + transfer.append(types: [.all]) // late bitmap, heart in flight + complete(.heart, sixByteBuffer, on: transfer) + XCTAssertEqual(writer.sent.last, allQuery, "the re-run is asked for before any delete") + _ = transfer.handle(cmd: 0x09, payload: [0x00]) // re-run: no data + + try await waitForCommit(writer) + writer.commits[0](true) + XCTAssertEqual(writer.deletes, [heartDelete], "all's first read is suspect, so it is not erased") + } + + /// The same, when the re-queue lands *during* the erase phase: the remaining delete for that type is + /// withdrawn, the re-run goes out once the in-flight delete is answered, and it earns its own erase. + func testARequeueDuringTheErasePhaseDefersThatTypeToItsRerun() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .all]) + complete(.heart, sixByteBuffer, on: transfer) + complete(.all, allBuffer, on: transfer) + try await waitForCommit(writer) + writer.commits[0](true) + XCTAssertEqual(writer.deletes, [heartDelete]) + + transfer.append(types: [.all]) + XCTAssertEqual(writer.sent.last, heartDelete, "the in-flight delete is not interrupted") + + _ = transfer.handle(cmd: 0x42, payload: [0x00]) + XCTAssertEqual(writer.sent.last, allQuery, "all is re-fetched, not deleted") + XCTAssertEqual(writer.deletes, [heartDelete]) + + complete(.all, allBuffer, on: transfer) + try await waitForCommit(writer, count: 2) + writer.commits[1](true) + XCTAssertEqual(writer.deletes, [heartDelete, allDelete], "the completed re-run is erased") + XCTAssertTrue(isFinished(transfer.handle(cmd: 0x44, payload: [0x00]))) + } + + // MARK: Error replies + + /// A 1-byte error on a key that isn't the in-flight type's — e.g. a late rejection of a delete the + /// watchdog gave up on — must not advance past the type being dumped. + func testAStrayErrorReplyDoesNotSkipTheCurrentType() { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer) + transfer.start(types: [.heart, .spo2]) + _ = transfer.handle(cmd: 0x06, payload: header(packets: 1, bytes: sixByteBuffer.count)) + + XCTAssertTrue(transfer.handle(cmd: 0x42, payload: [0xfc]).isEmpty) + XCTAssertTrue(transfer.handle(cmd: 0x09, payload: [0xfb]).isEmpty) + XCTAssertEqual(writer.sent, [heartQuery], "a foreign error must not move the queue") + + _ = transfer.handle(cmd: 0x15, payload: sixByteBuffer) + let done = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: sixByteBuffer)) + XCTAssertEqual(done.filter { if case .historyMeasurement = $0 { return true } else { return false } }.count, 2, + "heart still completes into its own buffer") + XCTAssertEqual(writer.sent.last, spo2Query) + } + + /// The ring rejecting a delete is logged and the phase moves on — no retry, like the vendor. + func testARejectedDeleteMovesOnWithoutRetry() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer) + try await waitForCommit(writer) + writer.commits[0](true) + + _ = transfer.handle(cmd: 0x42, payload: [0xfc]) + + XCTAssertEqual(writer.deletes, [heartDelete, spo2Delete], "on to the next type, heart not re-sent") + } + + /// A ring that never answers a delete can't hold the walk open: the watchdog moves on, then finishes. + func testAnUnansweredDeleteTimesOutAndTheWalkFinishes() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer, inactivitySeconds: 0.05) + var outOfBand: [RingDecodedEvent] = [] + transfer.onOutOfBandEvents = { outOfBand.append(contentsOf: $0) } + try await waitForCommit(writer) + writer.commits[0](true) + + try await waitFor({ !transfer.isActive }, "the delete watchdog never ended the phase") + XCTAssertEqual(writer.deletes, [heartDelete, spo2Delete], "each delete once, no retry") + XCTAssertTrue(isFinished(outOfBand)) + } + + // MARK: Re-entrancy + disconnect + + /// `.historySyncFinished` waits for the erase phase, and so does a new pass: a query interleaved with + /// a delete could be answered out of order against either. + func testStartIsRefusedUntilTheErasePhaseEnds() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer) + let before = writer.sent.count + + transfer.start(types: [.sleep]) // while awaiting the commit + XCTAssertEqual(writer.sent.count, before) + try await waitForCommit(writer) + writer.commits[0](true) + transfer.start(types: [.sleep]) // while a delete is in flight + XCTAssertFalse(writer.sent.contains(Data([0x05, 0x04]))) + + _ = transfer.handle(cmd: 0x42, payload: [0x00]) + XCTAssertTrue(isFinished(transfer.handle(cmd: 0x45, payload: [0x00]))) + transfer.start(types: [.sleep]) + XCTAssertEqual(writer.sent.last, Data([0x05, 0x04]), "a pass is accepted once the phase is over") + } + + /// A commit verdict that lands after a disconnect — even once a reconnect has started a fresh walk — + /// authorizes nothing. + func testACommitResolvedAfterDisconnectDeletesNothing() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer) + try await waitForCommit(writer) + + transfer.cancel() // disconnect + transfer.noteCapabilityBitmapReceived() // reconnect… + transfer.start(types: [.heart]) // …and a new walk begins + writer.commits[0](true) // the old link's verdict arrives late + + XCTAssertTrue(writer.deletes.isEmpty) + XCTAssertEqual(writer.sent.last, heartQuery, "the new walk is undisturbed") + } + + /// A disconnect mid-phase stops it dead: no further deletes, from a reply or from the watchdog. + func testDisconnectMidErasePhaseSendsNothingMore() async throws { + let writer = CommitWriter() + let transfer = walkedTransfer(writer, inactivitySeconds: 0.05) + try await waitForCommit(writer) + writer.commits[0](true) + XCTAssertEqual(writer.deletes, [heartDelete]) + + transfer.cancel() + try await Task.sleep(nanoseconds: 200_000_000) // well past the delete watchdog + _ = transfer.handle(cmd: 0x42, payload: [0x00]) // a late reply from the old link + + XCTAssertEqual(writer.deletes, [heartDelete], "nothing more may be deleted after a disconnect") + XCTAssertFalse(transfer.isActive) + } + + // MARK: Driver wiring + + private let stream = CBUUID(string: YCBTUUIDs.stream) + private let command = CBUUID(string: YCBTUUIDs.command) + + /// Drive the vitals pass (heart → all → SpO₂) through the driver: heart has data, the rest are empty. + private func driveVitalsPass(_ driver: YCBTDriver) { + let engine = driver.makeSyncEngine() + engine.syncVitalsHistory() + _ = driver.ingest(YCBTFrame.frame([0x05, 0x06] + header(packets: 1, bytes: sixByteBuffer.count)), from: stream) + _ = driver.ingest(YCBTFrame.frame([0x05, 0x15] + sixByteBuffer), from: stream) + _ = driver.ingest(YCBTFrame.frame([0x05, 0x80] + terminal(packets: 1, buffer: sixByteBuffer)), from: stream) + _ = driver.ingest(YCBTFrame.frame([0x05, 0x09, 0x00]), from: stream) + _ = driver.ingest(YCBTFrame.frame([0x05, 0x1a, 0x00]), from: stream) + } + + /// The driver tells the transfer when this connection's `02 01` reply lands — that is what enables + /// erasing at all on a real link. + func testTheDriversBitmapEnablesTheErasePhase() async throws { + let writer = CommitWriter() + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) + driver.connectionDidStart() + _ = driver.ingest(YCBTFrame.frame([0x02, 0x01] + [UInt8](repeating: 0, count: 27)), from: command) + + driveVitalsPass(driver) + try await waitForCommit(writer) + writer.commits[0](true) + + XCTAssertEqual(writer.deletes, [heartDelete], "only heart had records") + } + + func testWithoutTheBitmapTheDriverNeverErases() async throws { + let writer = CommitWriter() + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) + driver.connectionDidStart() + + driveVitalsPass(driver) + try await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(writer.commits.isEmpty) + XCTAssertTrue(writer.deletes.isEmpty) + } + + /// The bitmap belongs to one link: after a reconnect, a walk that finishes before the new `02 01` + /// reply erases nothing. + func testTheBitmapDoesNotSurviveAReconnect() async throws { + let writer = CommitWriter() + let driver = YCBTDriver(writer: writer, profile: .permissiveTestProfile) + driver.connectionDidStart() + _ = driver.ingest(YCBTFrame.frame([0x02, 0x01] + [UInt8](repeating: 0, count: 27)), from: command) + driver.connectionDidEnd() + driver.connectionDidStart() + + driveVitalsPass(driver) + try await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(writer.commits.isEmpty) + XCTAssertTrue(writer.deletes.isEmpty) + } +} diff --git a/docs/hardware/luckring.md b/docs/hardware/luckring.md index 7d8e3ac4..d55b9242 100644 --- a/docs/hardware/luckring.md +++ b/docs/hardware/luckring.md @@ -119,10 +119,13 @@ art and a fallback name. | FW update via app | ❌ | Out of scope | !!! info "PulseLoop never wipes the ring" - The vendor SDK has a destructive clear-history opcode; PulseLoop never sends - it. The ring replays its whole log on every sync, and PulseLoop dedupes - app-side (upserts by kind + timestamp / bucket / night), so re-syncs never - duplicate. + The vendor SDK has a destructive clear-history opcode (`207`), but the Luck + Ring app only sends it from a manual, confirmed "Clear data" button — never + after a sync — so PulseLoop never sends it either. Like the vendor app, + PulseLoop ACKs every completed frame the ring sends (including the + SEND_NO_ACK frames history arrives in), which is how the ring learns its + records were delivered. Any replay is still deduped app-side (upserts by + kind + timestamp / bucket / night), so re-syncs never duplicate. ### What the TK18 CANNOT do diff --git a/docs/hardware/r10m.md b/docs/hardware/r10m.md index c3a52f8b..f7e3c507 100644 --- a/docs/hardware/r10m.md +++ b/docs/hardware/r10m.md @@ -143,9 +143,11 @@ pressure. They stay listed because "R10M" is a model, not a firmware, and the ga *previous* clock and history rows upsert — one misdecoded record would re-persist on every sync. - **No background sync while disconnected.** The ring keeps logging on its own schedule; PulseLoop reads it on connect, every 30 minutes thereafter, and after a workout. -- **The ring's log is never deleted.** As with every YCBT ring, PulseLoop does not issue the - Health-Delete opcodes, so the ring replays its whole log on each sync and deduplication is app-side. - See [how PulseLoop diverges from SmartHealth](tk5.md#how-pulseloop-diverges-from-smarthealth). +- **The ring's log is erased after each sync.** As with every YCBT ring, PulseLoop sends the + Health-Delete opcodes for each type it read intact, but only after that data is durably saved and + only after the ring's capability bitmap has arrived. Sharing the ring with SmartHealth means each + app only sees what the other hasn't already imported. See + [how PulseLoop diverges from SmartHealth](tk5.md#how-pulseloop-diverges-from-smarthealth). --- diff --git a/docs/hardware/rwfit.md b/docs/hardware/rwfit.md index a5a989b4..1d496e9e 100644 --- a/docs/hardware/rwfit.md +++ b/docs/hardware/rwfit.md @@ -57,13 +57,16 @@ 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. +**Sync consumes transferred history from the ring.** Like the vendor app, each +modern page that carries records is deleted (`05xx30`) before the next page is +requested; the empty page that ends a stream is never deleted. Unlike the vendor, +which deletes first, PulseLoop validates and durably imports each non-sleep page +before deleting it. A sleep session can span pages, so raw sleep pages are first +atomically saved to a bounded, per-device journal in Application Support; complete +sessions are assembled and imported before removing that journal. It survives +interruption and is cleared with the user's app data. A lost delete reply is never +retried and ends that stream for the sync. 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 diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index f1cf4993..5c50b735 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -143,15 +143,34 @@ exchange at all**. PulseLoop implements none of RCSP, and doesn't need to. ## How PulseLoop diverges from SmartHealth -**We never delete records off the ring.** SmartHealth issues the Health-Delete opcodes (`05 40…4E`) -after a sync, so its next sync is small. PulseLoop doesn't: a delete that races a failed decode -destroys data that exists nowhere else, and it makes the vendor app and PulseLoop mutually -destructive on the same ring. - -The consequence is that **the ring replays its entire log on every sync**, so deduplication is -app-side: every history sample upserts on `(kind, timestamp)`, activity buckets upsert by start -epoch, and the cumulative step counter is a per-day `max` ratchet. All three are idempotent under -replay, so a double-sync produces no duplicates. The cost is a longer sync as the ring's log fills. +**We erase the ring's log only after it is safely on disk.** Like SmartHealth, PulseLoop deletes each +history type off the ring once it has been imported, so the next sync is small. The vendor deletes as +soon as its database insert succeeds; PulseLoop is stricter, because a delete destroys data that +exists nowhere else: + +- A type is erased only if its transfer ended in a CRC-verified terminal block and carried at least + one record. A "no data" reply, a rejection, a CRC failure or a stalled transfer leaves it alone. +- Deletes (`05 40` sport, `05 41` sleep, `05 42` heart, `05 43` blood pressure, `05 44` combined, + `05 45` SpO₂, `05 47` temperature, `05 4C` metabolic, `05 4E` body data, each with payload `02`) + go out only after the **last** query of the pass, and only once a durable save of everything + received has succeeded. A failed or timed-out save sends none. +- One delete at a time: PulseLoop waits for the ring's reply (or an error, or a 10 s timeout) and + moves on. There is no retry; a type that wasn't erased is simply read again next sync. +- Nothing is erased until the ring's capability bitmap (`02 01`) has arrived on this connection, and + a type re-fetched because that bitmap unlocked more of its fields is erased only after the re-fetch. +- The pass doesn't count as finished until its deletes are done, and a disconnect abandons them. + +Records PulseLoop deliberately doesn't import are erased with the rest of their type: samples +outside the ~8-day history window (see below; they are treated as mis-stamped) and fields for +sensors the ring doesn't claim. + +Deduplication is still app-side, because a sync whose erase was skipped replays the whole log: every +history sample upserts on `(kind, timestamp)`, activity buckets upsert by start epoch (replacing the +stored bucket, as SmartHealth's `INSERT OR REPLACE` does), and the cumulative step counter is a +per-day `max` ratchet. + +**Using SmartHealth and PulseLoop on the same ring:** each app erases what it has imported, so +whichever syncs first gets the records and the other never sees them. ## Known limitations - **~8-day history horizon.** `RingEventBridge` drops any history sample, sleep session or activity From 1cd4782146daa7d0e161167385da824c3f82b0ae Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 8 Nov 2026 16:39:45 -0500 Subject: [PATCH 2/5] Stop silent CRP timing streams from holding history sync to the watchdog --- PulseLoop/RingProtocol/CRPSyncEngine.swift | 69 ++++++++++++++++++---- PulseLoopTests/CRPSyncEngineTests.swift | 66 +++++++++++++++++++++ 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/PulseLoop/RingProtocol/CRPSyncEngine.swift b/PulseLoop/RingProtocol/CRPSyncEngine.swift index 90949ab8..dca22c1d 100644 --- a/PulseLoop/RingProtocol/CRPSyncEngine.swift +++ b/PulseLoop/RingProtocol/CRPSyncEngine.swift @@ -43,6 +43,29 @@ final class CRPSyncEngine: RingSyncEngine { private var historySyncInFlight = false private var historyCompletionTask: Task? + /// How long a timing vital gets to return its *first* frame before it stops holding the pass + /// open. A vital that never answers (stress `2/47` on zaggash's R11 and on the issue-#58 R100, + /// which ignore its state read-back `2/45` too) used to keep every pass waiting out the whole + /// `historyWatchdogNanos`. A vital that does answer within the window stays pending until its + /// terminal frame, bounded only by that watchdog, so this drops no data the ring offers. + /// + /// Why a timeout and not the connect read-backs (`2/6` HR, `2/7` HRV, `2/8` SpO2, `2/45` stress, + /// `2/21` temperature): the vendor ring SDK (`com.crrepa.ble`) gates nothing on them. Its history + /// calls are fire-and-forget writes (`k/f` paces on write completion, not on a reply), each + /// parser walks its own frames (`h/c.d`: `index + 1` until the terminal one), and there is no + /// "pass complete" at all; the Android port likewise only advances the cursor. The read-backs + /// can't stand in for the timeout either: the ring that leaves `2/47` silent leaves `2/45` + /// silent as well, a monitor that is on can still go unanswered, and they describe the state + /// *before* `applyTimingSettings` re-enables everything. + /// + /// Five seconds covers the connect handshake queued ahead of the history queries on the single + /// write channel plus a stored-frame reply; the ring answers the frame-0 queries in send order, + /// ahead of any follow-up frame. + private let timingFirstFrameWindowNanos: UInt64 + /// Timing vitals that have not returned any frame yet in the current pass. + private var awaitingFirstTimingFrame: Set = [] + private var timingFirstFrameTask: Task? + /// User-chosen all-day measurement config. Applied in the connect handshake and updatable /// live via `applyMeasurementSettings`. `nil` ⇒ the user has never saved one, and a fresh R11 /// ships with every all-day monitor OFF, so it records nothing to sync. We therefore fall back to @@ -75,12 +98,14 @@ final class CRPSyncEngine: RingSyncEngine { writer: RingCommandWriter?, // Below RingSyncCoordinator's 20-second stall deadline so this engine's explicit `done` wins. historyWatchdogNanos: UInt64 = 15_000_000_000, + timingFirstFrameWindowNanos: UInt64 = 5_000_000_000, eventSink: @escaping @MainActor (PulseEvent) -> Void = { event in Task { PulseEventBus.shared.publish(event) } } ) { self.writer = writer self.historyWatchdogNanos = historyWatchdogNanos + self.timingFirstFrameWindowNanos = timingFirstFrameWindowNanos self.eventSink = eventSink } @@ -184,9 +209,14 @@ final class CRPSyncEngine: RingSyncEngine { guard !historySyncInFlight else { return } historySyncInFlight = true pendingTimingHistoryCommands = Self.timingHistoryCommands + awaitingFirstTimingFrame = Self.timingHistoryCommands eventSink(.syncProgress(stage: "Syncing ring history…")) queryAllHistory(includeSleepBackfill: includeSleepBackfill) - armHistoryCompletionWatchdog() + // Unsupported/disabled streams are allowed to stay silent. A vital silent past the + // first-frame window stops holding the pass; the watchdog bounds one that answers and stalls. + // Either way the pass completes with whatever the ring did return instead of wedging sync. + timingFirstFrameTask = deadline(after: timingFirstFrameWindowNanos) { $0.dropSilentTimingStreams() } + historyCompletionTask = deadline(after: historyWatchdogNanos) { $0.finishHistorySync() } } /// CRP history is a standalone query set, so it participates in the coordinator's periodic and @@ -195,33 +225,44 @@ final class CRPSyncEngine: RingSyncEngine { startHistorySync(includeSleepBackfill: false) } - private func armHistoryCompletionWatchdog() { - historyCompletionTask?.cancel() - let delay = historyWatchdogNanos - historyCompletionTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: delay) + private func deadline( + after nanos: UInt64, + _ action: @escaping @MainActor (CRPSyncEngine) -> Void + ) -> Task { + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: nanos) guard let self, !Task.isCancelled else { return } - // Unsupported/disabled streams are allowed to stay silent. Reaching the bounded deadline - // completes the poll with whatever the ring did return instead of wedging sync forever. - self.finishHistorySync() + action(self) + } + } + + /// Stop waiting on every timing vital that hasn't returned a single frame by the end of the + /// first-frame window (see `timingFirstFrameWindowNanos`). A late reply from a dropped vital is + /// still decoded and persisted by the bridge, and `handle` still walks it while the pass is open. + private func dropSilentTimingStreams() { + guard historySyncInFlight else { return } + pendingTimingHistoryCommands.subtract(awaitingFirstTimingFrame) + awaitingFirstTimingFrame.removeAll() + if pendingTimingHistoryCommands.isEmpty { + finishHistorySync() } } private func finishHistorySync() { guard historySyncInFlight else { return } - historySyncInFlight = false - pendingTimingHistoryCommands.removeAll() + cancelHistorySync() requestedTimingFrames.removeAll() - historyCompletionTask?.cancel() - historyCompletionTask = nil eventSink(.syncProgress(stage: "done")) } private func cancelHistorySync() { historySyncInFlight = false pendingTimingHistoryCommands.removeAll() + awaitingFirstTimingFrame.removeAll() historyCompletionTask?.cancel() historyCompletionTask = nil + timingFirstFrameTask?.cancel() + timingFirstFrameTask = nil } /// Whether older nights have already been backfilled on this GATT link. @@ -283,6 +324,8 @@ final class CRPSyncEngine: RingSyncEngine { guard historySyncInFlight, case let .timingHistoryFrame(cmd, day, frameIndex) = event, Self.timingHistoryCommands.contains(cmd) else { return } + // Any frame proves the vital answers, so the first-frame window no longer applies to it. + awaitingFirstTimingFrame.remove(cmd) if day == CRPCommands.historyDayToday, frameIndex >= terminalFrameIndex(cmd: cmd) { pendingTimingHistoryCommands.remove(cmd) diff --git a/PulseLoopTests/CRPSyncEngineTests.swift b/PulseLoopTests/CRPSyncEngineTests.swift index 2bd8aa43..2d034cda 100644 --- a/PulseLoopTests/CRPSyncEngineTests.swift +++ b/PulseLoopTests/CRPSyncEngineTests.swift @@ -349,12 +349,78 @@ final class CRPSyncEngineTests: XCTestCase { XCTAssertEqual(stages.filter { $0 == "done" }.count, 1) } + /// Stress `2/47` goes unanswered on zaggash's R11 and the issue-#58 R100. A vital that returns no + /// frame inside the first-frame window must stop holding the pass, so `done` lands at the window + /// rather than at the whole-pass watchdog (set far out here so only the window can end the pass). + func testSilentStressStreamCompletesAtTheFirstFrameWindowNotTheWatchdog() async { + let w = FakeWriter() + var stages: [String] = [] + let engine = CRPSyncEngine( + writer: w, + historyWatchdogNanos: 60_000_000_000, + timingFirstFrameWindowNanos: 50_000_000, + eventSink: { event in + if case let .syncProgress(stage) = event { stages.append(stage) } + } + ) + engine.syncHistory() + + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHR, day: 0, frameIndex: 0)) + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHR, day: 0, frameIndex: 1)) + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingSpO2, day: 0, frameIndex: 1)) + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHRV, day: 0, frameIndex: 3)) + XCTAssertEqual(stages, ["Syncing ring history…"], "stress is still inside its window") + + try? await Task.sleep(nanoseconds: 200_000_000) + XCTAssertEqual(stages, ["Syncing ring history…", "done"]) + + // A stress reply after completion neither restarts the walk nor publishes a second `done`. + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingStress, day: 0, frameIndex: 0)) + XCTAssertTrue(w.sent.isEmpty) + XCTAssertEqual(stages.filter { $0 == "done" }.count, 1) + } + + /// The window only drops a vital that never answered. Stress answering after the other vitals + /// finished, but inside its window, must still be walked to its terminal frame, and must keep the + /// pass open past the window while that walk is in progress. + func testLateStressInsideTheWindowIsWalkedToItsLastFrame() async { + let w = FakeWriter() + var stages: [String] = [] + let engine = CRPSyncEngine( + writer: w, + historyWatchdogNanos: 60_000_000_000, + timingFirstFrameWindowNanos: 500_000_000, + eventSink: { event in + if case let .syncProgress(stage) = event { stages.append(stage) } + } + ) + engine.syncHistory() + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHR, day: 0, frameIndex: 1)) + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingSpO2, day: 0, frameIndex: 1)) + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingHRV, day: 0, frameIndex: 3)) + + try? await Task.sleep(nanoseconds: 50_000_000) + w.sent.removeAll() + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingStress, day: 0, frameIndex: 0)) + XCTAssertEqual(w.opcodes, [[2, CRPCommands.cmdQueryTimingStress]], "stress frame 1 is requested") + XCTAssertEqual(w.payloadByte(0, 7), 1) + + try? await Task.sleep(nanoseconds: 700_000_000) + XCTAssertEqual(stages, ["Syncing ring history…"], "an answering vital is not dropped at the window") + + engine.handle(.timingHistoryFrame(cmd: CRPCommands.cmdQueryTimingStress, day: 0, frameIndex: 1)) + XCTAssertEqual(stages, ["Syncing ring history…", "done"]) + } + func testDisconnectCancelsCompletionAndReconnectResendsPerLinkQueries() async { let w = FakeWriter() var stages: [String] = [] let engine = CRPSyncEngine( writer: w, historyWatchdogNanos: 20_000_000, + // Both deadlines expire inside the sleep below, so neither may survive the disconnect. + timingFirstFrameWindowNanos: 10_000_000, eventSink: { event in if case let .syncProgress(stage) = event { stages.append(stage) } } From 9d4962ec038d8a17c44ddf71c4beccc2d42cfe69 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 8 Nov 2026 16:41:59 -0500 Subject: [PATCH 3/5] End Colmi QRing HR, stress and HRV days on the header page count instead of the watchdog --- PulseLoop/RingProtocol/ColmiDecoder.swift | 4 + PulseLoop/RingProtocol/ColmiEncoder.swift | 42 +++- PulseLoop/RingProtocol/ColmiProtocol.swift | 3 + PulseLoop/RingProtocol/ColmiSyncEngine.swift | 58 +++++- PulseLoopTests/ColmiSyncEngineTests.swift | 205 +++++++++++++++++++ docs/hardware/colmi.md | 27 ++- 6 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 PulseLoopTests/ColmiSyncEngineTests.swift diff --git a/PulseLoop/RingProtocol/ColmiDecoder.swift b/PulseLoop/RingProtocol/ColmiDecoder.swift index cf0f8c41..7750ee49 100644 --- a/PulseLoop/RingProtocol/ColmiDecoder.swift +++ b/PulseLoop/RingProtocol/ColmiDecoder.swift @@ -248,6 +248,10 @@ struct ColmiDecoder { return decodeSpo2(v, calendar: calendar) case ColmiCommandID.bigDataSleep: return decodeSleep(v, calendar: calendar) + case ColmiCommandID.bigDataSleepNap: + // QRing's `parseDaySleepLunch` reads the same `[daysAgo][len][start][end][pairs…]` record + // as the main-sleep action (PulseLoopAndroid `ColmiDecoder.kt:337-341`). + return decodeSleep(v, calendar: calendar) case ColmiCommandID.bigDataTemperature: return decodeTemperature(v, calendar: calendar) default: diff --git a/PulseLoop/RingProtocol/ColmiEncoder.swift b/PulseLoop/RingProtocol/ColmiEncoder.swift index 778f1a93..d8d625c1 100644 --- a/PulseLoop/RingProtocol/ColmiEncoder.swift +++ b/PulseLoop/RingProtocol/ColmiEncoder.swift @@ -137,13 +137,49 @@ struct ColmiEncoder { // MARK: Big-data requests (raw, sent on the command characteristic) + /// `bc 2a 01 00 ff 00 ff` — all-history SpO2 (payload `ff`). func bigDataSpo2() -> Data { - Data([ColmiCommandID.bigDataV2, ColmiCommandID.bigDataSpo2, 0x01, 0x00, 0xff, 0x00, 0xff]) + Self.bigDataRequest(ColmiCommandID.bigDataSpo2, payload: [0xff]) } + + /// `bc 27 02 00 81 80 ff 01` — QRing's New_Sleep_Protocol request, `LargeDataHandler.addHeader(39, + /// [0xff, 0x01])`: `ff` = all history, `01` = protocol version (PulseLoopAndroid + /// `ColmiEncoder.kt:261-268`, ported from the QRing decompile; its `ColmiSleepSyncTest.kt:47-58` + /// pins the same bytes). The 1-byte `bc 27 01 00 ff 00 ff` we used to send (GadgetBridge + /// `YawellRingDeviceSupport.fetchHistorySleep`) is silently ignored by newer firmware, so sleep + /// never synced there. The reply is the same `0x27` day-record frame `decodeSleep` reads + /// (`testRing2ProCapturedNightDecodes` is a reply to this request), plus a `0x3e` nap frame on + /// rings that file short sleeps separately. func bigDataSleep() -> Data { - Data([ColmiCommandID.bigDataV2, ColmiCommandID.bigDataSleep, 0x01, 0x00, 0xff, 0x00, 0xff]) + Self.bigDataRequest(ColmiCommandID.bigDataSleep, payload: [0xff, 0x01]) } + + /// `bc 25 01 00 3e 81 02` — legacy temperature series (payload = day count 2). func bigDataTemperature() -> Data { - Data([ColmiCommandID.bigDataV2, ColmiCommandID.bigDataTemperature, 0x01, 0x00, 0x3e, 0x81, 0x02]) + Self.bigDataRequest(ColmiCommandID.bigDataTemperature, payload: [0x02]) + } + + /// `bc ` — QRing's `LargeDataHandler.addHeader`, where + /// the CRC is CRC16/MODBUS over the payload (PulseLoopAndroid `ColmiEncoder.kt:237-256`). + static func bigDataRequest(_ type: UInt8, payload: [UInt8]) -> Data { + let crc = crc16Modbus(payload) + let len = payload.count + return Data([ + ColmiCommandID.bigDataV2, type, + UInt8(len & 0xff), UInt8((len >> 8) & 0xff), + UInt8(crc & 0xff), UInt8((crc >> 8) & 0xff), + ] + payload) + } + + /// CRC16/MODBUS: init `0xffff`, reflected poly `0xa001`, no final xor. + static func crc16Modbus(_ bytes: [UInt8]) -> UInt16 { + var crc: UInt16 = 0xffff + for byte in bytes { + crc ^= UInt16(byte) + for _ in 0..<8 { + crc = crc & 1 != 0 ? (crc >> 1) ^ 0xa001 : crc >> 1 + } + } + return crc } } diff --git a/PulseLoop/RingProtocol/ColmiProtocol.swift b/PulseLoop/RingProtocol/ColmiProtocol.swift index 78cb431a..7a9d9123 100644 --- a/PulseLoop/RingProtocol/ColmiProtocol.swift +++ b/PulseLoop/RingProtocol/ColmiProtocol.swift @@ -64,6 +64,9 @@ enum ColmiCommandID { // big-data types static let bigDataTemperature: UInt8 = 0x25 static let bigDataSleep: UInt8 = 0x27 + /// Nap ("lunch") sleep, QRing big-data action 62. Rings with nap support file sessions of + /// 180 minutes or less here, unsolicited alongside the `0x27` reply; same day-record layout. + static let bigDataSleepNap: UInt8 = 0x3e static let bigDataSpo2: UInt8 = 0x2a // `0x69`/`0x6A` reading types for on-demand `SpotVital`s (QRing `StartHeartRateReq.getSimpleReq`). diff --git a/PulseLoop/RingProtocol/ColmiSyncEngine.swift b/PulseLoop/RingProtocol/ColmiSyncEngine.swift index 92d476a6..ce812760 100644 --- a/PulseLoop/RingProtocol/ColmiSyncEngine.swift +++ b/PulseLoop/RingProtocol/ColmiSyncEngine.swift @@ -54,6 +54,9 @@ final class ColmiSyncEngine: RingSyncEngine { private var daysAgo = 0 /// The midnight of the day currently being synced (for HR/stress/HRV sample timestamps). private var syncDay = Calendar.current.startOfDay(for: Date()) + /// How many packets the current HR/stress/HRV day has, from its packet 0 (`v[2]`); nil until that + /// header arrives. Reset on every day request. Drives `isTerminalPacket`. + private var expectedPackets: Int? /// A targeted post-workout run: today's HR log → SpO2 big-data → done, skipping the other five /// stages. Set by `startVitalsBackfill()`, cleared whenever a run finishes or a full sync starts. private var isVitalsBackfill = false @@ -219,22 +222,35 @@ final class ColmiSyncEngine: RingSyncEngine { private func requestActivity() { syncDay = dayStart(daysAgo) + expectedPackets = nil writer?.enqueue(Data(encoder.syncActivity(daysAgo: daysAgo))) } private func requestHeartRate() { syncDay = dayStart(daysAgo) - let unix = Int(syncDay.timeIntervalSince1970) + expectedPackets = nil + let unix = Self.heartRateRequestEpoch(dayStart: syncDay, timeZone: calendar.timeZone) writer?.enqueue(Data(encoder.syncHeartRate(fromUnix: unix))) } + /// The ring indexes its daily HR logs by local wall-clock time read as UTC, so the request carries + /// local midnight *plus* the zone offset — the calendar date at 00:00 UTC. GadgetBridge + /// (`YawellRingDeviceSupport.java:916`, `+ ZONE_OFFSET + DST_OFFSET`) and PulseLoopAndroid + /// (`ColmiSyncEngine.kt:496-500`, `atStartOfDay(ZoneOffset.UTC)`, from QRing) agree. The true + /// local-midnight epoch we sent before falls inside the ring's *previous* day east of UTC. + static func heartRateRequestEpoch(dayStart: Date, timeZone: TimeZone) -> Int { + Int(dayStart.timeIntervalSince1970) + timeZone.secondsFromGMT(for: dayStart) + } + private func requestStress() { syncDay = calendar.startOfDay(for: Date()) + expectedPackets = nil writer?.enqueue(Data(encoder.syncStress())) } private func requestHRV() { syncDay = dayStart(daysAgo) + expectedPackets = nil writer?.enqueue(Data(encoder.syncHRV(daysAgo: daysAgo))) } @@ -251,6 +267,7 @@ final class ColmiSyncEngine: RingSyncEngine { /// Decode a paged history frame for the current sync-day and advance the machine. Returns the /// decoded sample events for the bus. func handleHistoryFrame(_ data: Data) -> [RingDecodedEvent] { + captureExpectedPackets(data) let events = decoder.decodeHistory(data, day: syncDay, calendar: calendar) advanceAfterPagedFrame(data) armWatchdog() // progress made — reset the stall timer @@ -284,13 +301,30 @@ final class ColmiSyncEngine: RingSyncEngine { lastRealtimeFrameAt = Date() } + /// HR/stress/HRV packet 0 is a header: ` 00 …`, where `size` counts the + /// day's packets *including* this header (QRing `ReadHeartRateRsp`/`PressureRsp`/`HRVRsp` per + /// PulseLoopAndroid `ColmiSyncEngine.kt:357-372`; GadgetBridge reads the same `v[2]` for HR at + /// `YawellRingDeviceSupport.java:232` and HRV at `YawellRingPacketHandler.java:541`; tahnok + /// `hr.py` too). Out-of-range sizes are ignored so a garbled header falls back to the defaults. + private func captureExpectedPackets(_ data: Data) { + guard let packet = ColmiPacket(validating: data) else { return } + let v = packet.bytes + switch v[0] { + case ColmiCommandID.syncHeartRate, ColmiCommandID.syncStress, ColmiCommandID.syncHRV: + guard v[1] == 0 else { return } + let size = Int(v[2]) + expectedPackets = (2...64).contains(size) ? size : nil + default: + return + } + } + /// Paged stages (activity/HR/stress/HRV) advance day-by-day, then to the next stage. We treat an - /// empty marker (0xff) OR the final packet as "this day done". + /// empty marker (0xff) OR the final packet as "this day done". A header packet (0) just waits for + /// data packets. private func advanceAfterPagedFrame(_ data: Data) { let packetNr = ColmiDecoder.historyPacketNumber(data) let isEmpty = packetNr == 0xff - // For paged HR/stress/HRV, byte[2] in packet 0 carries the total; we conservatively treat any - // empty/terminal as "advance". A header packet (0) just waits for data packets. let dayComplete = isEmpty || isTerminalPacket(data) guard dayComplete else { return } @@ -313,19 +347,25 @@ final class ColmiSyncEngine: RingSyncEngine { } } - /// Terminal packet heuristic for paged stages: stress/HRV finish at packet 4 (GadgetBridge); - /// activity finishes when `currentPacket == totalPackets - 1` (bytes 5/6). + /// A day's last data packet: `packetNr == size - 1`, with `size` from the day's header + /// (`captureExpectedPackets`) — QRing's rule (PulseLoopAndroid `ColmiSyncEngine.kt:565-588`), + /// GadgetBridge's `hrPacketNr == packetsTotalNr - 1` (`YawellRingDeviceSupport.java:290`) and + /// tahnok's `sub_type == self.size - 1`. HR used to have no terminal at all, so every HR day with + /// data sat out the 10 s watchdog, which then skipped the whole stage and days 1-7 were never + /// requested. Fallbacks when the header was missed: stress/HRV keep the old size 5 (48 half-hour + /// slots = 12 + 3 × 13 values); HR ends on packet 23 (a full 5-minute day is 9 + 22 × 13 ≥ 288 + /// values), else the watchdog. Activity ends when `currentPacket == totalPackets - 1` (bytes 5/6). private func isTerminalPacket(_ data: Data) -> Bool { let v = [UInt8](data) guard v.count >= 7 else { return false } + let packetNr = Int(v[1]) switch v[0] { case ColmiCommandID.syncStress, ColmiCommandID.syncHRV: - return Int(v[1]) == 4 + return packetNr == (expectedPackets ?? 5) - 1 case ColmiCommandID.syncActivity: return Int(v[5]) == Int(v[6]) - 1 case ColmiCommandID.syncHeartRate: - // packet 0 carries total in byte[2]; treat last packet as terminal. - return false // UNVERIFIED: rely on emptiness / next-day request cadence + return packetNr == (expectedPackets ?? 24) - 1 default: return false } diff --git a/PulseLoopTests/ColmiSyncEngineTests.swift b/PulseLoopTests/ColmiSyncEngineTests.swift new file mode 100644 index 00000000..f4cd43bc --- /dev/null +++ b/PulseLoopTests/ColmiSyncEngineTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import PulseLoop + +/// The Colmi history state machine's paging: each HR/stress/HRV day ends on the page count its own +/// packet 0 announces (so the next request goes out at once instead of after the 10 s watchdog), the +/// HR request epoch, and the big-data request bytes. Pure — a recording writer captures every +/// enqueued command, no hardware. Inbound frames are built with `ColmiPacket.frame` (16 bytes + +/// checksum), as the ring sends them. +@MainActor +final class ColmiSyncEngineTests: XCTestCase { + + private final class RecordingWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private(set) var frames: [Data] = [] + func enqueue(_ command: Data) { frames.append(command) } + } + + private var writer: RecordingWriter! + private var engine: ColmiSyncEngine! + + override func setUp() { + super.setUp() + writer = RecordingWriter() + engine = ColmiSyncEngine(writer: writer, decoder: ColmiDecoder()) + } + + override func tearDown() { + engine = nil + writer = nil + super.tearDown() + } + + // MARK: Helpers + + private func feed(_ content: [UInt8]) { + _ = engine.handleHistoryFrame(ColmiPacket.frame(content)) + } + + /// An "empty day" reply (`packetNr = 0xff`). + private func feedEmptyDay(_ op: UInt8) { feed([op, 0xff]) } + + /// A paged day: header announcing `size` packets (cadence byte 5), then data packets 1..<`size`. + private func feedPagedDay(_ op: UInt8, size: UInt8) { + feed([op, 0x00, size, 0x05]) + for nr in 1.. [UInt8] { + let cal = Calendar.current + let day = cal.date(byAdding: .day, value: -daysAgo, to: cal.startOfDay(for: Date()))! + let epoch = ColmiSyncEngine.heartRateRequestEpoch(dayStart: day, timeZone: cal.timeZone) + return ColmiEncoder().syncHeartRate(fromUnix: epoch) + } + + /// Start a full sync and complete all eight activity days as empty → HR day 0 requested. + private func driveToHeartRate() { + engine.syncHistory() + for _ in 0..<8 { feedEmptyDay(ColmiCommandID.syncActivity) } + } + + /// … then all eight HR days as empty → stress requested. + private func driveToStress() { + driveToHeartRate() + for _ in 0..<8 { feedEmptyDay(ColmiCommandID.syncHeartRate) } + } + + /// … then stress, SpO2 and sleep → HRV day 0 requested. + private func driveToHRV() { + driveToStress() + feedEmptyDay(ColmiCommandID.syncStress) + engine.handleBigDataComplete(type: ColmiCommandID.bigDataSpo2) + engine.handleBigDataComplete(type: ColmiCommandID.bigDataSleep) + } + + // MARK: HR paging + + func testHeartRateDayEndsAtHeaderPageCountAndRequestsNextDay() { + driveToHeartRate() + XCTAssertEqual(lastFrame, hrRequest(daysAgo: 0)) + let before = writer.frames.count + + feed([ColmiCommandID.syncHeartRate, 0x00, 0x03, 0x05]) // header: 3 packets (0, 1, 2) + feed([ColmiCommandID.syncHeartRate, 0x01, 0, 0, 0, 0, 60, 61]) + XCTAssertEqual(writer.frames.count, before, "packet 1 of 3 is not the last — keep waiting") + + feed([ColmiCommandID.syncHeartRate, 0x02, 62, 63]) + XCTAssertEqual(writer.frames.count, before + 1, "the last packet must end the day at once") + XCTAssertEqual(lastFrame, hrRequest(daysAgo: 1), "next HR day requested, not the stress stage") + } + + func testAllEightHeartRateDaysAreRequestedThenStress() { + driveToHeartRate() + for daysAgo in 0..<8 { + XCTAssertEqual(lastFrame, hrRequest(daysAgo: daysAgo)) + feedPagedDay(ColmiCommandID.syncHeartRate, size: 4) + } + XCTAssertEqual(lastFrame, ColmiEncoder().syncStress()) + } + + func testHeartRateWithoutHeaderEndsOnPacket23() { + driveToHeartRate() + let before = writer.frames.count + feed([ColmiCommandID.syncHeartRate, 22, 60]) + XCTAssertEqual(writer.frames.count, before) + feed([ColmiCommandID.syncHeartRate, 23, 60]) + XCTAssertEqual(lastFrame, hrRequest(daysAgo: 1)) + } + + func testOutOfRangeHeaderFallsBackToDefault() { + driveToHeartRate() + let before = writer.frames.count + feed([ColmiCommandID.syncHeartRate, 0x00, 0xc8, 0x05]) // size 200: garbled, ignored + feed([ColmiCommandID.syncHeartRate, 0x01, 60]) + XCTAssertEqual(writer.frames.count, before, "packet 1 is not terminal without a usable header") + } + + // MARK: Stress / HRV paging + + func testStressEndsOnHeaderPageCount() { + driveToStress() + XCTAssertEqual(lastFrame, ColmiEncoder().syncStress()) + feedPagedDay(ColmiCommandID.syncStress, size: 3) + XCTAssertEqual(lastFrame, [UInt8](ColmiEncoder().bigDataSpo2()), "stress done after packet 2 of 3") + } + + func testStressWithoutHeaderStillEndsOnPacket4() { + driveToStress() + let before = writer.frames.count + for nr: UInt8 in 1...3 { feed([ColmiCommandID.syncStress, nr, 20]) } + XCTAssertEqual(writer.frames.count, before) + feed([ColmiCommandID.syncStress, 4, 20]) + XCTAssertEqual(lastFrame, [UInt8](ColmiEncoder().bigDataSpo2())) + } + + func testHRVEndsOnHeaderPageCountAndRequestsNextDay() { + driveToHRV() + XCTAssertEqual(lastFrame, ColmiEncoder().syncHRV(daysAgo: 0)) + let before = writer.frames.count + + feed([ColmiCommandID.syncHRV, 0x00, 0x03, 0x1e]) + feed([ColmiCommandID.syncHRV, 0x01, 0x00, 40]) + XCTAssertEqual(writer.frames.count, before) + feed([ColmiCommandID.syncHRV, 0x02, 41]) + XCTAssertEqual(lastFrame, ColmiEncoder().syncHRV(daysAgo: 1)) + } + + func testHeaderFromOneDayDoesNotLeakIntoTheNext() { + driveToHRV() + feedPagedDay(ColmiCommandID.syncHRV, size: 2) // day 0 ends on packet 1 + XCTAssertEqual(lastFrame, ColmiEncoder().syncHRV(daysAgo: 1)) + let before = writer.frames.count + feed([ColmiCommandID.syncHRV, 0x01, 0x00, 40]) // day 1, no header: fallback size 5 + XCTAssertEqual(writer.frames.count, before) + } + + // MARK: Request bytes + + func testSleepRequestIsQRingNewSleepProtocol() { + XCTAssertEqual([UInt8](ColmiEncoder().bigDataSleep()), [0xbc, 0x27, 0x02, 0x00, 0x81, 0x80, 0xff, 0x01]) + } + + func testSleepStageSendsTheNewRequest() { + driveToStress() + feedEmptyDay(ColmiCommandID.syncStress) + engine.handleBigDataComplete(type: ColmiCommandID.bigDataSpo2) + XCTAssertEqual(lastFrame, [0xbc, 0x27, 0x02, 0x00, 0x81, 0x80, 0xff, 0x01]) + } + + /// The CRC builder reproduces the SpO2 and temperature requests already verified on rings. + func testOtherBigDataRequestsAreUnchanged() { + XCTAssertEqual([UInt8](ColmiEncoder().bigDataSpo2()), [0xbc, 0x2a, 0x01, 0x00, 0xff, 0x00, 0xff]) + XCTAssertEqual([UInt8](ColmiEncoder().bigDataTemperature()), [0xbc, 0x25, 0x01, 0x00, 0x3e, 0x81, 0x02]) + } + + func testHeartRateRequestEpochIsLocalDateAtUTCMidnight() { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + let utcMidnight = utc.date(from: DateComponents(year: 2026, month: 7, day: 1))! + + for id in ["Asia/Kolkata", "America/New_York", "UTC"] { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: id)! + let localMidnight = cal.date(from: DateComponents(year: 2026, month: 7, day: 1))! + XCTAssertEqual( + ColmiSyncEngine.heartRateRequestEpoch(dayStart: localMidnight, timeZone: cal.timeZone), + Int(utcMidnight.timeIntervalSince1970), id + ) + } + } + + // MARK: Nap sleep + + func testNapBigDataDecodesAsSleep() { + let payload: [UInt8] = [0x01, 0x00, 0x08, 0xd0, 0x02, 0x0c, 0x03, ColmiCommandID.sleepLight, 30, ColmiCommandID.sleepDeep, 14] + let frame = Data([ColmiCommandID.bigDataV2, ColmiCommandID.bigDataSleepNap, + UInt8(payload.count), 0x00, 0x00, 0x00] + payload) + let events = ColmiDecoder().decodeBigData(frame) + guard events.count == 1, case let .sleepTimeline(_, stages) = events[0] else { + return XCTFail("expected one sleepTimeline, got \(events.map(\.kind))") + } + XCTAssertEqual(stages.count, 44) + } +} diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 4923cc90..09cdfd41 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -116,10 +116,29 @@ unit-tested against the same captures but has not been run against the ring. `6a 0b 00 00` for temperature (`StopHeartRateReq.stopTemperatureCheck`). Temperature is `raw / 10 + 20` °C. - **Sleep.** A session of 180 minutes or less is filed under the nap action (`bc 3e`) with main sleep - (`bc 27`) left empty; longer sessions land on `bc 27`, which iOS decodes (pinned by - `testRing2ProCapturedNightDecodes`). **iOS does not request or decode `bc 3e`**, so short sleeps on - this ring are not imported on iOS yet. Firmware analysis shows light/deep/REM are generated with - `rand()`; only the asleep/awake boundaries come from the sensors. + (`bc 27`) left empty; longer sessions land on `bc 27` (pinned by `testRing2ProCapturedNightDecodes`). + The ring sends `bc 3e` on its own alongside the `bc 27` reply; iOS decodes both with the same + day-record parser, but the iOS port has not yet been run against this ring. Firmware analysis + shows light/deep/REM are generated with `rand()`; only the asleep/awake boundaries come from the + sensors. + +## History sync (QRing firmware) + +PulseLoop pulls history as one chain on every connect and every 30 minutes while connected: +activity (8 days) → heart rate (8 days) → stress → SpO₂ → sleep → HRV (7 days) → temperature. It +sends what QRing sends: + +- **Paged days (HR `0x15`, stress `0x37`, HRV `0x39`) end on the ring's own page count.** Packet 0 + is a header, ` 00 `, and the day is done at packet `size − 1`. Without a + header, HR ends at packet 23 and stress/HRV at packet 4. A day with no data answers ` ff`. +- **HR is requested by date, not by instant.** The request carries local midnight plus the zone + offset, which is the calendar date at 00:00 UTC. The ring keys its daily logs on that. +- **Sleep uses QRing's New_Sleep_Protocol request**, `bc 27 02 00 81 80 ff 01`. Payload `ff 01` means + all history, protocol version 1, and `81 80` is its CRC16/MODBUS. Newer firmware ignores the + 1-byte `bc 27 01 00 ff 00 ff` that GadgetBridge sends. + +A stage that never answers is skipped after 10 s (20 s for activity), so one silent stage can't +stall the chain. ## Sensors From 29235657a30b130ab2a87094546b9c8c9ecf3fab Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 8 Nov 2026 16:45:45 -0500 Subject: [PATCH 4/5] Ask unanswered YCBT SpO2 and temperature logs once per connection instead of every sync --- .../ColmiSmartHealthCoordinator.swift | 3 +- PulseLoop/RingProtocol/TK5Coordinator.swift | 3 +- .../RingProtocol/YCBTHistoryTransfer.swift | 56 +++++++- PulseLoop/RingProtocol/YCBTProtocol.swift | 4 +- PulseLoop/RingProtocol/YCBTSyncEngine.swift | 12 +- PulseLoopTests/YCBTHistoryEraseTests.swift | 26 ++++ PulseLoopTests/YCBTHistoryTransferTests.swift | 130 ++++++++++++++++++ docs/hardware/colmi.md | 7 +- docs/hardware/r10m.md | 2 +- docs/hardware/tk5.md | 11 +- 10 files changed, 239 insertions(+), 15 deletions(-) diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 04f60898..9dfc3026 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -137,7 +137,8 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { /// R99 proved this benign: it NAKed `01 45` (HRV) and `01 1c` (all-day BP) with `0xFC` and honoured /// the other three. /// - `.spo2History` is the all-day `05 1A` log. A ring without it answers the query with a no-data - /// header or `0xFC`, which `YCBTHistoryTransfer` skips permanently. + /// header or `0xFC`, which `YCBTHistoryTransfer` skips permanently — or, like the R99, with nothing + /// at all, which costs one watchdog and is then not asked again until the link drops. /// /// Neither is named by any bit in `YCBTSupportFunction`, so gating them would not defer the decision /// — it would make them permanently unreachable (see `bitmapGatedCapabilities`). diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index fe105212..e86e4cef 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -55,7 +55,8 @@ final class TK5Coordinator: WearableCoordinator { /// /// - `.spo2History` — the all-day `05 1A` log. No bit names it: it is one of the SpO₂ sources /// `ISHASBLOODOXYGEN` already grants, not a separate sensor. A ring without the log answers the - /// query with a no-data header or `0xFC`, which `YCBTHistoryTransfer` skips permanently. + /// query with a no-data header or `0xFC`, which `YCBTHistoryTransfer` skips permanently — or with + /// nothing at all (the R99), which costs one watchdog per connection. /// - `.remSleep` — a stage tag (`3`) *inside* the `05 04` timeline `ISHASSLEEP` grants. Same shape. /// Gating either would not defer the decision, it would make them permanently unreachable, because /// no bit can ever satisfy the gate (`PairingMatchingTests`). diff --git a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift index c93ad61c..eea98c38 100644 --- a/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift +++ b/PulseLoop/RingProtocol/YCBTHistoryTransfer.swift @@ -81,8 +81,31 @@ final class YCBTHistoryTransfer { /// What the header promised, kept for the terminal block to be checked against. See `handleTerminal`. private var expectedPackets = 0 private var expectedBytes = 0 - /// Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. + /// Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. Deliberately + /// survives `cancel()`: an auto-reconnect keeps this driver, and a rejection is a firmware fact. private var unsupported: Set = [] + /// Types the watchdog skipped **without the ring sending anything at all** — no header, no frame on + /// the type's keys — not asked again until the link drops. + /// + /// An R99 never answers the dedicated SpO₂ log (`05 1a`): no header, no `0xFB`/`0xFC`, nothing, in + /// ~45 syncs. Without this, every 30-minute pass and every post-workout vitals backfill sat through + /// the full inactivity watchdog for it. No `02 01` bit names that log (SmartHealth never queries it), + /// so the ring's own silence is the only signal there is. + /// + /// Unlike `unsupported` this is cleared by `cancel()`. A silence is evidence, not a verdict: a fresh + /// link — possibly to a different ring of the same family on the same driver — asks again, and a ring + /// that does keep the log loses at most one connection's worth of reads (never data: a skipped type + /// is never erased, so the next connection reads it in full). + private var silent: Set = [] + /// Only the dedicated SpO₂ (`05 1a`) and temperature (`05 1e`) logs — the two SmartHealth itself never + /// queries (`DataSyncUtils.syncData`, `DataSyncUtils.java:99-143`) — can be remembered as silent. A core + /// log the vendor reads every sync (sport, sleep, heart, All, …) that misses one reply is retried on + /// the next pass: rings stay connected for days, and skipping one until a reconnect would let its + /// log fill up unread and un-erased — the very failure that froze the R99's automatic SpO₂. + private static let mayGoSilent: Set = [YCBTHistoryType.spo2.queryKey, YCBTHistoryType.temperature.queryKey] + /// Whether the ring has sent a header or a data frame for the in-flight type. Reset per type by + /// `advance()`, not by the CRC retry — a type that earned a retry was plainly answered. + private var currentTypeAnswered = false /// Types eligible for the erase phase, in completion order: CRC-accepted, ≥1 record, not re-queued /// since. See the type doc. @@ -115,8 +138,8 @@ final class YCBTHistoryTransfer { /// running — the walk isn't over until its deletes are. var isActive: Bool { state != .idle } - /// Seed the queue and request the first type. Types the ring already rejected this session are - /// skipped. + /// Seed the queue and request the first type. Types the ring already rejected this session, or left + /// unanswered on this connection, are skipped. /// /// **A transfer already in flight wins.** There are now three callers (the connect handshake, the /// post-workout vitals backfill, the 30-minute periodic pass), and a second `start` would abandon the @@ -127,10 +150,14 @@ final class YCBTHistoryTransfer { /// interleaved with a delete could be answered out of order against either. func start(types: [YCBTHistoryType]) { guard !isActive else { return } - queue = types.filter { !unsupported.contains($0.queryKey) } + queue = types.filter(isWorthAsking) publishOutOfBand(advance()) } + private func isWorthAsking(_ type: YCBTHistoryType) -> Bool { + !unsupported.contains(type.queryKey) && !silent.contains(type.queryKey) + } + /// Add types to the queue **without** disturbing whatever is in flight. /// /// `start` deliberately refuses while a transfer is running, which is right for a second full pass @@ -148,7 +175,7 @@ final class YCBTHistoryTransfer { /// the erase phase is running the additions simply wait; the phase hands over to them when it ends. func append(types: [YCBTHistoryType]) { let additions = types.filter { type in - !unsupported.contains(type.queryKey) + isWorthAsking(type) && !queue.contains(type) && state.type != type } @@ -159,14 +186,16 @@ final class YCBTHistoryTransfer { publishOutOfBand(advance()) } - /// Abandon any in-flight transfer (disconnect / teardown). Also forgets the erase phase and the - /// bitmap: both belong to one link, and a commit verdict still in flight is orphaned by `generation`. + /// Abandon any in-flight transfer (disconnect / teardown). Also forgets the erase phase, the bitmap + /// and the silent types: all belong to one link, and a commit verdict still in flight is orphaned by + /// `generation`. `unsupported` is kept (see its doc). func cancel() { cancelWatchdog() state = .idle queue.removeAll() buffer.removeAll() pendingDeletes.removeAll() + silent.removeAll() capabilityBitmapReceived = false generation &+= 1 } @@ -185,6 +214,7 @@ final class YCBTHistoryTransfer { buffer.removeAll(keepingCapacity: false) bufferCap = Self.defaultBufferCap retriedCurrentType = false + currentTypeAnswered = false expectedPackets = 0 expectedBytes = 0 guard !queue.isEmpty else { return beginErasePhase() } @@ -220,10 +250,14 @@ final class YCBTHistoryTransfer { return advance() } + // A terminal is deliberately not evidence of an answer: one that arrives before this type's header + // or data is the previous type's, late (see `handleTerminal`), and says nothing about this one. switch cmd { case type.queryKey: + currentTypeAnswered = true return handleHeader(type, payload: payload) case type.ackKey: + currentTypeAnswered = true appendData(payload) armWatchdog(for: type) return [] @@ -404,6 +438,10 @@ final class YCBTHistoryTransfer { /// Fires only on silence: the type is declared stalled and skipped. It must never ACK (an ACK /// without a verified terminal block tells the ring we accepted data we don't have) and must never /// stand in for completion. + /// + /// A type the ring never answered at all is also remembered as `silent` for the rest of this + /// connection. One that answered and *then* stalled is not: that is a transfer gone wrong, and the + /// next pass should try it again. private func armWatchdog(for type: YCBTHistoryType) { watchdog?.cancel() let deadline = typeDeadline ?? Date().addingTimeInterval(absoluteCapSeconds) @@ -412,6 +450,10 @@ final class YCBTHistoryTransfer { watchdog = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled, let self, self.state.type == type else { return } + if !self.currentTypeAnswered, Self.mayGoSilent.contains(type.queryKey) { + self.silent.insert(type.queryKey) + transferLog.info("\(type.label, privacy: .public) history unanswered; not asked again on this connection") + } self.publishOutOfBand(self.advance()) } } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index 68697250..9b734d0e 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -246,7 +246,9 @@ struct YCBTHistoryType: Equatable, Hashable, Sendable { /// Every type the SDK's `DataSyncUtils` can request, in its own ascending-key sync order — and every /// type `YCBTHealthRecords` decodes. Both YCBT families query the whole catalog, whatever their /// capability set says: a type the ring doesn't implement answers with a no-data header or a `0xFC` - /// (unsupported key), which `YCBTHistoryTransfer` skips — permanently, for `0xFC`. Asking is + /// (unsupported key), which `YCBTHistoryTransfer` skips — permanently, for `0xFC` — or, on some + /// firmware, with nothing at all, which costs one watchdog per connection (see + /// `YCBTHistoryTransfer.silent`). Asking is /// therefore cheaper than keeping a second, capability-derived list that could disagree with the /// ring's own answer. /// diff --git a/PulseLoop/RingProtocol/YCBTSyncEngine.swift b/PulseLoop/RingProtocol/YCBTSyncEngine.swift index ffca5a94..77aaf76d 100644 --- a/PulseLoop/RingProtocol/YCBTSyncEngine.swift +++ b/PulseLoop/RingProtocol/YCBTSyncEngine.swift @@ -34,8 +34,16 @@ final class YCBTSyncEngine: RingSyncEngine { /// /// A ring that doesn't implement a type answers with a no-data header or a `0xFC` (unsupported key) /// error; `YCBTHistoryTransfer` skips both and — for `0xFC` — stops asking for the rest of the - /// session. Querying the full catalog is therefore free, and which types actually return data is - /// exactly the capability evidence the on-device checkpoint is looking for. + /// session. Some firmware answers nothing at all instead (the R99 on `05 1a`): that costs one + /// watchdog, after which the transfer stops asking until the link drops. Querying the full catalog is + /// therefore cheap, and which types actually return data is exactly the capability evidence the + /// on-device checkpoint is looking for. + /// + /// `05 1a` (SpO₂) and `05 1e` (temperature) are queried although SmartHealth never asks for them + /// (`DataSyncUtils.syncData`): the SDK defines and decodes both logs, and asking is the only way to + /// learn whether a given ring keeps them. No `02 01` bit names either *log* — the nearest are the + /// sensor bits `ISHASBLOODOXYGEN` / `ISHASTEMP` — so `05 1e` is gated on the temperature sensor, and + /// `05 1a` only on the family's `.spo2History` claim; see `supportedHistoryTypes`. private static let historyTypes: [YCBTHistoryType] = [ .sport, .sleep, .heart, .blood, .all, .spo2, .temperature, .comprehensive, .bodyData, ] diff --git a/PulseLoopTests/YCBTHistoryEraseTests.swift b/PulseLoopTests/YCBTHistoryEraseTests.swift index 84d7357a..c15bccfd 100644 --- a/PulseLoopTests/YCBTHistoryEraseTests.swift +++ b/PulseLoopTests/YCBTHistoryEraseTests.swift @@ -207,6 +207,32 @@ final class YCBTHistoryEraseTests: XCTestCase { XCTAssertEqual(writer.deletes, [spo2Delete]) } + /// A type the ring never answered at all (the R99's `05 1a`) is remembered as silent for the + /// connection — and silence never makes it eligible: neither that walk nor a later one on the same + /// link erases it, while the types that did answer are erased as usual. + func testASilentTypeIsNeverErased() async throws { + let writer = CommitWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + transfer.noteCapabilityBitmapReceived() + transfer.start(types: [.heart, .spo2]) + complete(.heart, sixByteBuffer, on: transfer) + XCTAssertEqual(writer.sent.last, spo2Query) + // SpO₂: no header, no error, nothing — the watchdog skips it and the erase phase begins. + try await waitForCommit(writer) + writer.commits[0](true) + XCTAssertTrue(isFinished(transfer.handle(cmd: 0x42, payload: [0x00]))) + XCTAssertEqual(writer.deletes, [heartDelete]) + + writer.sent.removeAll() + transfer.start(types: [.heart, .spo2]) + complete(.heart, sixByteBuffer, on: transfer) + XCTAssertEqual(writer.sent, [heartQuery, Data([0x05, 0x80, 0x00])], + "the silent type is not asked again on this link") + try await waitForCommit(writer, count: 2) + writer.commits[1](true) + XCTAssertEqual(writer.deletes, [heartDelete], "and is never erased") + } + /// Until this connection's `02 01` bitmap arrives, the driver filters samples on the baseline set, so /// a saved log may be missing fields the ring does have. Nothing is erased then. func testNoDeleteBeforeTheCapabilityBitmap() async throws { diff --git a/PulseLoopTests/YCBTHistoryTransferTests.swift b/PulseLoopTests/YCBTHistoryTransferTests.swift index 4a389c79..4bf278b0 100644 --- a/PulseLoopTests/YCBTHistoryTransferTests.swift +++ b/PulseLoopTests/YCBTHistoryTransferTests.swift @@ -524,4 +524,134 @@ final class YCBTHistoryTransferTests: XCTestCase { XCTAssertTrue(writer.sent.isEmpty, "a cancelled transfer must not keep querying") XCTAssertFalse(transfer.isActive) } + + // MARK: Silent types (session memory) + + private let spo2Query = Data([0x05, 0x1a]) + + /// Walk whatever the engine queued: every query is answered "no data", except `silentKey`, which + /// gets nothing at all — the watchdog has to skip it. Returns the query keys in the order asked. + private func walk(_ transfer: YCBTHistoryTransfer, _ writer: FakeWriter, + silentOn silentKey: UInt8? = nil) async throws -> [UInt8] { + let isQuery: (Data) -> Bool = { $0.count == 2 && $0[0] == 0x05 } + var requested: [UInt8] = [] + while let query = writer.sent.last(where: isQuery) { + requested.append(query[1]) + writer.sent.removeAll() + if query[1] == silentKey { + for _ in 0..<500 where !writer.sent.contains(where: isQuery) && transfer.isActive { + try await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertFalse(writer.sent.contains { $0.starts(with: [0x05, 0x80]) }, "a skip never ACKs") + } else { + _ = transfer.handle(cmd: query[1], payload: [0x00]) + } + } + XCTAssertFalse(transfer.isActive, "the walk must finish") + return requested + } + + /// The R99 never answers `05 1a` — no header, no error, nothing. That costs the watchdog once; after + /// that, neither a vitals backfill nor the 30-minute pass on the same connection asks again. + func testASilentTypeIsSkippedOnceThenNotAskedAgainOnTheSameConnection() async throws { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + let engine = YCBTSyncEngine(writer: writer, transfer: transfer, profile: .permissiveTestProfile) + + engine.syncVitalsHistory() + let first = try await walk(transfer, writer, silentOn: 0x1a) + XCTAssertEqual(first, [0x06, 0x09, 0x1a], "the first pass asks, and the watchdog skips it") + + engine.syncVitalsHistory() + let backfill = try await walk(transfer, writer) + XCTAssertEqual(backfill, [0x06, 0x09], "the next backfill no longer waits on the silent log") + + engine.syncHistory() + let periodic = try await walk(transfer, writer) + XCTAssertEqual(periodic, [0x02, 0x04, 0x06, 0x08, 0x09, 0x1e, 0x2f, 0x33], + "nor does the periodic full pass") + } + + /// The memory belongs to one link: after a disconnect (`cancel()`, which the driver calls on both + /// `connectionDidEnd` and `connectionDidStart`) a silent type is asked again. A `0xFC` rejection, by + /// contrast, is a firmware fact and outlives the reconnect. + func testASilentTypeIsAskedAgainAfterAReconnect() async throws { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + + transfer.start(types: [.heart, .spo2]) + _ = transfer.handle(cmd: 0x06, payload: [0xfc]) // heart: unsupported key + try await waitForWrite(spo2Query, by: writer) // spo2: asked… + for _ in 0..<500 where transfer.isActive { try await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertFalse(transfer.isActive, "…and skipped by the watchdog") + + writer.sent.removeAll() + transfer.start(types: [.heart, .spo2]) + XCTAssertTrue(writer.sent.isEmpty, "both are remembered on this connection") + + transfer.cancel() + transfer.start(types: [.heart, .spo2]) + XCTAssertEqual(writer.sent, [spo2Query], "a new link asks the silent type again, not the rejected one") + } + + /// A ring that *answered* — a header, or even just a data frame — and then stalled has a transfer + /// that went wrong, not a log it lacks. The next pass must try it again. + func testATypeThatAnsweredThenStalledIsNotRememberedAsSilent() async throws { + for firstFrame in [(cmd: UInt8(0x1a), payload: header(records: 2, packets: 1, bytes: heartBuffer.count)), + (cmd: UInt8(0x22), payload: heartBuffer)] { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + + transfer.start(types: [.spo2]) + _ = transfer.handle(cmd: firstFrame.cmd, payload: firstFrame.payload) + for _ in 0..<500 where transfer.isActive { try await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertFalse(transfer.isActive, "the watchdog skips the stalled type") + + writer.sent.removeAll() + transfer.start(types: [.spo2]) + XCTAssertEqual(writer.sent, [spo2Query], "a type that replied on 0x\(String(firstFrame.cmd, radix: 16)) is asked again") + } + } + + /// `05 80` carries no type identity, so a late terminal for the previous type is not an answer from + /// this one: a type that saw nothing else is still silent. + func testALateTerminalForThePreviousTypeDoesNotCountAsAnAnswer() async throws { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + + transfer.start(types: [.spo2]) + _ = transfer.handle(cmd: 0x80, payload: terminal(packets: 1, buffer: heartBuffer)) + for _ in 0..<500 where transfer.isActive { try await Task.sleep(nanoseconds: 1_000_000) } + + writer.sent.removeAll() + transfer.start(types: [.spo2]) + XCTAssertTrue(writer.sent.isEmpty) + } + + /// Only the vendor-unqueried logs (`05 1a`, `05 1e`) may be remembered as silent. A core log that + /// misses one reply is asked again on the very next pass — skipping it until a reconnect could let it + /// fill up unread on a ring that stays connected for days. + func testACoreLogThatMissedOneReplyIsAskedAgainNextPass() async throws { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + transfer.start(types: [.all]) + for _ in 0..<500 where transfer.isActive { try await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertFalse(transfer.isActive, "the watchdog skips the unanswered pass") + + writer.sent.removeAll() + transfer.start(types: [.all]) + XCTAssertEqual(writer.sent, [allQuery], "the All log is never remembered as silent") + } + + /// The bitmap path (`append`) must not re-ask a silent type either. + func testAppendSkipsASilentType() async throws { + let writer = FakeWriter() + let transfer = YCBTHistoryTransfer(writer: writer, inactivitySeconds: 0.05, absoluteCapSeconds: 5) + transfer.start(types: [.spo2]) + for _ in 0..<500 where transfer.isActive { try await Task.sleep(nanoseconds: 1_000_000) } + + writer.sent.removeAll() + transfer.append(types: [.spo2, .all]) + XCTAssertEqual(writer.sent, [allQuery]) + } } diff --git a/docs/hardware/colmi.md b/docs/hardware/colmi.md index 09cdfd41..d2ae88aa 100644 --- a/docs/hardware/colmi.md +++ b/docs/hardware/colmi.md @@ -241,7 +241,7 @@ The R09 shows why that matters: it reports **no HRV, temperature, stress or bloo | Capability | QRing-Colmi | SmartHealth-Colmi (R09) | |---|:---:|:---:| | Heart rate — history / live / spot | ✅ | ✅ | -| SpO₂ — history | ✅ | ✅ | +| SpO₂ — history | ✅ | ✅ ² | | SpO₂ — **spot** | ❌ (all-day only) | ✅ (takes ~40 s) | | Steps / distance / calories | ✅ | ✅ | | Sleep, incl. REM | ✅ | 🧪 untested | @@ -256,6 +256,11 @@ The R09 shows why that matters: it reports **no HRV, temperature, stress or bloo ¹ **Ring-declared.** A different SmartHealth-Colmi that sets the bit would get these cards. The R09 leaves them clear, and backs that up on the wire — it refuses the HRV commands outright. +² **From the combined `05 09` record.** PulseLoop also asks for the dedicated SpO₂ log (`05 1a`), which +SmartHealth itself never requests. The R99 never answers that query at all — no data, no error — so +PulseLoop gives up after its 10-second timeout and doesn't ask again until the ring reconnects. Each +connection costs one 10-second wait, not one per sync. + ### 🧪 Still unconfirmed **Sleep** is the one significant path no SmartHealth-Colmi has exercised yet. Everything else above is diff --git a/docs/hardware/r10m.md b/docs/hardware/r10m.md index f7e3c507..14bc3355 100644 --- a/docs/hardware/r10m.md +++ b/docs/hardware/r10m.md @@ -42,7 +42,7 @@ an owner whose ring only ever shipped one firmware. | **Hardware validation** | ✅ full session, FW 2.32 | ❌ none — protocol proven on a sibling | ✅ an R99 runs it daily | | **Chip scheme query** (`02 1b`) | ❌ suppressed — drops the link | ✅ answered | ✅ answered | | **All-day BP monitor** (`01 1c`) | ❌ not implemented | ✅ sent | ✅ sent | -| **Dedicated SpO₂ log** (`05 1a`) | ❌ never queried | ✅ queried | ✅ queried | +| **Dedicated SpO₂ log** (`05 1a`) | ❌ never queried | ✅ queried | ✅ queried — the R99 never answers, so it's asked once per connection | A fix to any `YCBT*` file fixes all three rings; a regression in one breaks all three. diff --git a/docs/hardware/tk5.md b/docs/hardware/tk5.md index 5c50b735..ee00a29c 100644 --- a/docs/hardware/tk5.md +++ b/docs/hardware/tk5.md @@ -85,7 +85,7 @@ promise — the app claims it unconditionally, and the bitmap can only ever *add | Heart rate — live | 🧪 | — | verified against the capture (climbed 82→86) | | Heart rate — history | 🧪 | — | `05 06` query, 6-byte records | | SpO₂ — spot | 🧪 | — | red/IR LED, `03 2f` mode `02` | -| SpO₂ — history | 🧪 | — | dedicated `05 1A` log **and** the All record. Not separately gated: no bit names it — it is one of the SpO₂ sources `ISHASBLOODOXYGEN` already grants | +| SpO₂ — history | 🧪 | — | dedicated `05 1A` log **and** the All record. Not separately gated: no bit names it — it is one of the SpO₂ sources `ISHASBLOODOXYGEN` already grants. A ring that never answers the `05 1A` query is asked once per connection (see [below](#how-pulseloop-diverges-from-smarthealth)) | | Steps / distance / calories | 🧪 | distance & calories byte offsets in the `06 00` live push | live push + `05 02` history buckets + the All record's cumulative counter | | Sleep (light / deep / awake) | 🧪 | — | `05 04` timeline; stage = `tag & 0x0F` | | REM sleep | 🧪 | — | stage tag `3` — a stage *inside* the timeline `ISHASSLEEP` grants, so no bit names it and it is not gated | @@ -169,6 +169,15 @@ history sample upserts on `(kind, timestamp)`, activity buckets upsert by start stored bucket, as SmartHealth's `INSERT OR REPLACE` does), and the cumulative step counter is a per-day `max` ratchet. +**We also ask for two logs SmartHealth never reads.** SmartHealth's sync list doesn't include the +dedicated SpO₂ log (`05 1A`) or the temperature log (`05 1E`). PulseLoop asks for both (temperature only +if the ring claims the sensor), because the SDK defines both and asking is the only way to learn +whether a ring keeps them: no capability bit names either log. A ring that lacks one may reply "no +data", reject it (`0xFC`, after which PulseLoop stops asking for the rest of the session), or — like +the R99 on `05 1A` — not reply at all. That last case costs one 10-second timeout. PulseLoop then +stops asking for that log until the ring reconnects, so the 30-minute passes and post-workout +backfills on the same connection don't wait on it. A log that was never answered is never erased. + **Using SmartHealth and PulseLoop on the same ring:** each app erases what it has imported, so whichever syncs first gets the records and the other never sees them. From 024a6b8320d502969218dfcf7ea276f8b0d6ad36 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 8 Nov 2026 16:47:48 -0500 Subject: [PATCH 5/5] Sync Jring history day by day since the last finished sync, like the vendor app --- PulseLoop/RingProtocol/JringSyncEngine.swift | 211 +++++++++++++++++- PulseLoop/RingProtocol/RingProtocol.swift | 99 ++++++++- PulseLoop/Services/PulseServices.swift | 4 +- PulseLoop/Wearables/WearableDriver.swift | 5 +- PulseLoopTests/JringSyncEngineTests.swift | 219 ++++++++++++++++++- PulseLoopTests/RingDecoderTests.swift | 75 +++++-- docs/hardware/jring.md | 37 ++++ 7 files changed, 608 insertions(+), 42 deletions(-) diff --git a/PulseLoop/RingProtocol/JringSyncEngine.swift b/PulseLoop/RingProtocol/JringSyncEngine.swift index 646953a2..162fb20c 100644 --- a/PulseLoop/RingProtocol/JringSyncEngine.swift +++ b/PulseLoop/RingProtocol/JringSyncEngine.swift @@ -1,16 +1,25 @@ import Foundation -/// jring sync engine. The jring is fire-and-forget: the startup sequence enqueues all commands up -/// front and the device streams replies, so `handle(_:)` is a no-op (no response-driven machine). -/// -/// Command construction is lifted verbatim from the old `RingSyncCoordinator.runStartupSequence` -/// and the measurement helpers, via the existing `RingEncoder` — behavior is unchanged. +/// jring sync engine. The connect handshake is fire-and-forget (every command is enqueued up front), +/// but the history that follows is a response-driven per-day loop, as in the vendor app: steps/sleep +/// (`10 dd`) then heart rate (`16 dd`) for each day from the oldest unsynced one down to today. See +/// "Per-day history" below. @MainActor final class JringSyncEngine: RingSyncEngine { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) private weak var writer: RingCommandWriter? private let encoder = RingEncoder() + /// Where each ring's last *finished* full history sync is remembered (see `lastSyncKey`). + private let defaults: UserDefaults + private let now: () -> Date + /// Quiet window that ends a `10 dd` transfer: vendor `h1 = 2000` ms (`bad.java:212`). + private let activityQuietSeconds: TimeInterval + /// Safety net for a step the ring never finishes. The vendor's own (`checkGetDataByDayTask`, + /// `UPDATE_INTERVAL_IN_MILLISECONDS = 10000`) abandons the sync if no history arrives at all; we + /// use the same 10 s per silent step, and also below the coordinator's 20 s stall timeout so the + /// progress bar never drops mid-loop. + private let stepWatchdogSeconds: TimeInterval /// Shared with the driver's `RingDecoder`: latched when we send 0x01, read back when decoding /// ring-stamped history timestamps. private let clock: JringClock @@ -29,9 +38,26 @@ final class JringSyncEngine: RingSyncEngine { /// per-day queries once the bit ordering is confirmed against real hardware. private(set) var bandCapabilities: JringBandCapabilities? - init(writer: RingCommandWriter?, clock: JringClock) { + init(writer: RingCommandWriter?, clock: JringClock, + defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }, + activityQuietSeconds: TimeInterval = 2, stepWatchdogSeconds: TimeInterval = 10) { self.writer = writer self.clock = clock + self.defaults = defaults + self.now = now + self.activityQuietSeconds = activityQuietSeconds + self.stepWatchdogSeconds = stepWatchdogSeconds + } + + /// Auto-reconnect reuses this engine: a loop from the dropped link must not survive into the new one. + func connectionDidStart() { + resetHistory() + } + + /// The loop's timers would otherwise keep firing across the reconnect gap and enqueue `16 dd` / + /// `10 dd` ahead of the next handshake. + func connectionDidEnd() { + resetHistory() } func runStartup() { @@ -54,22 +80,177 @@ final class JringSyncEngine: RingSyncEngine { // had to initialise with the vendor app first. enqueueMeasurementCommands(measurementSettings) writer?.enqueue(encoder.makeBandFunctionCommand()) - writer?.enqueue(encoder.makeHistoryQueryCommand()) - writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand()) + startFullHistory() } func handle(_ event: RingDecodedEvent) { switch event { - // Bind handshake is the one response-driven flow: the ring may initiate a claim on connect. + // The ring may initiate a claim on connect. case let .bind(action, _): respondToBind(action: action) case let .bandFunction(caps): bandCapabilities = caps + case let .jringHistoryFrame(frame): + handleHistoryFrame(frame) + default: + break + } + } + + // MARK: - Per-day history (10 dd → 16 dd → next day) + + /// One pass of the vendor's day loop. `DupMainActivity.autoSync` (:3172-3207) starts it at + /// `iMax` = whole days since the last finished sync (capped at 6); FragmentMain chains each day's + /// requests on the previous transfer's end (`fm.java:1074-1120`); `onGetMultipleSportData` + /// (`DupMainActivity.java:1096-1127`) then steps `day -= 1`, and below 0 saves the sync-finish time. + private struct HistoryRun { + enum Phase { case activity, measurement } + /// Saved as the ring's last-finished time when the run completes — the *start*, like the + /// vendor's `timeSyncEnd`, so records logged while the loop ran are re-read next time. + let startedAt: Date + /// The post-workout `16 00` (`syncVitalsHistory`): reports "done" but is not a full sync. + let vitalsOnly: Bool + var day: Int + var phase: Phase + /// A step ended on the watchdog rather than the ring's own terminator: still move on, but don't + /// record the run as finished, so the next sync re-reads the same days. + var incomplete = false + // `16 dd` end-by-count bookkeeping (vendor `a1`/`b1`/`c1`, `bad.java:3865-3884`). + var segments: Int? + var segment: Int? + var segmentBlocks: Int? + } + + private var history: HistoryRun? + private var stepTimer: Task? + + /// Start the loop at the oldest day this ring hasn't finished syncing. No-op while a loop is already + /// running (a manual sync mid-loop): that loop still reaches today and reports "done". + /// + /// The vendor also skips a sync started under 300 s after the last one. We don't: `runStartup` only + /// runs on connect or an explicit sync, which must end in "done" to clear the progress bar, and + /// re-reading today is idempotent (buckets upsert by start time; history samples dedupe). + private func startFullHistory() { + guard history == nil else { return } + let startedAt = now() + history = HistoryRun(startedAt: startedAt, vitalsOnly: false, + day: daysToSync(asOf: startedAt), phase: .activity) + requestCurrentStep() + } + + /// `iMax` from `autoSync`: calendar days between the last finished sync and today, clamped to 0...6. + /// A ring never synced here gets the full 6 (vendor default `"0"` → epoch → capped). + private func daysToSync(asOf date: Date) -> Int { + guard let key = lastSyncKey, let last = defaults.object(forKey: key) as? Date else { return 6 } + let calendar = Calendar.current + let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: last), + to: calendar.startOfDay(for: date)).day ?? 6 + return min(max(days, 0), 6) + } + + /// Per ring: a different ring (or a re-pair after Forget) must not inherit another's sync point. + private var lastSyncKey: String? { + writer?.deviceIdentifier.map { "jring.lastHistorySyncFinishedAt.\($0)" } + } + + private func requestCurrentStep() { + guard let run = history else { return } + switch run.phase { + case .activity: + emitProgress("activity", day: run.day) + writer?.enqueue(encoder.makeHistoryQueryCommand(day: run.day)) + case .measurement: + if !run.vitalsOnly { emitProgress("heart rate", day: run.day) } + writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand(day: run.day)) + } + armStepTimer(seconds: stepWatchdogSeconds, timedOut: true) + } + + /// Each step re-arms the coordinator's stall timeout, which only progress stages reset. + private func emitProgress(_ what: String, day: Int) { + let when: String + switch day { + case 0: when = "today" + case 1: when = "yesterday" + default: when = "\(day) days ago" + } + writer?.emit(.historySyncProgress(stage: "Syncing \(what) (\(when))…")) + } + + private func handleHistoryFrame(_ frame: JringHistoryFrame) { + guard let run = history else { return } + switch (run.phase, frame) { + case (.activity, .activity): + armStepTimer(seconds: activityQuietSeconds, timedOut: false) + case (.activity, .activityFailed), (.measurement, .measurementEnd), (.measurement, .measurementFailed): + advanceHistory() + case let (.measurement, .measurementHeader(segments)): + history?.segments = segments + armStepTimer(seconds: stepWatchdogSeconds, timedOut: true) + case let (.measurement, .measurementSegment(index, blocks)): + history?.segment = index + history?.segmentBlocks = blocks + armStepTimer(seconds: stepWatchdogSeconds, timedOut: true) + case let (.measurement, .measurementBlock(index)): + // Vendor end-by-count: the last segment's last block (`b1 == a1 && c1 == index + 1`). + if let segments = run.segments, run.segment == segments, run.segmentBlocks == index + 1 { + advanceHistory() + } else { + armStepTimer(seconds: stepWatchdogSeconds, timedOut: true) + } default: + // A late frame from the step before (e.g. an `FF` after an end-by-count) is not ours. break } } + /// 10 dd → 16 dd of the same day → 10 dd of the next-newer day; after day 0's 16, finish. + private func advanceHistory() { + stepTimer?.cancel() + stepTimer = nil + guard var run = history else { return } + switch run.phase { + case .activity: + run.phase = .measurement + case .measurement: + guard !run.vitalsOnly, run.day > 0 else { return finishHistory() } + run.day -= 1 + run.phase = .activity + } + run.segments = nil + run.segment = nil + run.segmentBlocks = nil + history = run + requestCurrentStep() + } + + /// The only place `.historySyncFinished` (→ "done") comes from, so it fires once per pass. + private func finishHistory() { + guard let run = history else { return } + history = nil + if !run.vitalsOnly, !run.incomplete, let key = lastSyncKey { + defaults.set(run.startedAt, forKey: key) + } + writer?.emit(.historySyncFinished) + } + + /// One timer per step: the quiet window that ends a `10 dd`, or the watchdog for a silent step. + private func armStepTimer(seconds: TimeInterval, timedOut: Bool) { + stepTimer?.cancel() + stepTimer = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + if timedOut { self.history?.incomplete = true } + self.advanceHistory() + } + } + + private func resetHistory() { + stepTimer?.cancel() + stepTimer = nil + history = nil + } + // MARK: - Time (0x01) /// Latch the offset we're about to encode, then send it. The decoder subtracts the same value @@ -158,8 +339,10 @@ final class JringSyncEngine: RingSyncEngine { } /// Release the ring on Forget: send UNBOND (0x4B action 5) so the ring re-advertises for other apps. + /// Its sync point goes too, so pairing it again re-reads the full week. func unbind() { writer?.enqueue(encoder.makeBindCommand(action: 5)) + if let key = lastSyncKey { defaults.removeObject(forKey: key) } } func startHeartRate() { @@ -220,10 +403,14 @@ final class JringSyncEngine: RingSyncEngine { writer?.enqueue(encoder.makeFindRingCommand()) } - /// Post-workout backfill: re-request the 0x16 measurement history stream (1-min HR blocks) so - /// readings taken while the phone was away land in the just-finished session. + /// Post-workout backfill: re-request today's 0x16 measurement history (`16 00`, 1-min HR blocks) + /// so readings taken while the phone was away land in the just-finished session. Its end reports + /// "done" (the coordinator waits on it) but is not a full sync. Skipped while a full loop runs — + /// that loop reads today's heart rate anyway, and a second `16` would interleave with it. func syncVitalsHistory() { - writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand()) + guard history == nil else { return } + history = HistoryRun(startedAt: now(), vitalsOnly: true, day: 0, phase: .measurement) + requestCurrentStep() } func setGoal(steps: Int) { diff --git a/PulseLoop/RingProtocol/RingProtocol.swift b/PulseLoop/RingProtocol/RingProtocol.swift index b55d06fc..b6701d84 100644 --- a/PulseLoop/RingProtocol/RingProtocol.swift +++ b/PulseLoop/RingProtocol/RingProtocol.swift @@ -82,6 +82,26 @@ struct JringBandCapabilities: Sendable, Equatable { var hasPressureHistory: Bool { bit(83) } } +/// One control frame of the jring's per-day history transfer — the markers `JringSyncEngine` chains +/// its day loop on. Vendor decoder: `BluetoothLeService.java` notify handler (`bad.java:3810-3893`). +enum JringHistoryFrame: Sendable, Equatable { + /// A `0x10` steps or `0x11` sleep packet. These carry no terminator: the vendor ends the transfer + /// after 2 s without one (`d(strArrSplit, true)` → `postDelayed(.., h1 = 2000)`, `bad.java:212`). + case activity + /// `0x90`: the ring refused the `10 dd` request (vendor "getdayabyday failed"). + case activityFailed + /// `0x16 F0`: the number of `AA` segments that follow, u16 LE at [6..7] (vendor `a1`). + case measurementHeader(segments: Int) + /// `0x16 AA`: segment number at [2] (`b1`) and its `A0` block count, u16 LE at [7..8] (`c1`). + case measurementSegment(index: Int, blocks: Int) + /// `0x16 A0`: one data block; its zero-based index within the segment is u16 LE at [6..7]. + case measurementBlock(index: Int) + /// `0x16 FF`: end of the heart-rate transfer. + case measurementEnd + /// `0x96`: the ring refused the `16 dd` request (vendor "getheartdata detail failed"). + case measurementFailed +} + struct RingPacket { let commandId: UInt8 let payload: [UInt8] @@ -194,6 +214,11 @@ enum RingDecodedEvent: Sendable { /// Produces no `PulseEvent` — the samples themselves arrive as separate `.historyMeasurement` /// events; this only advances the cursor. case timingHistoryFrame(cmd: Int, day: Int, frameIndex: Int) + /// A jring per-day history control frame (see `JringHistoryFrame`). Consumed by + /// `JringSyncEngine`, which chains `10 dd` → `16 dd` → the next day on it. Produces no `PulseEvent`: + /// the samples themselves arrive as separate `.activityBucket` / `.sleepTimeline` / + /// `.historyMeasurement` events. + case jringHistoryFrame(JringHistoryFrame) case timeSyncAck(timestamp: Date) case commandAck(commandId: UInt8) case unknown(commandId: UInt8, raw: Data) @@ -230,6 +255,7 @@ enum RingDecodedEvent: Sendable { case .spotVitalSample: return "spot_vital_sample" case .spotVitalNoReading: return "spot_vital_no_reading" case .timingHistoryFrame: return "timing_history_frame" + case .jringHistoryFrame: return "jring_history_frame" case .timeSyncAck: return "time_sync_ack" case .commandAck: return "command_ack" case .unknown: return "unknown" @@ -290,6 +316,8 @@ enum RingDecodedEvent: Sendable { return #"{"rejected_mode":\#(mode)}"# case let .timingHistoryFrame(cmd, day, frameIndex): return #"{"cmd":\#(cmd),"day":\#(day),"frameIndex":\#(frameIndex)}"# + case let .jringHistoryFrame(frame): + return #"{"frame":"\#(frame)"}"# case let .historySyncProgress(stage): return #"{"stage":"\#(stage)"}"# case let .battery(percent): @@ -333,6 +361,14 @@ struct RingDecoder { if packet.commandId == 0x16, bytes.count >= 20, bytes[1] == 0xa0 { return decodeHistoryHeartRate(bytes) } + // Steps (0x10) / sleep (0x11) history: the data, then the marker that restarts the sync + // engine's quiet window — these transfers have no end-of-stream frame of their own. + if packet.commandId == 0x10 { + return (decodeStepHistory(bytes).map { [$0] } ?? []) + [.jringHistoryFrame(.activity)] + } + if packet.commandId == 0x11 { + return [decode(data), .jringHistoryFrame(.activity)] + } // The 0x0c status payload carries both the embedded address and the firmware string; emit both. if packet.commandId == 0x0c, bytes.count >= 13 { return [decode(data), .firmware(version: firmwareString(bytes))] @@ -340,9 +376,27 @@ struct RingDecoder { return [decode(data)] } + /// Decode a 0x10 step-history packet: LE u32 ring timestamp at [1..4], then fifteen one-minute step + /// counts at [5..19] (vendor `onGetDataByDay(1, ts + 60·i, bytes[5 + i], 0)`, `bad.java:3810-3820`). + /// + /// Published as **one** `.activityBucket` per packet (the quarter-hour it covers), like Colmi's + /// history: buckets are upserted by start time and summed per day, so re-pulling a day is + /// idempotent and today's live `0x03` total is never double-counted. One bucket rather than fifteen + /// keeps a 7-day pull to ~670 upserts instead of ~10,000. The packet has no distance channel, so the + /// bucket carries 0 m (persistence keeps a day's live distance when its buckets carry none). + /// `nil` for an all-zero packet: there is nothing to add, and no row is worth creating. + private func decodeStepHistory(_ bytes: [UInt8]) -> RingDecodedEvent? { + guard bytes.count >= 20 else { return nil } + let steps = bytes[5..<20].reduce(0) { $0 + Int($1) } + guard steps > 0 else { return nil } + return .activityBucket(timestamp: ringDate(u32le(bytes, 1)), steps: steps, distanceMeters: 0) + } + /// Decode a 0x16 `0xA0` data block: base timestamp at [2..5] (LE u32), then two consecutive /// blocks of six 1-minute HR samples at [8..13] and [14..19]. Each block is averaged into one /// reading, the second timestamped 60s after the first. Mirrors the Android multi-packet routing. + /// The block's index ([6..7]) follows as a marker so the sync engine can end the transfer the way + /// the vendor does when the last segment's last block lands (`bad.java:3881-3884`). private func decodeHistoryHeartRate(_ bytes: [UInt8]) -> [RingDecodedEvent] { let base = ringDate(u32le(bytes, 2)) func average(_ slice: ArraySlice) -> Int? { @@ -358,7 +412,7 @@ struct RingDecoder { events.append(.historyMeasurement(kind: .heartRate, value: Double(avg), timestamp: base.addingTimeInterval(60))) } - return events.isEmpty ? [.commandAck(commandId: 0x16)] : events + return events + [.jringHistoryFrame(.measurementBlock(index: Int(u16le(bytes, 6))))] } /// Decode the 9-byte `0x24` combined-sensor payload into one event per valid metric. Byte map @@ -414,21 +468,29 @@ struct RingDecoder { // in `decodeAll`. Here we surface just the embedded address (re-asserts connected state). let address = bytes.count >= 9 ? bytes[3...8].map { String(format: "%02x", $0) }.joined(separator: ":") : nil return .status(address: address) + case 0x10 where bytes.count >= 20: + return decodeStepHistory(bytes) ?? .jringHistoryFrame(.activity) case 0x11 where bytes.count >= 20: let timestamp = ringDate(u32le(bytes, 1)) let stages = bytes[5..<20].map(stage) return .sleepTimeline(timestamp: timestamp, stages: stages) case 0x14 where bytes.count >= 6: return .heartRateSample(bpm: Int(bytes[5]), timestamp: now) - case 0x16 where bytes.count >= 2 && bytes[1] == 0xff: - // End-of-stream marker for the measurement-history sync: surface it as "finished" so the - // coordinator's endSync path fires (via RingEventBridge → `.syncProgress("done")`). - return .historySyncFinished + case 0x16 where bytes[1] == 0xff: + // End of one day's heart-rate transfer. Not "sync finished": `JringSyncEngine` chains the + // next day on it and reports `.historySyncFinished` itself once day 0 is done. + return .jringHistoryFrame(.measurementEnd) + case 0x16 where bytes[1] == 0xf0: + return .jringHistoryFrame(.measurementHeader(segments: Int(u16le(bytes, 6)))) + case 0x16 where bytes[1] == 0xaa: + return .jringHistoryFrame(.measurementSegment(index: Int(bytes[2]), blocks: Int(u16le(bytes, 7)))) case 0x16: - // Header (0xF0) carries the total packet count; index (0xAA) is a sync-flow marker. Data - // blocks (0xA0) are handled in `decodeAll`. None decode to a measurement on their own, so - // they're plain acks here. + // Data blocks (0xA0) are handled in `decodeAll`; anything else is an unnamed sub-type. return .commandAck(commandId: packet.commandId) + case 0x90: + return .jringHistoryFrame(.activityFailed) + case 0x96: + return .jringHistoryFrame(.measurementFailed) case 0x20 where bytes.count >= 2: return .bandFunction(JringBandCapabilities(bytes: Array(bytes.dropFirst()))) case 0x24: @@ -463,6 +525,11 @@ struct RingDecoder { return String(format: "%04X%04XV%d", cid, did, version) } + private func u16le(_ bytes: [UInt8], _ offset: Int) -> UInt16 { + guard bytes.count >= offset + 2 else { return 0 } + return UInt16(bytes[offset]) | UInt16(bytes[offset + 1]) << 8 + } + private func u32le(_ bytes: [UInt8], _ offset: Int) -> UInt32 { guard bytes.count >= offset + 4 else { return 0 } return UInt32(bytes[offset]) @@ -485,8 +552,20 @@ struct RingEncoder { func makeStatusCommand() -> Data { data(hex: "0c00000000000000000000000000000000000000") } func makeLocaleCommand(locale: String = "en-US") -> Data { data(hex: "21656e2d55530000000000000000000000000000") } func makeActivityQueryCommand() -> Data { data(hex: "0299b85a00000000000000000000000000000000") } - func makeHistoryQueryCommand() -> Data { data(hex: "1000000000000000000000000000000000000000") } - func makeHistoryMeasurementQueryCommand() -> Data { data(hex: "1600000000000000000000000000000000000000") } + /// Steps + sleep history for one day (`10 dd`, vendor `getDataByDay(1, dd)` → + /// `BluetoothLeService.a(int,int)` at :5218). `day` 0 = today … 6; the ring answers with `0x10` + /// steps and `0x11` sleep packets, or `0x90` on failure. + func makeHistoryQueryCommand(day: Int = 0) -> Data { makeDayQuery(0x10, day: day) } + /// Heart-rate history for one day (`16 dd`, vendor `getDataByDay(2, dd)`): `F0` header, `AA` + /// segment, `A0` data blocks, `FF` end — or `0x96` on failure. + func makeHistoryMeasurementQueryCommand(day: Int = 0) -> Data { makeDayQuery(0x16, day: day) } + + private func makeDayQuery(_ opcode: UInt8, day: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = opcode + bytes[1] = UInt8(clamping: max(0, day)) + return Data(bytes) + } func makeHeartRateStartCommand() -> Data { data(hex: "14b4000000000000000000000000000000000000") } func makeHeartRateStopCommand() -> Data { data(hex: "1500000000000000000000000000000000000000") } func makeFindRingCommand() -> Data { data(hex: "040a000000000000000000000000000000000000") } diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index b3d76185..06c1d978 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -797,7 +797,9 @@ enum ActivityService { row.distanceMeters = max(row.distanceMeters, totalDistance) } else { row.steps = totalSteps - row.distanceMeters = totalDistance + // A bucket stream with no distance channel (jring `0x10` carries steps only) must not wipe + // the distance the ring reported live for that day. + if totalDistance > 0 { row.distanceMeters = totalDistance } row.source = ringHistorySource } row.syncedAt = syncedAt diff --git a/PulseLoop/Wearables/WearableDriver.swift b/PulseLoop/Wearables/WearableDriver.swift index f84924e5..7a8344b1 100644 --- a/PulseLoop/Wearables/WearableDriver.swift +++ b/PulseLoop/Wearables/WearableDriver.swift @@ -178,8 +178,9 @@ struct UserProfileValues: Sendable, Equatable { } } -/// Per-device orchestration of command flows. The jring engine is fire-and-forget (`handle` is a -/// no-op); the Colmi engine advances a 7-stage response-driven history machine inside `handle`. +/// Per-device orchestration of command flows. The jring engine fires its handshake up front, then +/// chains a per-day history loop in `handle`; the Colmi engine advances a 7-stage response-driven +/// history machine inside `handle`. /// Lives behind the driver so `RingBLEClient` / `RingSyncCoordinator` stay clean. @MainActor protocol RingSyncEngine: AnyObject { diff --git a/PulseLoopTests/JringSyncEngineTests.swift b/PulseLoopTests/JringSyncEngineTests.swift index 14c88170..5231ab6e 100644 --- a/PulseLoopTests/JringSyncEngineTests.swift +++ b/PulseLoopTests/JringSyncEngineTests.swift @@ -9,14 +9,58 @@ final class JringSyncEngineTests: XCTestCase { private final class FakeWriter: RingCommandWriter { nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) var sent: [Data] = [] + var emitted: [RingDecodedEvent] = [] + var deviceIdentifier: String? { "test-ring" } func enqueue(_ command: Data) { sent.append(command) } + func emit(_ event: RingDecodedEvent) { emitted.append(event) } var opcodes: [UInt8] { sent.compactMap(\.first) } func frame(opcode: UInt8) -> [UInt8]? { sent.first { $0.first == opcode }.map { [UInt8]($0) } } + /// `[opcode, day]` of every history request, in send order. + var historyRequests: [[UInt8]] { + var requests: [[UInt8]] = [] + for frame in sent where frame.first == 0x10 || frame.first == 0x16 { + requests.append([UInt8](frame.prefix(2))) + } + return requests + } + var doneCount: Int { + emitted.filter { if case .historySyncFinished = $0 { return true } else { return false } }.count + } } - private func makeEngine() -> (JringSyncEngine, FakeWriter) { + private static let lastSyncKey = "jring.lastHistorySyncFinishedAt.test-ring" + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "JringSyncEngineTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + /// Long timers by default so only the tests that inject short ones ever see one fire. + private func makeEngine(lastSync: Date? = nil, now: Date = Date(), + quiet: TimeInterval = 60, watchdog: TimeInterval = 60) -> (JringSyncEngine, FakeWriter) { + if let lastSync { defaults.set(lastSync, forKey: Self.lastSyncKey) } let writer = FakeWriter() - return (JringSyncEngine(writer: writer, clock: JringClock()), writer) + let engine = JringSyncEngine(writer: writer, clock: JringClock(), defaults: defaults, now: { now }, + activityQuietSeconds: quiet, stepWatchdogSeconds: watchdog) + return (engine, writer) + } + + private func daysAgo(_ days: Int, from now: Date = Date()) -> Date { + Calendar.current.date(byAdding: .day, value: -days, to: now)! + } + + /// End both steps of the current day the way the ring does on failure / end-of-stream. + private func finishDay(_ engine: JringSyncEngine) { + engine.handle(.jringHistoryFrame(.activityFailed)) + engine.handle(.jringHistoryFrame(.measurementEnd)) } func testStartupArmsBackgroundMonitoringAndQueriesCapabilities() { @@ -31,9 +75,176 @@ final class JringSyncEngineTests: XCTestCase { 0x21, // locale 0x19, // background monitoring ← arms the ring's sensor logging 0x20, // capability bitmask - 0x10, // activity history - 0x16, // measurement history + 0x10, // activity history — the 16 follows once this day's 10 transfer ends ]) + // Never synced here → the vendor's maximum look-back, 6 days. + XCTAssertEqual(writer.historyRequests, [[0x10, 6]]) + } + + // MARK: - Per-day history loop + + /// `10 dd` / `16 dd` are 20-byte frames, zero padded (vendor `BluetoothLeService.a(int,int)`). + func testDayQueryCommandBytes() { + let encoder = RingEncoder() + XCTAssertEqual([UInt8](encoder.makeHistoryQueryCommand(day: 3)), [0x10, 0x03] + [UInt8](repeating: 0, count: 18)) + XCTAssertEqual([UInt8](encoder.makeHistoryMeasurementQueryCommand(day: 3)), [0x16, 0x03] + [UInt8](repeating: 0, count: 18)) + // The no-argument forms stay "today". + XCTAssertEqual([UInt8](encoder.makeHistoryQueryCommand()), [0x10] + [UInt8](repeating: 0, count: 19)) + XCTAssertEqual([UInt8](encoder.makeHistoryMeasurementQueryCommand()), [0x16] + [UInt8](repeating: 0, count: 19)) + } + + /// `iMax` = whole days since the last finished sync: two days ago → start at day 2. + func testStartupStartsAtDaysSinceLastFinishedSync() { + let (engine, writer) = makeEngine(lastSync: daysAgo(2)) + engine.runStartup() + XCTAssertEqual(writer.historyRequests, [[0x10, 2]]) + } + + /// The look-back is capped at 6, like the vendor's. + func testLookBackIsCappedAtSixDays() { + let (engine, writer) = makeEngine(lastSync: daysAgo(30)) + engine.runStartup() + XCTAssertEqual(writer.historyRequests, [[0x10, 6]]) + } + + /// `0x90` ends the steps step → `16` of the same day; `16 FF` → the next-newer day's `10`. + func testMeasurementEndAdvancesToNextDay() { + let (engine, writer) = makeEngine(lastSync: daysAgo(2)) + engine.runStartup() + engine.handle(.jringHistoryFrame(.activityFailed)) + XCTAssertEqual(writer.historyRequests, [[0x10, 2], [0x16, 2]]) + engine.handle(.jringHistoryFrame(.measurementEnd)) + XCTAssertEqual(writer.historyRequests, [[0x10, 2], [0x16, 2], [0x10, 1]]) + } + + /// `0x10`/`0x11` packets have no terminator: 2 s (here 50 ms) without one ends the step. + func testQuietWindowAdvancesStepsToHeartRate() async throws { + let (engine, writer) = makeEngine(lastSync: daysAgo(1), quiet: 0.05) + engine.runStartup() + engine.handle(.jringHistoryFrame(.activity)) + engine.handle(.jringHistoryFrame(.activity)) + XCTAssertEqual(writer.historyRequests, [[0x10, 1]], "still inside the quiet window") + try await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(writer.historyRequests, [[0x10, 1], [0x16, 1]]) + } + + /// The vendor's other `16` terminator: the last segment's last `A0` block (`bad.java:3881`). + func testHeartRateEndsWhenLastSegmentsLastBlockArrives() { + let (engine, writer) = makeEngine(lastSync: daysAgo(1)) + engine.runStartup() + engine.handle(.jringHistoryFrame(.activityFailed)) + engine.handle(.jringHistoryFrame(.measurementHeader(segments: 2))) + engine.handle(.jringHistoryFrame(.measurementSegment(index: 1, blocks: 2))) + engine.handle(.jringHistoryFrame(.measurementBlock(index: 0))) + engine.handle(.jringHistoryFrame(.measurementBlock(index: 1))) // last block, but not the last segment + engine.handle(.jringHistoryFrame(.measurementSegment(index: 2, blocks: 2))) + engine.handle(.jringHistoryFrame(.measurementBlock(index: 0))) + XCTAssertEqual(writer.historyRequests.count, 2) + engine.handle(.jringHistoryFrame(.measurementBlock(index: 1))) + XCTAssertEqual(writer.historyRequests, [[0x10, 1], [0x16, 1], [0x10, 0]]) + // A trailing FF from the finished day must not skip today's steps. + engine.handle(.jringHistoryFrame(.measurementEnd)) + XCTAssertEqual(writer.historyRequests.count, 3) + } + + /// "done" fires exactly once, after day 0's heart rate — not per day — and records the sync point. + func testDoneFiresOnceAfterDayZeroAndRecordsFinish() { + let now = Date() + let (engine, writer) = makeEngine(lastSync: daysAgo(1, from: now), now: now) + engine.runStartup() + finishDay(engine) + XCTAssertEqual(writer.doneCount, 0) + finishDay(engine) + XCTAssertEqual(writer.historyRequests, [[0x10, 1], [0x16, 1], [0x10, 0], [0x16, 0]]) + XCTAssertEqual(writer.doneCount, 1) + XCTAssertEqual(defaults.object(forKey: Self.lastSyncKey) as? Date, now) + + // Stale terminators after the pass change nothing. + finishDay(engine) + XCTAssertEqual(writer.doneCount, 1) + XCTAssertEqual(writer.historyRequests.count, 4) + + // The next connect starts from today. + let (next, nextWriter) = makeEngine(now: now) + next.runStartup() + XCTAssertEqual(nextWriter.historyRequests, [[0x10, 0]]) + } + + /// A step the ring never answers is skipped by the watchdog; the pass still reports "done" but is + /// not recorded as finished, so the next sync re-reads the same days. + func testWatchdogSkipsSilentStepsWithoutRecordingFinish() async throws { + let lastSync = daysAgo(0) + let (engine, writer) = makeEngine(lastSync: lastSync, watchdog: 0.05) + engine.runStartup() + try await Task.sleep(nanoseconds: 500_000_000) + XCTAssertEqual(writer.historyRequests, [[0x10, 0], [0x16, 0]]) + XCTAssertEqual(writer.doneCount, 1) + XCTAssertEqual(defaults.object(forKey: Self.lastSyncKey) as? Date, lastSync) + } + + /// A dropped link cancels the loop: no timer fires into the reconnect gap, and late frames are ignored. + func testDisconnectCancelsLoop() async throws { + let (engine, writer) = makeEngine(lastSync: daysAgo(1), quiet: 0.05, watchdog: 0.05) + engine.runStartup() + engine.handle(.jringHistoryFrame(.activity)) + engine.connectionDidEnd() + try await Task.sleep(nanoseconds: 300_000_000) + engine.handle(.jringHistoryFrame(.activityFailed)) + engine.handle(.jringHistoryFrame(.measurementEnd)) + XCTAssertEqual(writer.historyRequests, [[0x10, 1]]) + XCTAssertEqual(writer.doneCount, 0) + + // The next link starts a fresh pass. + engine.connectionDidStart() + engine.runStartup() + XCTAssertEqual(writer.historyRequests, [[0x10, 1], [0x10, 1]]) + } + + /// A full 7-day pass walks 6 → 0 and never writes an `0x0E` frame (`0E 12 34 12 34 …` is the + /// factory reset; other `0E` frames are OTA / power-off / restart). + func testFullPassWalksSixDaysToTodayAndNeverSends0E() { + let (engine, writer) = makeEngine() + engine.runStartup() + for _ in 0...6 { finishDay(engine) } + engine.syncVitalsHistory() + engine.handle(.jringHistoryFrame(.measurementEnd)) + engine.unbind() + var expected: [[UInt8]] = [] + for day in stride(from: UInt8(6), through: 0, by: -1) { + expected.append([0x10, day]) + expected.append([0x16, day]) + } + expected.append([0x16, 0]) // the post-workout backfill + XCTAssertEqual(writer.historyRequests, expected) + XCTAssertFalse(writer.opcodes.contains(0x0e)) + XCTAssertEqual(writer.doneCount, 2) + } + + /// Post-workout backfill stays today-only `16 00`; its end reports "done" but is not a full sync. + func testVitalsHistoryIsTodayOnlyAndNotAFullSync() { + let lastSync = daysAgo(3) + let (engine, writer) = makeEngine(lastSync: lastSync) + engine.syncVitalsHistory() + XCTAssertEqual(writer.historyRequests, [[0x16, 0]]) + engine.handle(.jringHistoryFrame(.measurementEnd)) + XCTAssertEqual(writer.doneCount, 1) + XCTAssertEqual(defaults.object(forKey: Self.lastSyncKey) as? Date, lastSync) + } + + /// During a full pass the backfill would interleave a second `16`; the pass covers today anyway. + func testVitalsHistoryIsSkippedDuringFullPass() { + let (engine, writer) = makeEngine(lastSync: daysAgo(1)) + engine.runStartup() + engine.syncVitalsHistory() + XCTAssertEqual(writer.historyRequests, [[0x10, 1]]) + } + + /// Forget clears the ring's sync point, so pairing it again re-reads the full week. + func testUnbindClearsSyncPoint() { + let (engine, writer) = makeEngine(lastSync: daysAgo(1)) + engine.unbind() + XCTAssertNil(defaults.object(forKey: Self.lastSyncKey)) + XCTAssertEqual(writer.opcodes, [0x4b]) } /// The 0x19 sent at connect must carry the user's configured cadence, an all-day window, and the diff --git a/PulseLoopTests/RingDecoderTests.swift b/PulseLoopTests/RingDecoderTests.swift index bde6013a..049038fe 100644 --- a/PulseLoopTests/RingDecoderTests.swift +++ b/PulseLoopTests/RingDecoderTests.swift @@ -307,22 +307,64 @@ final class RingDecoderTests: XCTestCase { XCTAssertTrue(events.contains { if case .firmware = $0 { return true } else { return false } }) } - /// 0x16 with the end-of-stream marker (bytes[1] == 0xFF) decodes as `.historySyncFinished`, which - /// the event bridge maps to `.syncProgress("done")` so the coordinator's endSync fires. - func testHistoryMeasurementFinishedMarker() { - let event = decode("16ff000000000000000000000000000000000000") - guard case .historySyncFinished = event else { - return XCTFail("expected historySyncFinished, got \(event.kind)") - } + /// 0x16 `FF` ends one *day's* heart-rate transfer — a marker for `JringSyncEngine`'s day loop, not + /// "sync finished" (the engine reports that itself after day 0). + func testHistoryMeasurementEndIsADayMarker() { + XCTAssertEqual(historyFrame(decode("16ff000000000000000000000000000000000000")), .measurementEnd) + } + + /// `F0` header: segment count u16 LE at [6..7]; `AA` segment: number at [2], block count u16 LE at + /// [7..8] (vendor `a1`/`b1`/`c1`, `bad.java:3869-3874`). + func testHistoryMeasurementHeaderAndSegmentMarkers() { + XCTAssertEqual(historyFrame(decode("16f0000000000201000000000000000000000000")), .measurementHeader(segments: 0x0102)) + XCTAssertEqual(historyFrame(decode("16aa030000000004000000000000000000000000")), .measurementSegment(index: 3, blocks: 4)) + } + + /// `0x90` / `0x96` are the ring refusing `10 dd` / `16 dd`; both end the step. + func testHistoryFailureMarkers() { + XCTAssertEqual(historyFrame(decode("9000000000000000000000000000000000000000")), .activityFailed) + XCTAssertEqual(historyFrame(decode("9600000000000000000000000000000000000000")), .measurementFailed) } - /// A non-0xFF 0x16 sub-type (e.g. the 0xF0 header marker) still decodes as a plain ack, unchanged. - func testHistoryMeasurementHeaderStillAcks() { - let event = decode("16f0000000000000000000000000000000000000") - guard case let .commandAck(commandId) = event else { - return XCTFail("expected commandAck, got \(event.kind)") + /// 0x10 step history: LE u32 ring time at [1..4], fifteen one-minute step counts at [5..19] + /// (`bad.java:3810-3820`) → one quarter-hour bucket, then the quiet-window marker. + func testStepHistoryPacketDecodesToBucketAndMarker() { + let base: UInt32 = 1_700_000_000 + var bytes = [UInt8](repeating: 0, count: 20) + bytes[0] = 0x10 + bytes[1] = UInt8(base & 0xff); bytes[2] = UInt8((base >> 8) & 0xff) + bytes[3] = UInt8((base >> 16) & 0xff); bytes[4] = UInt8((base >> 24) & 0xff) + for i in 5..<20 { bytes[i] = UInt8(i) } // 5 + 6 + … + 19 = 180 steps + let events = decoder.decodeAll(Data(bytes)) + XCTAssertEqual(events.count, 2) + guard case let .activityBucket(ts, steps, distance) = events.first else { + return XCTFail("expected activityBucket, got \(events.first?.kind ?? "nil")") } - XCTAssertEqual(commandId, 0x16) + XCTAssertEqual(ts.timeIntervalSince1970, TimeInterval(base)) + XCTAssertEqual(steps, 180) + XCTAssertEqual(distance, 0, "0x10 has no distance channel") + XCTAssertEqual(historyFrame(events[1]), .activity) + } + + /// An all-zero minute block adds nothing, but still restarts the quiet window. + func testEmptyStepHistoryPacketIsMarkerOnly() { + let events = decoder.decodeAll((try? Data(hexString: "1000f15365000000000000000000000000000000")) ?? Data()) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(historyFrame(events[0]), .activity) + } + + /// Sleep history (0x11) keeps its timeline event and gains the same marker. + func testSleepHistoryPacketAlsoEmitsMarker() { + let events = decoder.decodeAll((try? Data(hexString: "1100f15365282828282828282828282828282828")) ?? Data()) + XCTAssertEqual(events.count, 2) + guard case .sleepTimeline = events[0] else { return XCTFail("expected sleepTimeline") } + XCTAssertEqual(historyFrame(events[1]), .activity) + } + + private func historyFrame(_ event: RingDecodedEvent) -> JringHistoryFrame? { + if case let .jringHistoryFrame(frame) = event { return frame } + XCTFail("expected jringHistoryFrame, got \(event.kind)") + return nil } /// 0x16 data block (sub-type 0xA0): base ts at [2..5], then two 6-sample HR blocks → two averages @@ -343,4 +385,11 @@ final class RingDecoderTests: XCTestCase { XCTAssertEqual(hr[1].0, 60) XCTAssertEqual(hr[1].1.timeIntervalSince1970 - hr[0].1.timeIntervalSince1970, 60, accuracy: 0.5) } + + /// Each `A0` block ends with its index (u16 LE at [6..7]) so the engine can end by count. + func testHistoryHeartRateBlockCarriesIndexMarker() { + let events = decoder.decodeAll((try? Data(hexString: "16a000f153650500000000000000000000000000")) ?? Data()) + XCTAssertEqual(events.count, 1, "no non-zero samples → just the marker") + XCTAssertEqual(historyFrame(events[0]), .measurementBlock(index: 5)) + } } diff --git a/docs/hardware/jring.md b/docs/hardware/jring.md index 4949d1d0..b38f9274 100644 --- a/docs/hardware/jring.md +++ b/docs/hardware/jring.md @@ -115,6 +115,43 @@ teardown and how the packet formats were decoded. - Flashing: Renesas SUOTA (`0xFEF5` service) to the DA14531. - **All firmware traffic is plain HTTP with no token or signature — integrity is an MD5 only.** +## History sync + +PulseLoop pulls history the way the vendor app (`com.jaga.ibraceletplus.jyring`) +does: **one day at a time**, oldest first. + +| Request | Answer | Transfer ends on | +|---|---|---| +| `10 dd` — steps + sleep | `0x10` steps / `0x11` sleep packets: LE u32 time at [1..4], then 15 one-minute values | 2 s with no new packet, or `0x90` (refused) | +| `16 dd` — heart rate | `F0` header (segment count), `AA` segment, `A0` data blocks | `FF`, the last segment's last `A0` block, or `0x96` (refused) | + +`dd` is 0 = today … 6. The frames are 20 bytes, zero padded. + +- **How far back.** On connect, the sync starts at the number of calendar days + since this ring's last *finished* sync, capped at 6. A ring never synced here + gets all 6 days. This matches the vendor's `DupMainActivity.autoSync`. +- **Chaining.** Each day runs `10 dd` → `16 dd`, then moves to the next-newer + day. After day 0 the app shows "done" — once per sync, not once per day. +- **Sync point.** The time the sync *started* is saved per ring, but only when + every step ended on the ring's own terminator. If the ring goes silent for + 10 s, that step is skipped and the next sync re-reads the same days. Forgetting + the ring clears its sync point. +- **No 300 s gap.** The vendor skips a sync that starts within 300 s of the last + one. PulseLoop always reads at least today, because a manual sync must finish + with "done". Re-reading a day is idempotent. +- **Steps** come down as one 15-minute bucket per `0x10` packet. Buckets are + upserted by start time and summed per day. The packet has no distance, so a + day's distance comes only from the live `0x03` total. +- **Post-workout** backfill sends only today's `16 00`. +- **Not pulled yet:** temperature (`39 dd`), offline SpO₂ (`40 dd`), pressure + (`55 dd`) and multi-sport (`25 dd`). The vendor sends these only on rings whose + `0x20` capability reply lists them, and PulseLoop has no decoders for their + replies yet. + +!!! danger "Never send `0E 12 34 12 34 12 34 12 34`" + That frame is a factory reset. Other `0E` frames are OTA, power-off and + restart. PulseLoop sends no `0x0E` frame at all. + ## Background behavior - Idle timeout: ~20 seconds of inactivity