diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 8ffa068..5478cb1 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -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 diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index f318f37..f484645 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -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 } } diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..7e386be 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -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 { @@ -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 } } } diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd4..1322c4d 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -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 ] )) @@ -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 diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 04f6089..d15a54c 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -196,6 +196,9 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { let bitmapGatedCapabilities: Set = [ .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" diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index 3d91422..5e8cae4 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -36,6 +36,20 @@ enum RingEventBridge { static let respiratoryRateRange: ClosedRange = 4...60 /// Plausible VO₂max, in mL/kg/min (sedentary floor to elite-athlete ceiling). static let vo2maxRange: ClosedRange = 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 = 1...500 + /// pNN50 is a percentage of successive beat intervals differing by more than 50 ms. + static let pnn50Range: ClosedRange = 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 = 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 = 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 @@ -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) } } diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index fe10521..1ae50bc 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -120,6 +120,9 @@ final class TK5Coordinator: WearableCoordinator { let bitmapGatedCapabilities: Set = [ .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" diff --git a/PulseLoop/RingProtocol/YCBTCoordinator.swift b/PulseLoop/RingProtocol/YCBTCoordinator.swift index bf35dfd..9232b39 100644 --- a/PulseLoop/RingProtocol/YCBTCoordinator.swift +++ b/PulseLoop/RingProtocol/YCBTCoordinator.swift @@ -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, ] diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift index a036eaf..4d258dc 100644 --- a/PulseLoop/RingProtocol/YCBTDriver.swift +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -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) } } diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift index ecc2fbc..146414e 100644 --- a/PulseLoop/RingProtocol/YCBTHealthRecords.swift +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -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 @@ -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 } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index 3cca23d..6de5fcc 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -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 diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 56d685b..c00ca73 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -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, 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, diff --git a/PulseLoop/Views/AutonomicDetailView.swift b/PulseLoop/Views/AutonomicDetailView.swift new file mode 100644 index 0000000..cf4cdac --- /dev/null +++ b/PulseLoop/Views/AutonomicDetailView.swift @@ -0,0 +1,171 @@ +import SwiftUI +import SwiftData + +/// The HRV panel: the time- and frequency-domain measures the YCBT body-data record (`05 33`) +/// reports alongside the single HRV scalar every ring gives. +/// +/// Reached only from the HRV detail screen, and only on rings that declare `.hrvDetail` — two taps +/// off the dashboard rather than six new cards on it. These are numbers you read occasionally to +/// understand a trend, not ones you glance at on a home screen. +/// +/// One card per measure: latest value, a sparkline over the selected period, and a plain-language +/// explanation. No zone colouring — unlike heart rate or SpO₂ these have no population-normal band +/// worth drawing, and inventing one would be exactly the black box the project exists to avoid. +struct AutonomicDetailView: View { + @Environment(\.modelContext) private var modelContext + + @State private var period: MetricDetailView.DetailPeriod = .week + @State private var series: [MeasurementKind: [MetricSample]] = [:] + /// Observed so the cards refresh when a background sync lands while this is open. + @State private var dataChange = PulseDataChange.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + periodSelector + ForEach(MeasurementKind.autonomicKinds, id: \.self) { kind in + card(for: kind) + } + explainer + } + .padding(16) + .padding(.bottom, 40) + .pulseGlassContainer(spacing: 18) + } + .background(PulseColors.background) + .pageChrome("HRV Detail") + .task(id: period) { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + } + + private var periodSelector: some View { + Picker("Period", selection: $period) { + ForEach(MetricDetailView.DetailPeriod.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + } + + // MARK: - Per-measure card + + @ViewBuilder + private func card(for kind: MeasurementKind) -> some View { + let samples = series[kind] ?? [] + // A ring that reports some of the panel but not all shouldn't show empty cards for the rest. + if !samples.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(kind.shortTitle.uppercased()) + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Spacer() + Text(formatted(samples.last?.value, kind: kind)) + .font(PulseFont.numberXL).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + if !kind.unit.isEmpty { + Text(kind.unit) + .font(PulseFont.caption).foregroundStyle(PulseColors.textMuted) + } + } + if samples.count >= 2 { + MiniSparkline(values: samples.map(\.value), color: PulseColors.hrv) + .frame(height: 40) + } + HStack(spacing: 0) { + stat("Average", formatted(average(samples), kind: kind)) + stat("Min", formatted(samples.map(\.value).min(), kind: kind)) + stat("Max", formatted(samples.map(\.value).max(), kind: kind)) + } + Text(blurb(for: kind)) + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(spacing: 4) { + Text(title.uppercased()) + .font(PulseFont.caption2).tracking(0.6) + .foregroundStyle(PulseColors.textMuted).lineLimit(1) + Text(value) + .font(PulseFont.footnote.weight(.semibold)).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .minimumScaleFactor(0.6).lineLimit(1) + } + .frame(maxWidth: .infinity) + } + + private var explainer: some View { + VStack(alignment: .leading, spacing: 6) { + Text("WHAT THIS MEANS").font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text("These break the single HRV number down into its parts. They're computed by the ring's " + + "own firmware from beat-to-beat timing, so treat them as wellness signals rather than " + + "clinical measurements — and read them as trends against your own history, not against " + + "anyone else's numbers.") + .font(PulseFont.footnote.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + // MARK: - Copy + + private func blurb(for kind: MeasurementKind) -> String { + switch kind { + case .rmssd: + return "The beat-to-beat measure most tied to rest and recovery. It tracks the parasympathetic " + + "\"rest and digest\" side, and tends to fall after hard training, alcohol, or a poor night." + case .sdnn: + return "Overall variability across the whole window. It picks up slower rhythms than RMSSD, so " + + "it moves more with the length of the recording than with any single night." + case .pnn50: + return "The share of consecutive beats differing by more than 50 ms. It moves with RMSSD and is " + + "another read on parasympathetic activity." + case .lfPower: + return "Power in the low-frequency band. Often described as sympathetic, but it reflects a mix of " + + "influences including blood-pressure regulation — worth watching as a trend, not a verdict." + case .hfPower: + return "Power in the high-frequency band, driven largely by breathing. It rises with slow, deep " + + "breathing and with restful sleep." + case .lfHfRatio: + return "The balance between the two bands, computed here from the LF and HF powers themselves. " + + "It's commonly read as a stress-versus-recovery balance, though that reading is debated." + default: + return "" + } + } + + // MARK: - Data + + private func reload() { + let now = Date() + let start = now.addingTimeInterval(-Double(period.days) * 86_400) + var next: [MeasurementKind: [MetricSample]] = [:] + for kind in MeasurementKind.autonomicKinds { + let rows = MetricsRepository.measurements( + kind: kind, start: start, end: now, limit: 500, context: modelContext + ) + // The repository returns newest-first; a chart wants chronological order. + next[kind] = rows.reversed().map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + } + series = next + } + + private func average(_ samples: [MetricSample]) -> Double? { + guard !samples.isEmpty else { return nil } + return samples.reduce(0) { $0 + $1.value } / Double(samples.count) + } + + /// Ratios need a decimal; everything else in the panel is a whole number at display precision. + private func formatted(_ value: Double?, kind: MeasurementKind) -> String { + guard let value, value.isFinite else { return "--" } + return kind == .lfHfRatio ? String(format: "%.2f", value) : "\(Int(value.rounded()))" + } +} diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 0928f37..ae60cfe 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -10,6 +10,7 @@ struct MetricDetailView: View { let metric: MetricKind @Binding var path: NavigationPath @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] @@ -60,6 +61,15 @@ struct MetricDetailView: View { case .month: return .thirtyDays } } + /// Window length in days, for callers that query `Measurement` rows directly rather than + /// through `MetricsService.metricRange` (which is keyed by `MetricKey`). + var days: Int { + switch self { + case .today: return 1 + case .week: return 7 + case .month: return 30 + } + } } var body: some View { @@ -68,6 +78,7 @@ struct MetricDetailView: View { periodSelector chartSection statTiles + if metric == .hrv, showsAutonomicPanel { autonomicRow } legend explainer if isEstimatedMetric { disclaimer } @@ -203,6 +214,58 @@ struct MetricDetailView: View { Rectangle().fill(PulseColors.borderSubtle).frame(width: 1, height: 34) } + // MARK: - Autonomic panel entry + + /// Whether to offer the HRV-panel tap-through: the ring must declare `.hrvDetail` and there must + /// actually be something behind the tap. Deliberately a *row on this screen* rather than a + /// dashboard card — six more tiles on Today or Vitals would bury the metrics people open the app + /// for, and these are read occasionally, not glanced at. + private var showsAutonomicPanel: Bool { + MetricsService.activeCapabilities(context: modelContext, ble: ble).contains(.hrvDetail) + && MeasurementKind.autonomicKinds.contains { + // `latestMeasurement` is a `fetchLimit: 1` probe — an existence check, cheap enough + // to call from `body`. + MetricsRepository.latestMeasurement(kind: $0, context: modelContext) != nil + } + } + + private var autonomicRow: some View { + Button { path.append(AppRoute.autonomicDetail) } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("HRV DETAIL") + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text(autonomicPreview) + .font(PulseFont.footnote).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1).minimumScaleFactor(0.7) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("HRV detail. \(autonomicPreview)") + .accessibilityHint("Opens SDNN, RMSSD, pNN50 and LF/HF trends") + } + + /// The three most-recognised measures, latest value each. Falls back to a prompt rather than a + /// row of dashes when the panel exists but the period holds nothing. + private var autonomicPreview: String { + let parts: [String] = [MeasurementKind.rmssd, .sdnn, .pnn50].compactMap { kind in + guard let row = MetricsRepository.latestMeasurement(kind: kind, context: modelContext) else { return nil } + let unit = kind.unit.isEmpty ? "" : " \(kind.unit)" + return "\(kind.shortTitle) \(Int(row.value.rounded()))\(unit)" + } + return parts.isEmpty ? "SDNN, RMSSD, pNN50 and LF/HF" : parts.joined(separator: " · ") + } + // MARK: - Legend private var legend: some View { diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 3a16886..09bc46d 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -117,6 +117,8 @@ struct RootAppView: View { case let .metricDetail(metric): MetricDetailView(metric: metric, path: $path) .pulseZoomDestination(route, in: zoomNS) + case .autonomicDetail: + AutonomicDetailView() case .activityTrends: ActivityTrendsView(path: $path) case .recordSelect: diff --git a/PulseLoop/Wearables/WearableCapability.swift b/PulseLoop/Wearables/WearableCapability.swift index 899e22d..5910bf2 100644 --- a/PulseLoop/Wearables/WearableCapability.swift +++ b/PulseLoop/Wearables/WearableCapability.swift @@ -53,6 +53,15 @@ enum WearableCapability: String, CaseIterable, Codable, Sendable { // `0xbc 0x2a`). Drives the workout vitals plan: a device with no `manualSpo2` but with this // capability shows the latest logged value instead of pretending it can spot-measure. case spo2History + + /// The ring reports the **HRV panel** — SDNN, RMSSD, pNN50 and the LF/HF pair — and not just the + /// single `hrv` scalar every family gives. Today that means the YCBT body-data record (`05 33`), + /// so the jring, Colmi QRing and LuckRing families never declare it and the Autonomic screen is + /// simply absent for them. + /// + /// Separate from `.hrv` on purpose: those two answer different questions. Every supported ring + /// can produce an HRV number; only these can break it down. + case hrvDetail } extension Set where Element == WearableCapability { diff --git a/PulseLoopTests/EventBridgeTests.swift b/PulseLoopTests/EventBridgeTests.swift index 999d0c3..ae269e2 100644 --- a/PulseLoopTests/EventBridgeTests.swift +++ b/PulseLoopTests/EventBridgeTests.swift @@ -88,6 +88,7 @@ final class EventBridgeTests: XCTestCase { .heartRate: 70, .spo2: 97, .stress: 40, .hrv: 55, .temperature: 36.6, .bloodPressureSystolic: 118, .bloodPressureDiastolic: 79, .fatigue: 20, .bloodSugar: 99, .respiratoryRate: 14, .vo2max: 42, + .sdnn: 48, .rmssd: 55, .pnn50: 12, .lfPower: 1200, .hfPower: 900, .lfHfRatio: 1.33, ] let now = Date() for kind in MeasurementKind.allCases { @@ -105,6 +106,25 @@ final class EventBridgeTests: XCTestCase { } } + /// The demo/debug value table is keyed rather than switched, so nothing makes it exhaustive at + /// compile time. This does it at test time: a new `MeasurementKind` without a range would + /// otherwise silently seed 0–100 for a metric measured in ms² or as a ratio. + func testEveryMeasurementKindHasAMockRange() { + for kind in MeasurementKind.allCases { + guard let spec = MetricsService.mockValueRanges[kind] else { + XCTFail("new MeasurementKind \(kind) — add a plausible demo range to mockValueRanges") + continue + } + XCTAssertTrue( + RingEventBridge.events( + for: .historyMeasurement(kind: kind, value: spec.range.lowerBound, timestamp: Date()), + now: Date() + ).count == 1, + "\(kind)'s demo floor is outside its own persistence gate" + ) + } + } + func testHeartRateSampleMapsThrough() { let events = RingEventBridge.events(for: .heartRateSample(bpm: 72, timestamp: Date())) XCTAssertEqual(events.count, 1) diff --git a/PulseLoopTests/HrvDetailCapabilityTests.swift b/PulseLoopTests/HrvDetailCapabilityTests.swift new file mode 100644 index 0000000..d771fbe --- /dev/null +++ b/PulseLoopTests/HrvDetailCapabilityTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import PulseLoop + +/// The HRV panel exists on exactly one record — the YCBT `05 33` body data — so it must be invisible +/// on every ring that can't produce it, and offered only when the ring itself claims the bit that +/// gates that record. These pin both directions across all six supported families. +@MainActor +final class HrvDetailCapabilityTests: XCTestCase { + + /// Every coordinator, and everything it could ever grant (baseline plus bitmap-gated). + private var everyFamily: [(name: String, granted: Set)] { + let coordinators: [any WearableCoordinator] = [ + JringCoordinator(), ColmiCoordinator(), LuckRingCoordinator(), + ColmiSmartHealthCoordinator(), TK5Coordinator(), YCBTCoordinator(), + ] + return coordinators.map { + ("\(type(of: $0))", $0.capabilities.union($0.bitmapGatedCapabilities)) + } + } + + // MARK: - Which families can ever offer it + + /// The jring's `0x24` packet, the Colmi QRing's big-data channel and the LuckRing's K6 dataTypes + /// carry a single HRV scalar and nothing to break it down. None of them may claim the panel by + /// any route — the Autonomic screen is simply absent there. + func testNonYCBTFamiliesNeverOfferTheHrvPanel() { + let ycbtFamilies = ["ColmiSmartHealthCoordinator", "TK5Coordinator", "YCBTCoordinator"] + for family in everyFamily where !ycbtFamilies.contains(family.name) { + XCTAssertFalse(family.granted.contains(.hrvDetail), + "\(family.name) has no body-data record and must not claim .hrvDetail") + } + } + + /// …and all three YCBT families can, since they share one driver and one record. + func testEveryYCBTFamilyCanOfferTheHrvPanel() { + let coordinators: [any WearableCoordinator] = [ + ColmiSmartHealthCoordinator(), TK5Coordinator(), YCBTCoordinator(), + ] + for coordinator in coordinators { + XCTAssertTrue(coordinator.bitmapGatedCapabilities.contains(.hrvDetail), + "\(type(of: coordinator)) should defer the panel to the ring's own bitmap") + XCTAssertFalse(coordinator.capabilities.contains(.hrvDetail), + "\(type(of: coordinator)): a baseline entry would be an unconditional promise") + } + } + + // MARK: - The bit itself + + /// The panel rides `IS_HAS_PRESSURE` (byte 22, bit 6) — the bit the vendor app gates the whole + /// `05 33` query on — so it is claimed exactly when stress and fatigue are. + func testPanelIsClaimedWithStressAndFatigue() { + var bitmap = [UInt8](repeating: 0, count: 32) + bitmap[22] = 1 << 6 + let claimed = YCBTSupportFunction.capabilities(from: bitmap) + + XCTAssertTrue(claimed.contains(.hrvDetail)) + XCTAssertTrue(claimed.contains(.stress)) + XCTAssertTrue(claimed.contains(.fatigue)) + } + + /// `ISHASHRV` (byte 1, bit 1) governs the single HRV scalar, which reaches the app from the + /// `05 09` combined record and the `06 03` live stream. A ring with HRV but no body-data record + /// must get the scalar and *not* the panel — this is the distinction the separate capability exists + /// to draw. + func testHrvScalarBitAloneDoesNotGrantThePanel() { + var bitmap = [UInt8](repeating: 0, count: 32) + bitmap[1] = 1 << 1 + let claimed = YCBTSupportFunction.capabilities(from: bitmap) + + XCTAssertTrue(claimed.contains(.hrv), "the scalar is claimed") + XCTAssertFalse(claimed.contains(.hrvDetail), "the breakdown is not") + } + + /// The real R99 (firmware 2.32) leaves `IS_HAS_PRESSURE` clear — byte 22 is `0x20`, bit 5 not + /// bit 6 — and NAKs `05 33` outright. It must resolve to no panel, exactly as it resolves to no + /// stress and no fatigue. + func testTheRealR99GetsNoPanel() { + let claimed: [UInt8] = [ + 0xf9, 0x09, 0x00, 0x00, 0x00, 0x00, 0x0c, 0xd8, + 0x10, 0x04, 0x01, 0xb2, 0xb6, 0x00, 0x40, 0x0f, + 0x00, 0x14, 0x50, 0x00, 0x00, 0x00, 0x20, 0x00, + ] + let bitmap = claimed + [UInt8](repeating: 0, count: 60 - claimed.count) + let derived = YCBTSupportFunction.capabilities(from: bitmap) + XCTAssertFalse(derived.contains(.hrvDetail)) + + let refined = ColmiSmartHealthCoordinator().refinedCapabilities(bitmapDerived: derived) + XCTAssertFalse(refined.contains(.hrvDetail), + "a ring that NAKs the body-data record must not offer its fields") + } + + /// A truncated or garbage bitmap means "no opinion", so the family baseline stands — and since + /// the panel is never a baseline entry, that resolves to no panel rather than to one granted by + /// accident. + func testATruncatedBitmapDoesNotGrantThePanel() { + for length in [0, 5, 13] { + let refined = TK5Coordinator().refinedCapabilities( + bitmapDerived: YCBTSupportFunction.capabilities(from: [UInt8](repeating: 0xFF, count: length)) + ) + XCTAssertFalse(refined.contains(.hrvDetail), "length \(length)") + } + } + + // MARK: - Persistence round-trip + + /// Capabilities persist as a CSV on `Device`, so a newly appended case has to survive the trip — + /// otherwise the panel would vanish whenever the set is read back from the store rather than the + /// live connection. + func testPanelCapabilitySurvivesTheCSVRoundTrip() { + let original: Set = [.heartRate, .hrv, .hrvDetail, .stress] + XCTAssertEqual(Set(csv: original.csv), original) + XCTAssertTrue(original.csv.contains("hrvDetail")) + } + + // MARK: - The kinds it gates + + /// The panel's `MeasurementKind`s deliberately have no `MetricKey`, which is what keeps them off + /// Today and Vitals entirely — they are reachable only through the HRV detail screen. + func testPanelKindsAreNotDashboardMetrics() { + let dashboardKinds = Set(MetricKey.allCases.map(\.rawValue)) + for kind in MeasurementKind.autonomicKinds { + XCTAssertFalse(dashboardKinds.contains(kind.rawValue), + "\(kind) must not be a dashboard card — it lives behind the HRV screen") + } + } +} diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index d049619..cf8b6b6 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -574,7 +574,7 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(refined, [ .heartRate, .spo2, .spo2History, .steps, .battery, - .hrv, .manualHrv, .bloodPressure, .manualBloodPressure, + .hrv, .manualHrv, .hrvDetail, .bloodPressure, .manualBloodPressure, .temperature, .stress, .fatigue, .bloodSugar, .sleep, .remSleep, .manualHeartRate, .manualSpo2, @@ -588,17 +588,18 @@ final class PairingMatchingTests: XCTestCase { /// `ISHASFATIGUE` in the SDK; what there is, is `DataSyncUtils` gating the *entire* body-data query /// (`05 33` — the record that carries both scores, as its `pressure` and `body` fields) on /// `IS_HAS_PRESSURE`. A ring with that bit clear is never even asked for the record, so it can no more - /// produce a fatigue score than a stress one. The two therefore arrive and depart together. + /// produce a fatigue score than a stress one. The two therefore arrive and depart together — as does + /// `.hrvDetail`, the HRV panel that lives in the same record's bytes @14–24. func testFatigueAndStressAreClaimedTogetherOrNotAtAll() { let tk5 = TK5Coordinator() var bodyDataRing = [UInt8](repeating: 0, count: 27) bodyDataRing[22] = 1 << 6 // IS_HAS_PRESSURE let claimed = YCBTSupportFunction.capabilities(from: bodyDataRing) - XCTAssertEqual(claimed, [.stress, .fatigue]) + XCTAssertEqual(claimed, [.stress, .fatigue, .hrvDetail]) let refined = tk5.refinedCapabilities(bitmapDerived: claimed) - XCTAssertEqual(refined, tk5.capabilities.union([.stress, .fatigue])) + XCTAssertEqual(refined, tk5.capabilities.union([.stress, .fatigue, .hrvDetail])) // Never one without the other, whichever way the bit falls. XCTAssertEqual(refined.contains(.stress), refined.contains(.fatigue)) let silent = tk5.refinedCapabilities(bitmapDerived: []) diff --git a/PulseLoopTests/YCBTHealthRecordsTests.swift b/PulseLoopTests/YCBTHealthRecordsTests.swift index 7ee6392..76400a2 100644 --- a/PulseLoopTests/YCBTHealthRecordsTests.swift +++ b/PulseLoopTests/YCBTHealthRecordsTests.swift @@ -217,6 +217,65 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) } + /// The HRV panel out of the same captured record: `sdnn:u16@14` = 48 ms, `pnn50@17` = 12 %, + /// `rmssd:u16@18` = 55 ms, `lf:u16@20` = 1200, `hf:u16@22` = 900. All five land in ordinary adult + /// resting ranges, which is the corroboration that the offsets are right — a misread would put + /// them in the thousands or at zero. + func testBodyDataDecodesTheHrvPanel() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord) + XCTAssertEqual(values(.sdnn, in: events).first ?? 0, 48, accuracy: 0.001) + XCTAssertEqual(values(.pnn50, in: events).first ?? 0, 12, accuracy: 0.001) + XCTAssertEqual(values(.rmssd, in: events).first ?? 0, 55, accuracy: 0.001) + XCTAssertEqual(values(.lfPower, in: events).first ?? 0, 1200, accuracy: 0.001) + XCTAssertEqual(values(.hfPower, in: events).first ?? 0, 900, accuracy: 0.001) + XCTAssertEqual(MeasurementKind.rmssd.unit, "ms") + XCTAssertEqual(MeasurementKind.pnn50.unit, "%") + XCTAssertEqual(MeasurementKind.lfHfRatio.unit, "", "a ratio of two powers is dimensionless") + } + + /// **The cross-check that settles byte 24.** The record carries its own `lfHf` at @24, a single + /// byte standing in for a ratio whose real range is ~0.5–3 — so it must be scaled, and the SDK + /// never says by how much. PulseLoop doesn't guess: it recomputes the ratio from the LF and HF + /// powers, whose shared (and also unstated) scale cancels in the quotient. + /// + /// Here the two agree — @24 is `0x0d` = 13, i.e. 1.3 at a ÷10 scale, against 1200 ÷ 900 = 1.33 — + /// which corroborates the whole panel's offsets from real captured bytes rather than inference. + /// The derived value is still the one published, because it stays right even on a firmware that + /// scales @24 differently. + func testLfHfRatioIsDerivedFromThePowersAndAgreesWithTheRecordsOwnByte() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord) + let derived = values(.lfHfRatio, in: events).first ?? 0 + XCTAssertEqual(derived, 1200.0 / 900.0, accuracy: 0.0001) + + let onWireTenths = Double(capturedBodyRecord[24]) / 10 + XCTAssertEqual(derived, onWireTenths, accuracy: 0.05, + "the derived ratio agrees with the record's own lfHf byte at a ÷10 scale") + } + + /// A record reporting no HRV at all fills the whole panel with zeros, and zero is the ring's + /// "no sample" filler — not a measurement of zero variability. + func testAllZeroHrvPanelPublishesNothing() { + var record = capturedBodyRecord + for index in 14...24 { record[index] = 0 } + let events = YCBTHealthRecords.bodyData(record) + + for kind in MeasurementKind.autonomicKinds { + XCTAssertTrue(values(kind, in: events).isEmpty, "\(kind) should be absent, not zero") + } + XCTAssertEqual(values(.hrv, in: events).count, 1, "the HRV scalar at @6 is unaffected") + } + + /// LF present but HF missing can't produce a ratio — and must not divide by zero. + func testRatioNeedsBothPowers() { + var record = capturedBodyRecord + record[22] = 0; record[23] = 0 // hf = 0 + let events = YCBTHealthRecords.bodyData(record) + + XCTAssertEqual(values(.lfPower, in: events).first ?? 0, 1200, accuracy: 0.001) + XCTAssertTrue(values(.hfPower, in: events).isEmpty) + XCTAssertTrue(values(.lfHfRatio, in: events).isEmpty) + } + /// The scale, at the seam where it is easy to get wrong: a whole-number score has fraction 0, and /// `(7, 0)` is 70 — not 7, and not 7.0. `RingEventBridge.stressRange` (1…100) can't catch a 10× /// error, so this is the only place it is pinned. @@ -336,8 +395,8 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(YCBTHealthRecords.decode(blood, type: .blood).count, 3) // sys + dia + hr XCTAssertEqual(YCBTHealthRecords.decode(sport, type: .sport).count, 1) XCTAssertEqual(YCBTHealthRecords.decode(temperature, type: .temperature).count, 2) - // hrv + stress + fatigue + vo2max - XCTAssertEqual(YCBTHealthRecords.decode(capturedBodyRecord, type: .bodyData).count, 4) + // hrv + stress + fatigue + vo2max + the HRV panel (sdnn, pnn50, rmssd, lf, hf, lf/hf) + XCTAssertEqual(YCBTHealthRecords.decode(capturedBodyRecord, type: .bodyData).count, 10) // 8 records × (systolic + diastolic + spo2 + respiratory rate + hrv); temp and blood sugar are // the unmeasured fillers in this capture, and the cumulative step counter is not published. XCTAssertEqual(YCBTHealthRecords.decode(capturedAllRecords, type: .all).count, 8 * 5) diff --git a/PulseLoopTests/YCBTSupportFunctionTests.swift b/PulseLoopTests/YCBTSupportFunctionTests.swift index a7a8b16..ac643ee 100644 --- a/PulseLoopTests/YCBTSupportFunctionTests.swift +++ b/PulseLoopTests/YCBTSupportFunctionTests.swift @@ -46,7 +46,9 @@ final class YCBTSupportFunctionTests: XCTestCase { (15, 2, [.manualBloodPressure]),// ISHASTESTBLOOD (15, 3, [.manualSpo2]), // ISHASTESTSPO2 (17, 3, [.bloodSugar]), // ISHASBLOODSUGAR - (22, 6, [.stress, .fatigue]), // IS_HAS_PRESSURE — the whole `05 33` record + // IS_HAS_PRESSURE gates the whole `05 33` record, so it yields every capability whose + // data lives in it: stress (@8–9), fatigue (@10–11) and the HRV panel (@14–24). + (22, 6, [.stress, .fatigue, .hrvDetail]), (23, 0, [.manualHrv]), // IS_HAS_HRV_MEASUREMENT ] for entry in expected { @@ -80,7 +82,7 @@ final class YCBTSupportFunctionTests: XCTestCase { YCBTSupportFunction.capabilities(from: allOnes), [ .steps, .sleep, .heartRate, .bloodPressure, .spo2, .hrv, .findDevice, .temperature, - .bloodSugar, .stress, .fatigue, + .bloodSugar, .stress, .fatigue, .hrvDetail, .manualHeartRate, .manualBloodPressure, .manualSpo2, .manualHrv, ] ) @@ -154,7 +156,7 @@ final class YCBTSupportFunctionTests: XCTestCase { XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 22, set: [(22, 6)])), []) XCTAssertEqual( YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(22, 6)])), - [.stress, .fatigue] + [.stress, .fatigue, .hrvDetail] ) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(23, 0)])), []) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 24, set: [(23, 0)])), [.manualHrv]) @@ -249,7 +251,7 @@ final class YCBTSupportFunctionTests: XCTestCase { guard case let .supportFunctions(claimed) = events.first else { return XCTFail("expected .supportFunctions, got \(events)") } - XCTAssertEqual(claimed, [.heartRate, .spo2, .stress, .fatigue]) + XCTAssertEqual(claimed, [.heartRate, .spo2, .stress, .fatigue, .hrvDetail]) } /// The `02 1b` reply reaches the debug feed with its value (`InnerUtils.isJieLiChipScheme`: 3/4/5). diff --git a/docs/hardware/index.md b/docs/hardware/index.md index a11d7a5..f57c911 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -162,6 +162,7 @@ been validated end to end on hardware. | Blood pressure | ✅² | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪 | ❔⁷ | | Blood sugar | ✅³ | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪⁴ | ❔⁷ | | HRV | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | ❔⁷ | +| HRV detail (SDNN/RMSSD/pNN50/LF-HF)¹¹ | ❌ | ❌ | ❌ | ❌ | ❌ | ❔⁷ | ❔⁷ | ❔⁷ | | Stress | ✅ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪 | ❔⁷ | | Fatigue | ✅ | ✅ | ✅ | ✅ | ✅ | ❌⁸ | 🧪 | ❔⁷ | | Skin temperature | ❌ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪⁴ | ❔⁷ | @@ -180,6 +181,8 @@ been validated end to end on hardware. ⁸ Not claimed: no capability bit names fatigue, so it can be neither gated nor honestly promised on hardware nobody has connected. The first real sync decides. ¹⁰ From the `05 09` combined record, not a dedicated log — the R10M has no `05 1A` all-day SpO₂ history, so PulseLoop never issues that query for it. +¹¹ The panel behind the single HRV number, and a YCBT exclusive: it lives entirely in the `05 33` body-data record, which the 56ff, QRing-Colmi and LuckRing protocols have no equivalent of. On the YCBT families it is claimed **per ring**, riding `IS_HAS_PRESSURE` — the same bit that gates stress and fatigue, because all three are fields of that one record. Neither Oura nor Ultrahuman surfaces this. See [HRV detail](../project/hrv-metrics.md). + The TK5 stores stress and fatigue on the ring itself (the body-data record), which an earlier version of this page denied — it also reads respiratory rate and VO₂max, which no other supported ring exposes. Its whole column is 🧪 until the [on-device checkpoint](tk5.md#needs-on-device-confirmation) clears. ## Not Supported by PulseLoop diff --git a/docs/project/hrv-metrics.md b/docs/project/hrv-metrics.md new file mode 100644 index 0000000..9930e9f --- /dev/null +++ b/docs/project/hrv-metrics.md @@ -0,0 +1,147 @@ +--- +title: HRV detail +description: The HRV panel — SDNN, RMSSD, pNN50 and LF/HF — where each value comes from, how it is computed, which rings have it, and what PulseLoop deliberately refuses to show. +--- + +# HRV detail + +Every supported ring reports a single **HRV** number. Some also report the panel behind it: the +standard time- and frequency-domain measures that number is summarising. + +That panel is what this page documents. It is worth noting that **neither Oura nor Ultrahuman +surfaces it** — both show one HRV figure — so on the right hardware a $20 ring gives you strictly +more autonomic detail than a $349 one. + +The decoder is +[`YCBTHealthRecords.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/RingProtocol/YCBTHealthRecords.swift) +and the screen is +[`AutonomicDetailView.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Views/AutonomicDetailView.swift). +Both are covered by tests that lock every number on this page. + +## Where to find it + +**Vitals → HRV → HRV detail.** + +Deliberately two taps in, and deliberately not on Today or Vitals as cards. These are numbers you +read occasionally to understand a trend, not ones you glance at — six more dashboard tiles would +bury the metrics people actually open the app for. The panel's `MeasurementKind`s have no +`MetricKey`, which is what makes a dashboard card structurally impossible rather than merely +absent today. + +## Which rings have it + +| Family | HRV scalar | HRV panel | +|---|---|---| +| jring / 56ff | ✅ | ❌ — no body-data record exists | +| Colmi / Yawell (QRing) | ✅ | ❌ — no body-data record exists | +| LuckRing / TK18 | ✅ | ❌ — no body-data record exists | +| Colmi / Yawell (SmartHealth) | ❔ per ring | ❔ per ring | +| TK5 | ❔ per ring | ❔ per ring | +| R10M / LittleMeatball | ❔ per ring | ❔ per ring | + +The panel lives entirely in the YCBT **body-data record** (`05 33`), so only the three YCBT +families can produce it at all — and among those it is claimed **per ring**, not per family. + +### The gate + +`WearableCapability.hrvDetail` rides **`IS_HAS_PRESSURE` (byte 22, bit 6)** of the ring's own +`02 01` capability bitmap. That is the bit the vendor SDK gates the entire `05 33` query on: + +```java +if (YCBTClient.isSupportFunction(FunctionConstant.IS_HAS_PRESSURE)) { + arrayList.add(DATATYPE.Health_History_Body_Data); +} +``` + +A ring with that bit clear is never asked for the record, so it can no more produce SDNN than it +can produce a stress score — which is why the panel is claimed and withheld together with +`.stress` and `.fatigue`, the two other fields of that same record. + +!!! note "Why not `ISHASHRV`?" + Byte 1 bit 6 governs the **scalar**, which arrives from a different record (`05 09`) and from + the `06 03` live stream. A ring can have HRV and still have no body-data record to break it + down, so the panel needs its own capability rather than riding `.hrv`. + +The real **R99** (firmware 2.32) is the worked example: byte 22 is `0x20` — bit 5, not bit 6 — and +it NAKs `05 33` outright, so it gets the scalar and no panel. + +## The metrics + +Byte offsets are within the 28-byte body-data record (`DataUnpack` case 51); `u16` is +little-endian. + +| Metric | Offset | Unit | Plausible range | What it is | +|---|---|---|---|---| +| **RMSSD** | `u16` @18 | ms | 1–500 | Root mean square of successive beat-interval differences. The measure most tied to rest and recovery — it tracks parasympathetic ("rest and digest") activity and falls after hard training, alcohol, or a poor night. | +| **SDNN** | `u16` @14 | ms | 1–500 | Standard deviation of beat intervals across the window. Captures slower rhythms than RMSSD, so it moves more with recording length than with any single night. | +| **pNN50** | `u8` @17 | % | 0–100 | Share of consecutive beats differing by more than 50 ms. Moves with RMSSD; another read on parasympathetic activity. | +| **LF power** | `u16` @20 | ms² | 0–50 000 | Power in the low-frequency band. Often called sympathetic, but it reflects a mix including blood-pressure regulation. | +| **HF power** | `u16` @22 | ms² | 0–50 000 | Power in the high-frequency band, driven largely by breathing. Rises with slow, deep breathing and restful sleep. | +| **LF/HF** | *derived* | ratio | 0.01–20 | Balance between the two bands. Commonly read as stress-versus-recovery, though that reading is debated. | + +Ranges are enforced in +[`RingEventBridge`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/RingProtocol/RingEventBridge.swift) +before anything is persisted. They are misframe guards, not physiological claims — a value outside +them indicates a misdecoded record, since a ring replays its whole log on every sync and one bad +record would otherwise re-persist forever. + +Zero means **absent**, not measured-as-zero: `0` is the ring's "no sample" filler across the whole +panel. + +### LF/HF is computed, not read + +The record carries its own `lfHf` at **@24** — and PulseLoop ignores it. + +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, and the SDK never states one. Rather than guess, the ratio is recomputed: + +``` +LF/HF = lfPower / hfPower +``` + +Whatever common scale LF and HF share **cancels in the quotient**, so the derived ratio is correct +even though the absolute powers' unit is inferred. + +The captured hardware record the tests are built on confirms the approach: @24 reads `0x0d` = 13, +i.e. 1.3 at a ÷10 scale, against a derived 1200 ÷ 900 = **1.33**. The two agree — which is also +the corroboration that every offset in the table above is right, since a misread would put these +values in the thousands or at zero rather than in ordinary adult resting ranges. + +## No zone colouring, and why + +Unlike heart rate or SpO₂, these have no population-normal band worth drawing. RMSSD alone spans +roughly an order of magnitude across healthy adults and shifts with age, fitness, posture and +recording length. Painting a green/amber/red band on that would be inventing a threshold — exactly +the black box this project exists to avoid. + +Read them as trends against your own history. + +## Not exported to Apple Health + +HealthKit models HRV as one type, `heartRateVariabilitySDNN`, and has nothing for RMSSD, pNN50 or +spectral power. The one that could map — SDNN — is already occupied by the ring's HRV scalar, and +whether that scalar simply *is* the ring's SDNN is unverified. Exporting both would either +double-report one measurement or silently disagree with itself, so the panel stays in-app until a +hardware session settles which is which. + +## What we deliberately do not surface + +The same records carry several other fields. They are decoded past, not accidentally missed: + +| Field | Where | Why not | +|---|---|---| +| **Load index** | `05 33` @4–5 | Vendor-proprietary composite. No stated scale, no unit, no published definition — nothing to check it against. | +| **Sympathetic tone** | `05 33` @12–13 | Same: a proprietary index, not a standard measure. | +| **Body fat** | `05 09` @15–16 | The ring has no bioimpedance sensor. Any figure here is derived from the profile you typed in, not measured. | +| **Uric acid** | `05 2F` @7–9 | No optical ring can measure uric acid. | +| **Blood ketones** | `05 2F` @10–12 | No optical ring can measure ketones. | +| **Blood lipids** | `05 2F`, four fractions | No optical ring can measure a lipid panel. | + +The bottom four are the reason this section exists. Surfacing them would make the app look more +capable and would be straightforwardly dishonest — the hardware cannot measure them, whatever the +firmware reports. (The same reasoning is already applied to the jring's "blood sugar", which is a +profile-derived estimate and is labelled as one everywhere it appears.) + +The top two are a softer call: they are real outputs of a real algorithm, but with no definition +to hold them to, showing them would be reporting a number rather than a metric. If the vendor +scale is ever pinned down, they can join the panel. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..40520c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - HRV detail: project/hrv-metrics.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md