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
5 changes: 5 additions & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
case settingsActivityTracking
case settingsGoals
case settingsVitals
case settingsCalibration
case settingsPrivacyData
case settingsAbout
case pairing
Expand All @@ -28,9 +29,9 @@
case activity = "Activity"
case sleep = "Sleep"
case coach = "Coach"

Check warning on line 32 in PulseLoop/App/AppTheme.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
var id: String { rawValue }

Check warning on line 34 in PulseLoop/App/AppTheme.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
var symbol: String {
switch self {
case .today: return "circle.circle"
Expand Down Expand Up @@ -69,6 +70,10 @@
static let stress = Color(hex: "#FF8A4C")
static let hrv = Color(hex: "#9D7CFF")
static let temperature = Color(hex: "#2DD4D8")
// jring/56ff metrics
static let bloodPressure = Color(hex: "#FF6B9D")
static let bloodSugar = Color(hex: "#FFB84D")
static let fatigue = Color(hex: "#C77DFF")
static let borderSubtle = Color.white.opacity(0.08)
static let borderStrong = Color.white.opacity(0.16)
}
Expand All @@ -78,7 +83,7 @@
let trimmed = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var value: UInt64 = 0
Scanner(string: trimmed).scanHexInt64(&value)

Check warning on line 86 in PulseLoop/App/AppTheme.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
let red: UInt64
let green: UInt64
let blue: UInt64
Expand Down
22 changes: 22 additions & 0 deletions PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ enum PulseEvent: Sendable {
case stressSample(value: Int, timestamp: Date)
case hrvSample(value: Int, timestamp: Date)
case temperatureSample(celsius: Double, timestamp: Date)
// Extra metrics from the jring/56ff 0x24 combined-sensor packet.
case bloodPressureSample(systolic: Int, diastolic: Int, timestamp: Date)
case fatigueSample(value: Int, timestamp: Date)
case bloodSugarSample(mgdl: Double, timestamp: Date)
/// Firmware version string parsed from the ring's status/firmware payload; persisted on the Device.
case firmwareVersion(String)
/// Friendly history-sync progress for the product UI (e.g. "Syncing sleep…"). Never protocol terms.
case syncProgress(stage: String)
case workoutStarted(UUID)
Expand Down Expand Up @@ -241,6 +247,16 @@ final class EventPersistenceSubscriber {
persistMeasurement(kind: .hrv, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "hrv_sample")
case let .temperatureSample(celsius, timestamp):
persistMeasurement(kind: .temperature, value: celsius, timestamp: timestamp, source: .colmi, kindLabel: "temperature_sample")
case let .bloodPressureSample(systolic, diastolic, timestamp):
// BP is two metrics in one packet β€” store as two rows so each trends independently.
persistMeasurement(kind: .bloodPressureSystolic, value: Double(systolic), timestamp: timestamp, source: .live, kindLabel: "bp_systolic_sample")
persistMeasurement(kind: .bloodPressureDiastolic, value: Double(diastolic), timestamp: timestamp, source: .live, kindLabel: "bp_diastolic_sample")
case let .fatigueSample(value, timestamp):
persistMeasurement(kind: .fatigue, value: Double(value), timestamp: timestamp, source: .live, kindLabel: "fatigue_sample")
case let .bloodSugarSample(mgdl, timestamp):
persistMeasurement(kind: .bloodSugar, value: mgdl, timestamp: timestamp, source: .live, kindLabel: "blood_sugar_sample")
case let .firmwareVersion(version):
persistFirmwareVersion(version)
case let .sleepTimeline(timestamp, stages):
persistSleepTimeline(start: timestamp, stages: stages)
case let .gpsPoint(sessionId, latitude, longitude, altitude, horizontalAccuracy, speed, course, accepted, rejectionReason, timestamp):
Expand Down Expand Up @@ -287,6 +303,12 @@ final class EventPersistenceSubscriber {
)
}

/// Record the ring's firmware version on the current Device row (idempotent β€” no-op if unchanged).
private func persistFirmwareVersion(_ version: String) {
guard let device = DeviceRepository.current(context: context), device.firmwareVersion != version else { return }
device.firmwareVersion = version
}

/// Upsert a sleep session by night, appending this packet's per-minute stage blocks and
/// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per
/// night, so blocks must accumulate into one session rather than spawning a session per
Expand Down
8 changes: 8 additions & 0 deletions PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ enum MeasurementKind: String, Codable, CaseIterable {
case stress
case hrv
case temperature = "temp"
// jring/56ff metrics from the 0x24 combined-sensor packet. Append only β€” raw values persisted.
case bloodPressureSystolic = "bp_sys"
case bloodPressureDiastolic = "bp_dia"
case fatigue
case bloodSugar = "glucose"

/// Display unit for a measurement of this kind.
var unit: String {
Expand All @@ -27,6 +32,9 @@ enum MeasurementKind: String, Codable, CaseIterable {
case .stress: return ""
case .hrv: return "ms"
case .temperature: return "Β°C"
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
case .fatigue: return ""
case .bloodSugar: return "mg/dL"
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions PulseLoop/PulseLoopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ struct PulseLoopApp: App {
// One-time cleanup of activity totals inflated by the old accumulator bug.
ActivityService.migrateInflatedActivityIfNeeded(context: container.mainContext)

// A persisted "connected" state can't survive a restart β€” the live link is gone. Reset it so
// the UI never shows a false "Connected" until a real connection re-confirms.
if !runningTests {
DeviceRepository.resetStaleConnectionState(context: container.mainContext)
}

// Don't bring up CoreBluetooth under tests (see `isRunningUnitTests`).
let client = RingBLEClient(startManager: !runningTests)
let coordinator = RingSyncCoordinator(client: client, context: container.mainContext)
Expand Down Expand Up @@ -103,6 +109,12 @@ struct PulseLoopApp: App {
// batch isn't lost.
if phase != .active { persistence.flush() }
guard phase == .active else { return }
// Foreground reconnect: the OS can silently tear down the BLE link while suspended without
// delivering a disconnect, leaving us "connected" but dead. On every resume, re-link the
// last-known ring if it isn't actually connected. (Android foreground-reconnect parity.)
if bleClient.hasLastKnownRing, bleClient.state != .connected {
bleClient.connectLastKnown()
}
// Both calls are no-ops when the AI Coach master switch is off β€” the
// scheduler gates on `coachMasterEnabled`, and `runDueSlot` short
// -circuits via the feature-flags gate.
Expand Down
2 changes: 2 additions & 0 deletions PulseLoop/RingProtocol/JringCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ final class JringCoordinator: WearableCoordinator {
.heartRate, .spo2, .steps, .sleep, .battery,
.manualHeartRate, .manualSpo2, // jring supports on-demand HR + SpO2 spot readings
.realtimeHeartRate, .findDevice,
// 56ff combined-sensor metrics decoded from the 0x24 packet.
.bloodPressure, .bloodSugar, .fatigue, .stress, .hrv,
]

let iconSystemName = "circle.hexagongrid.circle.fill"
Expand Down
2 changes: 1 addition & 1 deletion PulseLoop/RingProtocol/JringDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ final class JringDriver: WearableDriver {
func frame(_ command: Data) -> Data { command } // already 20 bytes, no checksum

func ingest(_ data: Data, from characteristic: CBUUID) -> [RingDecodedEvent] {
[decoder.decode(data)]
decoder.decodeAll(data)
}

func makeSyncEngine() -> RingSyncEngine {
Expand Down
74 changes: 71 additions & 3 deletions PulseLoop/RingProtocol/JringSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,90 @@ final class JringSyncEngine: RingSyncEngine {
private weak var writer: RingCommandWriter?
private let encoder = RingEncoder()

/// The user's profile for the ring's 0x02 user-info command. `nil` β‡’ send neutral defaults so the
/// ring's blood-sugar/calorie algorithms still have something to work with.
private var userProfile: UserProfileValues?
/// Reference BP calibration (mmHg). 0 β‡’ not calibrated, so we skip the 0x33 push.
private var bpReferenceSystolic = 0
private var bpReferenceDiastolic = 0

init(writer: RingCommandWriter?) {
self.writer = writer
}

func runStartup() {
// Canonical startup: status β†’ time sync β†’ locale β†’ activity query β†’ history β†’ history measurements.
// Claim the ring first (0x48) so it streams to us even if another app held it, then push the
// user profile (0x02) + any BP calibration (0x33) the ring needs for its on-device algorithms,
// then the canonical status β†’ time β†’ locale β†’ activity β†’ history flow.
writer?.enqueue(encoder.makeAppIdentifierCommand())
writer?.enqueue(Data(userInfoCommand()))
if bpReferenceSystolic > 0, bpReferenceDiastolic > 0 {
writer?.enqueue(encoder.makeBPAdjustCommand(systolic: bpReferenceSystolic, diastolic: bpReferenceDiastolic))
}
writer?.enqueue(encoder.makeStatusCommand())
writer?.enqueue(encoder.makeTimeSyncCommand())
writer?.enqueue(encoder.makeLocaleCommand())
writer?.enqueue(encoder.makeActivityQueryCommand())
writer?.enqueue(encoder.makeHistoryQueryCommand())
writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand())
}

func handle(_ event: RingDecodedEvent) {
// Fire-and-forget protocol β€” nothing to advance.
// Bind handshake is the one response-driven flow: the ring may initiate a claim on connect.
if case let .bind(action, _) = event { respondToBind(action: action) }
}

// MARK: - User profile (0x02)

func setUserProfile(_ profile: UserProfileValues) {
userProfile = profile
}

func applyUserProfile(_ profile: UserProfileValues) {
userProfile = profile
writer?.enqueue(Data(userInfoCommand()))
}

/// Build the 0x02 command from the stored profile, falling back to the legacy hardcoded defaults
/// (age 25, male, 184 cm, 90 kg) when no profile has been pushed yet.
private func userInfoCommand() -> [UInt8] {
guard let p = userProfile else {
return [UInt8](encoder.makeUserInfoCommand(age: 25, isMale: true, heightCm: 184, weightKg: 90))
}
// jring encodes sex as a high bit on the age byte; `UserProfileValues.gender == 0x01` β‡’ male.
return [UInt8](encoder.makeUserInfoCommand(
age: Int(p.age), isMale: p.gender == 0x01, heightCm: Int(p.heightCm), weightKg: Int(p.weightKg)
))
}

// MARK: - Blood-pressure calibration (0x33)

func setBloodPressureCalibration(systolic: Int, diastolic: Int) {
bpReferenceSystolic = systolic
bpReferenceDiastolic = diastolic
}

func applyBloodPressureCalibration(systolic: Int, diastolic: Int) {
bpReferenceSystolic = systolic
bpReferenceDiastolic = diastolic
guard systolic > 0, diastolic > 0 else { return }
writer?.enqueue(encoder.makeBPAdjustCommand(systolic: systolic, diastolic: diastolic))
}

// MARK: - Bind handshake (0x4B)

/// Respond to the ring-driven bind handshake: INIT(0) β†’ reply APP_START(1); ACK(2) β†’ reply
/// SUCCESS(4). Other actions (cancel/unbond) need no app reply here.
private func respondToBind(action: UInt8) {
switch action {
case 0: writer?.enqueue(encoder.makeBindCommand(action: 1)) // INIT β†’ APP_START
case 2: writer?.enqueue(encoder.makeBindCommand(action: 4)) // ACK β†’ SUCCESS
default: break
}
}

/// Release the ring on Forget: send UNBOND (0x4B action 5) so the ring re-advertises for other apps.
func unbind() {
writer?.enqueue(encoder.makeBindCommand(action: 5))
}

func startHeartRate() {
Expand Down
Loading
Loading