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
4 changes: 4 additions & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import SwiftUI
enum AppRoute: Hashable {
case activityDetail(UUID)
case metricDetail(MetricKind)
/// The HRV panel — SDNN, RMSSD, pNN50, LF/HF. Reached only from the HRV detail screen, and only
/// on rings that declare `.hrvDetail`, so it stays two taps off the dashboard rather than adding
/// six cards to it.
case autonomicDetail
case activityTrends
case recordSelect
case logPastActivity
Expand Down
10 changes: 10 additions & 0 deletions PulseLoop/Health/HealthKitTypeMappings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ enum HealthKitTypeMappings {
// HealthKit has both (`respiratoryRate`, `vo2Max`), but exporting them needs new share
// types plus their own per-type toggles to keep the sync opt-in per metric. Follow-up.
return nil
case .rmssd, .pnn50, .lfPower, .hfPower, .lfHfRatio:
// HealthKit models HRV as a single `heartRateVariabilitySDNN` type and has nothing for
// RMSSD, pNN50 or spectral power. There is no honest destination for these.
return nil
case .sdnn:
// The one that *could* map — `heartRateVariabilitySDNN` — is already occupied by the
// ring's `hrv` scalar. Whether that scalar simply *is* the ring's SDNN is unverified, so
// exporting both would either double-report the same measurement or silently disagree
// with itself. Left unmapped until a hardware session settles which is which.
return nil
}
}

Expand Down
31 changes: 31 additions & 0 deletions PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ enum MeasurementKind: String, Codable, CaseIterable {
// body-data record (byte 16). Append only — raw values persisted.
case respiratoryRate = "resp_rate"
case vo2max
// The YCBT body-data record's HRV panel (`05 33`, `DataUnpack` case 51) — standard time- and
// frequency-domain measures the ring computes and reports alongside the single `hrv` scalar.
// Only the YCBT families produce these; see `WearableCapability.hrvDetail`. Append only.
case sdnn
case rmssd
case pnn50
case lfPower = "lf_power"
case hfPower = "hf_power"
case lfHfRatio = "lf_hf_ratio"

/// The HRV panel: everything derived from beat-to-beat variability beyond the `hrv` scalar.
/// Grouped so the UI can render them together without restating the list at each call site.
static let autonomicKinds: [MeasurementKind] = [.rmssd, .sdnn, .pnn50, .lfPower, .hfPower, .lfHfRatio]

/// Display unit for a measurement of this kind.
var unit: String {
Expand All @@ -41,6 +54,24 @@ enum MeasurementKind: String, Codable, CaseIterable {
case .bloodSugar: return "mg/dL"
case .respiratoryRate: return "brpm" // breaths per minute
case .vo2max: return "mL/kg/min"
case .sdnn, .rmssd: return "ms"
case .pnn50: return "%"
case .lfPower, .hfPower: return "ms²"
case .lfHfRatio: return "" // a ratio of two powers — dimensionless
}
}

/// Short human label. Only the HRV-panel kinds need one today: the rest are titled by
/// `MetricKind`, which these deliberately do not join (they are never dashboard cards).
var shortTitle: String {
switch self {
case .sdnn: return "SDNN"
case .rmssd: return "RMSSD"
case .pnn50: return "pNN50"
case .lfPower: return "LF power"
case .hfPower: return "HF power"
case .lfHfRatio: return "LF/HF"
default: return rawValue
}
}
}
Expand Down
21 changes: 20 additions & 1 deletion PulseLoop/Persistence/SeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ enum SeedData {
state: .connected,
capabilities: [
.heartRate, .spo2, .steps, .sleep, .battery, .remSleep,
.stress, .hrv, .temperature, .bloodPressure, .bloodSugar, .fatigue
.stress, .hrv, .temperature, .bloodPressure, .bloodSugar, .fatigue,
// The demo ring is a maximal one, so `.hrvDetail` is declared here too — otherwise
// the Autonomic screen would be unreachable without YCBT hardware to hand.
.hrvDetail
]
))

