Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions PulseLoop/Diagnostics/DiagnosticsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import UIKit
#endif

/// Builds a shareable diagnostics bundle (JSON): app/OS/device info + the recent `WearableLog`
/// timeline. Raw BLE packets (`RawPacketRow`) are included only in DEBUG builds, so release exports
/// never leak protocol bytes.
/// timeline. Raw BLE packets (`RawPacketRow`) ride along only while `RawPacketCapture.isEnabled` —
/// always in DEBUG builds, and in release only under the user's explicit Privacy & Data opt-in
/// (the remote-tester workflow for rings nobody on the project has in hand).
@MainActor
enum DiagnosticsExporter {
/// Serialize a diagnostics report to pretty-printed JSON.
Expand All @@ -16,9 +17,9 @@ enum DiagnosticsExporter {
root["app"] = appInfo()
root["device"] = deviceInfo(context: context)
root["logs"] = recentLogs(context: context, limit: maxLogs)
#if DEBUG
root["rawPackets"] = recentPackets(context: context, limit: 200)
#endif
if RawPacketCapture.isEnabled {
root["rawPackets"] = recentPackets(context: context, limit: 200)
}

guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]),
let json = String(data: data, encoding: .utf8) else {
Expand Down
29 changes: 29 additions & 0 deletions PulseLoop/Diagnostics/RawPacketCapture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// The switch that decides whether raw BLE packets (`RawPacketRow`) are persisted and exported.
///
/// DEBUG builds always capture — the packet feed is the daily protocol-debugging tool. Release
/// builds capture **only while the user has switched it on** in Privacy & Data → Diagnostics. The
/// release path exists for exactly one workflow: a remote tester on TestFlight pairing a ring
/// family nobody on the project has in hand (RWfit is the first), where the diagnostics export's
/// hex rows are the only way to see what the ring actually said. Raw packets encode health
/// readings, so the toggle is off by default, visibly labelled, and the captured rows can be
/// cleared from the same screen.
enum RawPacketCapture {
static let defaultsKey = "diagnostics.captureRawPackets"

/// Whether the persistence subscriber should store packets right now.
static var isEnabled: Bool {
#if DEBUG
return true
#else
return userOptedIn
#endif
}

/// The release-build opt-in, as shown by the Privacy & Data toggle.
static var userOptedIn: Bool {
get { UserDefaults.standard.bool(forKey: defaultsKey) }
set { UserDefaults.standard.set(newValue, forKey: defaultsKey) }
}
}
21 changes: 11 additions & 10 deletions PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,12 @@ final class EventPersistenceSubscriber {
private let context: ModelContext
private var task: Task<Void, Never>?

#if DEBUG
/// Rolling cap for the DEBUG-only raw-packet trace, and how often we prune (every Nth insert,
/// so we don't pay a fetch on every packet during a sync burst).
/// Rolling cap for the raw-packet trace, and how often we prune (every Nth insert, so we don't
/// pay a fetch on every packet during a sync burst). DEBUG builds always capture; release
/// builds only when the user opts in (see `RawPacketCapture`).
private let rawPacketCap = 2_000
private let rawPacketPruneInterval = 200
private var rawPacketInsertsSincePrune = 0
#endif

/// Coalesced-save state. During a sync the ring streams hundreds of events; saving per event
/// woke every `@Query` hundreds of times (the re-render storm). Instead we insert/mutate without
Expand Down Expand Up @@ -239,9 +238,12 @@ final class EventPersistenceSubscriber {
context.insert(device)
recordBatterySample(percent)
case let .rawPacket(direction, data, decoded):
// The raw byte trace is a developer diagnostic only — never stored in release builds, so
// production never persists protocol hex/opcodes.
#if DEBUG
// The raw byte trace is a developer diagnostic. DEBUG builds always keep it; release
// builds keep it only while the user has explicitly enabled capture in Privacy & Data —
// the toggle exists so a remote tester on TestFlight can hand back protocol bytes from a
// ring family we've never had in hand (raw packets encode health data, hence opt-in,
// off by default and clearable).
guard RawPacketCapture.isEnabled else { return }
context.insert(
RawPacketRow(
direction: direction,
Expand All @@ -252,14 +254,13 @@ final class EventPersistenceSubscriber {
confidence: decoded.confidence
)
)
// Keep the debug trace a rolling window so it can't grow without bound. Prune only
// every Nth insert to avoid a fetch on every packet during a sync burst.
// Keep the trace a rolling window so it can't grow without bound. Prune only every Nth
// insert to avoid a fetch on every packet during a sync burst.
rawPacketInsertsSincePrune += 1
if rawPacketInsertsSincePrune >= rawPacketPruneInterval {
rawPacketInsertsSincePrune = 0
DebugRepository.pruneRawPackets(maxRows: rawPacketCap, context: context)
}
#endif
case let .derivedUpdate(kind, entityType, entityId, payloadJSON):
context.insert(DerivedUpdateRow(kind: kind, entityType: entityType, entityId: entityId, payloadJSON: payloadJSON))
case let .activityUpdate(timestamp, steps, distanceMeters, calories):
Expand Down
141 changes: 141 additions & 0 deletions PulseLoop/RingProtocol/RWfitCommandGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Foundation

/// The RWfit protocol-level command queue: **one outstanding command at a time**, released by the
/// device's transport ACK (legacy `0xFE` matching our serial+cmd; JieLi flag-0x11 matching our
/// triple) or by a timeout after one retry. Mirrors `x5/d.java` / `x5/c.java`'s LinkedList queues,
/// including their inter-command spacing (100 ms legacy / 230 ms JieLi).
///
/// Lives behind the driver (which owns the codecs and sees the ACKs); the sync engine and history
/// pager submit logical `RWfitOutbound` commands and never see wire bytes. Our own outbound ACK
/// frames deliberately bypass this queue — they expect no reply, and delaying one stalls the ring's
/// retransmit loop (the vendor's `b3 == -1` fast path).
@MainActor
final class RWfitCommandGate {
nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes)

private weak var writer: RingCommandWriter?
private let legacyCodec: RWfitLegacyCodec
private let jlCodec: RWfitJLCodec

/// Response timeout per attempt; the vendor allows 2 retries at a shorter spacing, but
/// `RingBLEClient`'s own 4 s GATT write-ACK timeout already covers the transport layer, so one
/// protocol retry is enough to survive a dropped notification.
private let responseTimeout: TimeInterval
private let legacySpacing: TimeInterval = 0.1
private let jieliSpacing: TimeInterval = 0.23

/// The framing every submitted command is framed with. Set by the driver at service discovery,
/// before anything can be submitted (`runStartup` runs after `.connected`).
var framing: RWfitFraming = .legacy

private var queue: [RWfitOutbound] = []
private var inFlight: RWfitOutbound?
private var inFlightSerial = 0
private var retried = false
private var timeoutTask: Task<Void, Never>?
private var spacingTask: Task<Void, Never>?

init(
writer: RingCommandWriter?,
legacyCodec: RWfitLegacyCodec,
jlCodec: RWfitJLCodec,
responseTimeout: TimeInterval = 2
) {
self.writer = writer
self.legacyCodec = legacyCodec
self.jlCodec = jlCodec
self.responseTimeout = responseTimeout
}

var isIdle: Bool { inFlight == nil && queue.isEmpty }

/// Enqueue a logical command; sends immediately when the channel is free.
func submit(_ command: RWfitOutbound) {
queue.append(command)
pump()
}

/// Drop everything (disconnect/teardown). In-flight state must not survive into the next link —
/// its serial would never match and would wedge the queue.
func cancel() {
timeoutTask?.cancel(); timeoutTask = nil
spacingTask?.cancel(); spacingTask = nil
queue.removeAll()
inFlight = nil
retried = false
}

// MARK: - ACKs from the device (driver calls these from `ingest`)

/// Legacy `0xFE`: release when serial and cmd match the in-flight command (`x5/d.java i()`).
func noteLegacyAck(cmd: UInt8, serial: Int) {
guard case let .legacy(inCmd, _)? = inFlight, inCmd == cmd, serial == inFlightSerial else { return }
release()
}

/// JieLi flag-0x11: release when the echoed triple matches (`x5/c.java f()`).
func noteJieliAck(triple: RWfitJLTriple) {
guard case let .jieli(payload)? = inFlight, payload.count >= 3,
payload[0] == triple.cmd, payload[1] == triple.key, payload[2] == triple.keyFlag
else { return }
release()
}

// MARK: - Pump

private func pump() {
guard inFlight == nil, spacingTask == nil, !queue.isEmpty else { return }
let command = queue.removeFirst()
inFlight = command
retried = false
send(command)
}

private func send(_ command: RWfitOutbound) {
switch command {
case let .legacy(cmd, payload):
let encoded = legacyCodec.encode(cmd: cmd, payload: payload)
inFlightSerial = encoded.serial
writer?.enqueue(encoded.frame)
case let .jieli(payload):
writer?.enqueue(jlCodec.encode(payload: payload))
}
armTimeout()
}

private func armTimeout() {
timeoutTask?.cancel()
timeoutTask = Task { [weak self] in
let nanos = UInt64((self?.responseTimeout ?? 2) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
guard !Task.isCancelled, let self else { return }
self.timedOut()
}
}

private func timedOut() {
guard let command = inFlight else { return }
if retried {
// Two silent attempts: drop it and move on — wedging the queue on one lost command
// starves everything behind it (the vendor does the same after its retry budget).
release()
} else {
retried = true
send(command)
}
}

private func release() {
timeoutTask?.cancel(); timeoutTask = nil
inFlight = nil
// Inter-command spacing: the firmware drops back-to-back commands (the vendor paces at
// 100/230 ms), so the next send waits out the gap.
let spacing = framing == .jieli ? jieliSpacing : legacySpacing
spacingTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(spacing * 1_000_000_000))
guard !Task.isCancelled, let self else { return }
self.spacingTask = nil
self.pump()
}
}
}
70 changes: 70 additions & 0 deletions PulseLoop/RingProtocol/RWfitCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
@preconcurrency import CoreBluetooth

/// Coordinator for the RWfit family (`com.rw.revivalfit` — rings sold under assorted brands,
/// including "Colmi"-labelled units that share nothing with the Colmi protocol). This file is the
/// whole of what makes an RWfit ring an RWfit ring: its advertised identity and its capability set;
/// the two-framings problem lives entirely in `RWfitDriver`.
///
/// Recognition is by **strong, family-exclusive signals** — exactly the ones the vendor's own
/// scanner keys on (`r5/d.java:70-134`):
/// - the advertised `A00A` service (the vendor's `pidType 1` pattern `02 01 06 03 03 0a a0` is
/// Flags + a 16-bit service list containing `0xA00A`, which CoreBluetooth surfaces as a service
/// UUID), or
/// - manufacturer data opening with company ID `0x05D6` (`d6 05 02 00` / `d6 05 41 54` "AT") or
/// `0x06D6` (`d6 06 02 00`, the "T-Ring" line).
///
/// **No name matching, on purpose**: the one field a rebrander always changes is the name — the
/// known unit was bought as a "Colmi" — and the catalog card's `advertisedNamePatterns` is empty
/// until a diagnostics export shows what these rings actually call themselves.
@MainActor
final class RWfitCoordinator: WearableCoordinator {
nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes)

static let deviceType: RingDeviceType = .rwfit

/// Manufacturer-data prefixes: little-endian company ID + the vendor's fixed lead-in bytes.
static let manufacturerHexPrefixes = ["d6050200", "d6054154", "d6060200"]

static func matches(name: String?, advertisement: AdvertisementInfo) -> Bool {
if advertisesService(advertisement) { return true }
if let mfg = advertisement.manufacturerData {
let hex = mfg.hexString
if manufacturerHexPrefixes.contains(where: hex.hasPrefix) { return true }
}
return false
}

/// True when the advertisement carries the `A00A` service, 16-bit or 128-bit form.
private static func advertisesService(_ advertisement: AdvertisementInfo) -> Bool {
advertisement.serviceUUIDs.contains { uuid in
let value = uuid.uuidString.uppercased()
return value == "A00A" || value == "0000A00A-0000-1000-8000-00805F9B34FB"
}
}

/// The baseline: what **every** RWfit ring's firmware serves regardless of framing — the
/// history streams both wire protocols define unconditionally, plus in-band battery. REM is in:
/// both sleep formats carry a REM stage (legacy type 3, JieLi model 4).
let capabilities: Set<WearableCapability> = [
.heartRate, .spo2, .steps, .sleep, .remSleep, .battery,
]

/// Everything per-unit, granted only when the connected ring claims it:
/// - sensor streams from the legacy `0x03` feature bitmap / the JieLi bind-reply TLV
/// (temperature, BP, HRV, stress, blood sugar);
/// - the manual/realtime measurement set, granted by the driver on JieLi links — the vendor app
/// has no legacy on-demand measurement command at all, so a legacy link must not render
/// measure buttons that could only ever time out.
let bitmapGatedCapabilities: Set<WearableCapability> = [
.temperature, .bloodPressure, .manualBloodPressure,
.hrv, .manualHrv, .stress, .bloodSugar,
.realtimeHeartRate, .manualHeartRate, .manualSpo2,
]

let iconSystemName = "circle.circle.fill"

func makeDriver(writer: RingCommandWriter) -> WearableDriver {
RWfitDriver(writer: writer)
}
}
Loading
Loading