Expand Down Expand Up @@ -280,6 +283,22 @@ enum SeedData {
if day == -18 { hrv = 92 } // spike → above baseline
add(.hrv, hrv.rounded(), hrvTs)

// The HRV panel — same overnight instant as the HRV scalar, since on a real ring they come
// out of the same body-data record. RMSSD and pNN50 track HRV (they measure the same
// parasympathetic activity), so they move with it rather than independently; LF/HF is
// derived from the two powers exactly as the decoder derives it.
let rmssd = Swift.max(8, hrv * 0.85 + sin(phase * 0.7) * 6)
let sdnn = Swift.max(12, hrv * 1.25 + sin(phase * 0.35) * 9)
let pnn50 = Swift.max(1, Swift.min(60, rmssd * 0.4 + sin(phase * 0.9) * 4))
let lf = 900 + sin(phase * 0.4) * 320
let hf = Swift.max(120, rmssd * 14 + sin(phase * 0.8) * 140)
add(.rmssd, rmssd.rounded(), hrvTs)
add(.sdnn, sdnn.rounded(), hrvTs)
add(.pnn50, pnn50.rounded(), hrvTs)
add(.lfPower, lf.rounded(), hrvTs)
add(.hfPower, hf.rounded(), hrvTs)
add(.lfHfRatio, lf / hf, hrvTs)

// Blood pressure — 1 pair/day (morning). Normal→Stage 2; a couple of high days show red.
let bpTs = calendar.date(bySettingHour: 8, minute: 0, second: 0, of: dayStart) ?? dayStart
var sys = 116 + sin(phase * 0.45) * 10
Expand Down
3 changes: 3 additions & 0 deletions PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator {
let bitmapGatedCapabilities: Set<WearableCapability> = [
.temperature, .bloodPressure, .stress, .bloodSugar, .manualBloodPressure,
.hrv, .manualHrv,
// The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress — all of them
// are fields of the one `05 33` body-data record that bit gates.
.hrvDetail,
]

let iconSystemName = "circle.circle.fill"
Expand Down
18 changes: 18 additions & 0 deletions PulseLoop/RingProtocol/RingEventBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ enum RingEventBridge {
static let respiratoryRateRange: ClosedRange<Int> = 4...60
/// Plausible VO₂max, in mL/kg/min (sedentary floor to elite-athlete ceiling).
static let vo2maxRange: ClosedRange<Int> = 10...90
/// Plausible SDNN and RMSSD, in milliseconds — one gate, because they are the same quantity
/// measured two ways and share a range. Wider than `hrvRange` on purpose: that gate covers the
/// ring's single summary scalar, whereas a short-window SDNN over a restful night can
/// legitimately run higher. The ceiling is well past any physiological value and exists only to
/// drop a misframed u16.
static let beatIntervalMsRange: ClosedRange<Int> = 1...500
/// pNN50 is a percentage of successive beat intervals differing by more than 50 ms.
static let pnn50Range: ClosedRange<Double> = 0...100
/// LF and HF spectral power, in ms². Zero is a real reading (no power in the band), so the floor
/// is inclusive; the ceiling is a misframe guard, not a physiological claim.
static let spectralPowerRange: ClosedRange<Double> = 0...50_000
/// LF/HF ratio. Bounded away from zero because the ring reports it as a scaled integer and `0`
/// is its "no sample" filler rather than a real balance.
static let lfHfRatioRange: ClosedRange<Double> = 0.01...20
/// Sanity ceilings for one intraday activity bucket (~15 min): well above any human cadence so
/// only clearly-misframed packets are rejected.
static let maxBucketSteps = 5000
Expand Down Expand Up @@ -170,6 +184,10 @@ enum RingEventBridge {
case .bloodSugar: return bloodSugarRange.contains(value)
case .respiratoryRate: return respiratoryRateRange.contains(Int(value))
case .vo2max: return vo2maxRange.contains(Int(value))
case .sdnn, .rmssd: return beatIntervalMsRange.contains(Int(value))
case .pnn50: return pnn50Range.contains(value)
case .lfPower, .hfPower: return spectralPowerRange.contains(value)
case .lfHfRatio: return lfHfRatioRange.contains(value)
}
}

Expand Down
3 changes: 3 additions & 0 deletions PulseLoop/RingProtocol/TK5Coordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ final class TK5Coordinator: WearableCoordinator {
let bitmapGatedCapabilities: Set<WearableCapability> = [
.temperature, .bloodPressure, .manualBloodPressure,
.stress, .fatigue, .bloodSugar,
// The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress and fatigue —
// all three are fields of the one `05 33` body-data record that bit gates.
.hrvDetail,
]

let iconSystemName = "circle.circle.fill"
Expand Down
3 changes: 3 additions & 0 deletions PulseLoop/RingProtocol/YCBTCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ final class YCBTCoordinator: WearableCoordinator {
.temperature, .bloodPressure, .manualBloodPressure,
.stress, .fatigue, .bloodSugar,
.hrv, .manualHrv,
// The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress and fatigue —
// all three are fields of the one `05 33` body-data record that bit gates.
.hrvDetail,
.findDevice,
]

Expand Down
5 changes: 5 additions & 0 deletions PulseLoop/RingProtocol/YCBTDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ final class YCBTDriver: WearableDriver {
case .stress: return capabilities.contains(.stress)
case .fatigue: return capabilities.contains(.fatigue)
case .temperature: return capabilities.contains(.temperature)
// The HRV panel is gated on its own capability rather than `.hrv`: a ring can report the
// single HRV scalar (from `05 09` or the `06 03` live stream) without having the `05 33`
// body-data record these fields live in.
case .sdnn, .rmssd, .pnn50, .lfPower, .hfPower, .lfHfRatio:
return capabilities.contains(.hrvDetail)
}
}

Expand Down
53 changes: 51 additions & 2 deletions PulseLoop/RingProtocol/YCBTHealthRecords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,22 @@ enum YCBTHealthRecords {
/// The SDK's `pressure` is the **stress** score and `body` the **fatigue** score (that is how the
/// SmartHealth UI labels `BodyData.getPressureValue()` / `getBodyStateValue()`). This record is the
/// proof that the ring *stores* stress rather than the app deriving it — the old TK5 capability note
/// said the opposite. Load index, sympathetic tone, SDNN, pNN50, RMSSD and LF/HF have no
/// `MeasurementKind`; they are left on the floor rather than force-fitted into one.
/// said the opposite.
///
/// **The HRV panel.** SDNN, pNN50, RMSSD and the LF/HF pair are now decoded — see
/// `WearableCapability.hrvDetail`, which rides `IS_HAS_PRESSURE` exactly as `.stress`/`.fatigue`
/// do, because that one bit gates this whole record's query.
///
/// Two fields are still deliberately left on the floor: **load index (@4–5)** and **sympathetic
/// tone (@12–13)** are vendor-proprietary composites with no stated scale, no unit, and no
/// published definition. Unlike SDNN or RMSSD there is nothing to check them against, so
/// surfacing them would be inventing a metric rather than reporting one.
///
/// **`lfHf` at @24 is read but not trusted.** It is a single byte standing in for a ratio whose
/// real range is roughly 0.5–3, so it must carry an implicit scale factor the SDK never states.
/// Rather than guess it, the ratio is recomputed here from the LF and HF powers — whatever common
/// scale those two share cancels, so the quotient is right even though the absolute powers'
/// unit is inferred.
///
/// Those two scores go through `score` (digit-concatenated, the app's 1…100 scale) while HRV goes
/// through `composite` (milliseconds) — see both doc comments for why the same byte pair is read two
Expand All @@ -204,6 +218,41 @@ enum YCBTHealthRecords {
if r.count > 16, r[16] > 0 {
events.append(.historyMeasurement(kind: .vo2max, value: Double(r[16]), timestamp: ts))
}
events.append(contentsOf: hrvPanel(r, timestamp: ts))
}
return events
}

/// The HRV panel from a body-data record, guarded field by field.
///
/// Every field sits past byte 16, so each read repeats the same `r.count` gate the VO₂max read
/// uses — the SDK's `length >= cursor + 25` branch for the rumoured short-prefix firmware. A `0`
/// is the ring's "no sample" filler for all of these, so zero means absent, not measured-as-zero.
/// (`RingEventBridge` allows a genuine zero for LF/HF power, but a record that reports no HRV at
/// all reports it as zeros across the panel, which the `> 0` gates already drop.)
private static func hrvPanel(_ r: [UInt8], timestamp ts: Date) -> [RingDecodedEvent] {
var events: [RingDecodedEvent] = []

if r.count > 15 {
let sdnn = YCBTBytes.u16(r, 14)
if sdnn > 0 { events.append(.historyMeasurement(kind: .sdnn, value: Double(sdnn), timestamp: ts)) }
}
if r.count > 17, r[17] > 0 {
events.append(.historyMeasurement(kind: .pnn50, value: Double(r[17]), timestamp: ts))
}
if r.count > 19 {
let rmssd = YCBTBytes.u16(r, 18)
if rmssd > 0 { events.append(.historyMeasurement(kind: .rmssd, value: Double(rmssd), timestamp: ts)) }
}

guard r.count > 23 else { return events }
let lf = YCBTBytes.u16(r, 20)
let hf = YCBTBytes.u16(r, 22)
if lf > 0 { events.append(.historyMeasurement(kind: .lfPower, value: Double(lf), timestamp: ts)) }
if hf > 0 { events.append(.historyMeasurement(kind: .hfPower, value: Double(hf), timestamp: ts)) }
// Derived rather than read from @24 — see the note on `bodyData`. Needs both halves.
if lf > 0, hf > 0 {
events.append(.historyMeasurement(kind: .lfHfRatio, value: Double(lf) / Double(hf), timestamp: ts))
}
return events
}
Expand Down
7 changes: 7 additions & 0 deletions PulseLoop/RingProtocol/YCBTProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,13 @@ enum YCBTSupportFunction {
// gate no bit can satisfy is a dead one (`PairingMatchingTests`).
Bit(byte: 22, bit: 6, minLength: 23, capability: .fatigue), // IS_HAS_PRESSURE (same record)

// **The HRV panel rides the same bit, for the same reason.** SDNN, pNN50, RMSSD and the LF/HF
// pair are fields @14–24 of that one `05 33` record, so a ring with `IS_HAS_PRESSURE` clear is
// never asked for them either. `ISHASHRV` (1.1) is deliberately *not* the gate here: it governs
// the single HRV scalar, which arrives separately in the `05 09` combined record and on the
// `06 03` live stream — a ring can have HRV and still have no body-data record to break it down.
Bit(byte: 22, bit: 6, minLength: 23, capability: .hrvDetail), // IS_HAS_PRESSURE (same record)

// Find-my-ring. `DeviceSupportFunctionUtil.isHasFindDevice` reads it, and `MeAntiLostActivity`
// hides the whole screen without it.
Bit(byte: 6, bit: 4, minLength: 14, capability: .findDevice), // ISHASFINDDEVICE
Expand Down
57 changes: 32 additions & 25 deletions PulseLoop/Services/PulseServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,32 +195,39 @@ enum MetricsService {
DeviceRepository.devices(context: context)
}

/// Ordinary resting-adult ranges per kind, for demo/debug rows. A table rather than a `switch`
/// so adding a `MeasurementKind` doesn't push `insertMockMeasurement` further past SwiftLint's
/// cyclomatic-complexity gate — and so the whole set can be read at a glance.
///
/// `wholeNumber` marks the kinds whose display precision is integral; only temperature and the
/// LF/HF ratio carry a decimal.
static let mockValueRanges: [MeasurementKind: (range: ClosedRange<Double>, wholeNumber: Bool)] = [
.heartRate: (62...86, true),
.spo2: (96...99, true),
.stress: (20...70, true),
.hrv: (30...90, true),
.temperature: (33...36, false),
.bloodPressureSystolic: (110...130, true),
.bloodPressureDiastolic: (70...85, true),
.fatigue: (20...70, true),
.bloodSugar: (85...110, true),
.respiratoryRate: (12...18, true),
.vo2max: (35...50, true),
// The HRV panel, so demo data exercises the Autonomic screen with something plausible.
.sdnn: (40...110, true),
.rmssd: (25...85, true),
.pnn50: (5...35, true),
.lfPower: (400...1600, true),
.hfPower: (300...1400, true),
.lfHfRatio: (0.6...2.8, false),
]

static func insertMockMeasurement(kind: MeasurementKind, context: ModelContext) {
let value: Double
switch kind {
case .heartRate:
value = Double(Int.random(in: 62...86))
case .spo2:
value = Double(Int.random(in: 96...99))
case .stress:
value = Double(Int.random(in: 20...70))
case .hrv:
value = Double(Int.random(in: 30...90))
case .temperature:
value = Double.random(in: 33...36)
case .bloodPressureSystolic:
value = Double(Int.random(in: 110...130))
case .bloodPressureDiastolic:
value = Double(Int.random(in: 70...85))
case .fatigue:
value = Double(Int.random(in: 20...70))
case .bloodSugar:
value = Double(Int.random(in: 85...110))
case .respiratoryRate:
value = Double(Int.random(in: 12...18))
case .vo2max:
value = Double(Int.random(in: 35...50))
}
// A kind absent from the table is a programming error, not a runtime case — every one is
// listed, and `testEveryMeasurementKindHasAMockRange` fails if a new one isn't.
let spec = mockValueRanges[kind] ?? (0...100, true)
let raw = Double.random(in: spec.range)
let value = spec.wholeNumber ? raw.rounded() : raw
let row = MeasurementRepository.insertMeasurement(
kind: kind,
value: value,
Expand Down
Loading