diff --git a/CLAUDE.md b/CLAUDE.md index a1a0580..bfa8df7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ FPV drone racing use case: operator taps LAP on phone, lap time appears on pilot ``` firmware/ ESP32 PlatformIO project (Arduino framework) -app/ iOS SwiftUI app (iOS 18+, xcodegen) +app/ iOS SwiftUI app (iOS 18+, xcodegen) + HDZapWatch watchOS companion (heart-rate streamer) docs/ Architecture, research, TestFlight setup (only docs/manual/ + docs/flash/ ship to Pages) docs/manual/ End-user manual (en + ja); served on GitHub Pages docs/flash/ Browser firmware flasher (esptool-js, M5StickS3); served on GitHub Pages @@ -75,6 +75,7 @@ cd app && xcodegen generate # regenerate .xcodeproj after changes - iOS: @MainActor + @Observable (not ObservableObject), @Environment for DI - BLE callbacks stage paired state under `g_ble_mux` (UID staging, lap frame); idempotent single-flag commands use bare `volatile`. See `ble_service.h` shared-state docstring. Heavy work (NVS, ESP-NOW reinit) runs in main loop, not in callbacks. - `CBCentralManager` delegate queue MUST be main (`queue: nil`). `BluetoothManager` is `@MainActor`; `recordError` runtime-asserts main-actor isolation. +- Apple Watch heart rate: `HDZapWatch` (watchOS target in `app/project.yml`, bundle id `sh.saqoo.HDZap.watchkitapp` — must stay `.watchkitapp` with matching `WKCompanionAppBundleIdentifier`) runs an `HKWorkoutSession` + `HKLiveWorkoutBuilder` and streams bpm at ~1 Hz over WatchConnectivity (`["hr": Double, "ts": TimeInterval]`). iOS `WatchHeartRateManager` mirrors `BluetoothManager`'s telemetry surface (`lastHeartRateBpm` / `lastHeartRateReceivedAt` / `heartRateNotifyRevision`); `TimerView` ingests into `RaceHeartRateSample`s persisted on `RaceRecord.heartRateSamples` (decodeIfPresent-backfilled, validated, CSV-exportable — same shape as `flightBatterySamples`). Race START/STOP is relayed to the watch as `["race": "start"|"stop"]` so the workout follows the phone once the watch app is open; both directions of `sendMessage` require `isReachable` (counterpart app frontmost / workout-active). No firmware/OSD involvement. - NVS namespace: "hdzero"; keys: `"uid"` (6 bytes) + `"init"` (sentinel for torn-save detection). Save order is remove sentinel → write uid → write sentinel; loadUid warns but still returns a present uid when the sentinel is absent (fail-soft — dropping a valid UID on every torn save would be worse than a log line). - Unicast MAC invariant: `uid[0] & 0x01 == 0` at every assignment site - `M5.BtnA/B.wasPressed()` is non-consuming (pure read of a latched edge), so multiple consumers can observe the same press in one tick — current pattern: `markActivity()` (LCD wake) reads the edge, and the same `wasPressed()` derives a `silenceReq` flag passed into `batteryMonitor.tick(now, silenceReq)` (alarm silence; `tick()` no-ops the silence when tier==None or already silenced). Don't add a "consume" wrapper — the multi-observer model is the design. diff --git a/app/HDZap/HDZapApp.swift b/app/HDZap/HDZapApp.swift index c2c61da..9d419b5 100644 --- a/app/HDZap/HDZapApp.swift +++ b/app/HDZap/HDZapApp.swift @@ -7,6 +7,7 @@ struct HDZapApp: App { @State private var lapAnnouncer = LapAnnouncer() @State private var raceHistory = RaceHistoryStore() @State private var osdLayout = OSDLayoutSettings() + @State private var watchHeartRate = WatchHeartRateManager() /// One subscription manager shared across the whole app. The init triggers /// `Transaction.updates` listener registration via `start()` in onAppear — see body. @State private var subscription = SubscriptionManager() @@ -71,6 +72,7 @@ struct HDZapApp: App { .environment(lapAnnouncer) .environment(raceHistory) .environment(osdLayout) + .environment(watchHeartRate) .environment(subscription) .task { // Start the StoreKit2 listener once the SwiftUI scene is on screen — earlier diff --git a/app/HDZap/Models/RaceHeartRateSample.swift b/app/HDZap/Models/RaceHeartRateSample.swift new file mode 100644 index 0000000..0b65d88 --- /dev/null +++ b/app/HDZap/Models/RaceHeartRateSample.swift @@ -0,0 +1,36 @@ +import Foundation + +/// One Apple Watch heart-rate observation during a saved race. +/// +/// Mirrors `RaceFlightBatterySample`'s conventions so persistence, CSV +/// export, and chronological ordering behave identically across both +/// telemetry channels. The watch streams bpm over WatchConnectivity at +/// roughly 1 Hz while its workout session runs; `WatchHeartRateManager` +/// stages the latest value and `TimerView` snapshots it into the race. +struct RaceHeartRateSample: Codable, Equatable { + /// Seconds elapsed since race `startedAt`; non-negative within valid races. + let tRace: TimeInterval + /// Absolute wall-clock `.now` snapshot when the WatchConnectivity + /// message landed on the phone (watch→phone path latency, ~1 s). + let receivedAt: Date + /// Beats per minute as reported by HealthKit's live workout builder, + /// rounded to the nearest integer on the watch side. + let bpm: Int + + /// Stable in-race ordering: race time ascending, then receivedAt + /// ascending on ties. Same comparator shape as the flight-battery + /// channel so save sites can't desync ordering rules. + static func chronologicalLess(_ a: RaceHeartRateSample, + _ b: RaceHeartRateSample) -> Bool { + if a.tRace != b.tRace { return a.tRace < b.tRace } + return a.receivedAt < b.receivedAt + } +} + +extension Sequence where Element == RaceHeartRateSample { + /// Convenience for the canonical chronological order used at every + /// post-race save site. + func sortedChronologically() -> [RaceHeartRateSample] { + sorted(by: RaceHeartRateSample.chronologicalLess) + } +} diff --git a/app/HDZap/Models/RaceRecord+HeartRate.swift b/app/HDZap/Models/RaceRecord+HeartRate.swift new file mode 100644 index 0000000..05d4773 --- /dev/null +++ b/app/HDZap/Models/RaceRecord+HeartRate.swift @@ -0,0 +1,39 @@ +import Foundation + +extension RaceRecord { + /// CSV for Apple Watch heart-rate samples captured during this race. + func heartRateCSVText() -> String { + guard !heartRateSamples.isEmpty else { return "" } + var lines: [String] = [ + "t_race_s,received_at,bpm", + ] + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + for s in heartRateSamples { + lines.append([ + String(format: "%.3f", s.tRace), + iso.string(from: s.receivedAt), + "\(s.bpm)", + ].joined(separator: ",")) + } + return lines.joined(separator: "\n") + "\n" + } + + func heartRateSummaryLines() -> [String] { + guard let first = heartRateSamples.first else { return [] } + guard let last = heartRateSamples.last else { return [] } + let bpmMin = heartRateSamples.map(\.bpm).min() ?? last.bpm + let bpmMax = heartRateSamples.map(\.bpm).max() ?? last.bpm + let bpmAvg = Double(heartRateSamples.map(\.bpm).reduce(0, +)) + / Double(heartRateSamples.count) + return [ + "\(heartRateSamples.count) heart-rate samples", + String(format: "HR %d → %d bpm · min %d · avg %.0f · max %d", + first.bpm, + last.bpm, + bpmMin, + bpmAvg, + bpmMax), + ] + } +} diff --git a/app/HDZap/Models/RaceRecord.swift b/app/HDZap/Models/RaceRecord.swift index 0cd1206..7a85fb2 100644 --- a/app/HDZap/Models/RaceRecord.swift +++ b/app/HDZap/Models/RaceRecord.swift @@ -29,6 +29,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { let laps: [LapEntry] /// CRSF flight-pack battery telemetry captured during the race (may be empty). let flightBatterySamples: [RaceFlightBatterySample] + /// Apple Watch heart-rate samples captured during the race (may be empty). + let heartRateSamples: [RaceHeartRateSample] /// Memberwise init is `private` so every code path has to go through /// the validating factory or `init(from:)`. Anything else would let a @@ -40,7 +42,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { targetLapCount: Int, accentHue: Double, laps: [LapEntry], - flightBatterySamples: [RaceFlightBatterySample]) { + flightBatterySamples: [RaceFlightBatterySample], + heartRateSamples: [RaceHeartRateSample]) { self.id = id self.startedAt = startedAt self.endedAt = endedAt @@ -49,6 +52,7 @@ struct RaceRecord: Identifiable, Codable, Equatable { self.accentHue = accentHue self.laps = laps self.flightBatterySamples = flightBatterySamples + self.heartRateSamples = heartRateSamples } init(from decoder: Decoder) throws { @@ -62,6 +66,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { let laps = try c.decode([LapEntry].self, forKey: .laps) let flightBatterySamples = try c.decodeIfPresent([RaceFlightBatterySample].self, forKey: .flightBatterySamples) ?? [] + let heartRateSamples = try c.decodeIfPresent([RaceHeartRateSample].self, + forKey: .heartRateSamples) ?? [] try Self.validate(startedAt: startedAt, endedAt: endedAt, sessionLimit: sessionLimit, @@ -69,6 +75,7 @@ struct RaceRecord: Identifiable, Codable, Equatable { accentHue: accentHue, laps: laps, flightBatterySamples: flightBatterySamples, + heartRateSamples: heartRateSamples, coding: c) self.init(id: id, startedAt: startedAt, @@ -77,7 +84,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { targetLapCount: targetLapCount, accentHue: Self.normalizedHue(accentHue), laps: laps, - flightBatterySamples: flightBatterySamples) + flightBatterySamples: flightBatterySamples, + heartRateSamples: heartRateSamples) } var lapCount: Int { laps.count } @@ -119,7 +127,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { sessionLimit: TimeInterval, targetLapCount: Int, accentHue: Double, - flightBatterySamples: [RaceFlightBatterySample] = []) -> RaceRecord? { + flightBatterySamples: [RaceFlightBatterySample] = [], + heartRateSamples: [RaceHeartRateSample] = []) -> RaceRecord? { let entries = laps.map { LapEntry(id: $0.id, time: $0.time) } guard (try? validate(startedAt: startedAt, endedAt: endedAt, @@ -128,6 +137,7 @@ struct RaceRecord: Identifiable, Codable, Equatable { accentHue: accentHue, laps: entries, flightBatterySamples: flightBatterySamples, + heartRateSamples: heartRateSamples, coding: nil)) != nil else { return nil } @@ -139,7 +149,8 @@ struct RaceRecord: Identifiable, Codable, Equatable { targetLapCount: targetLapCount, accentHue: normalizedHue(accentHue), laps: entries, - flightBatterySamples: flightBatterySamples + flightBatterySamples: flightBatterySamples, + heartRateSamples: heartRateSamples ) } @@ -156,6 +167,7 @@ struct RaceRecord: Identifiable, Codable, Equatable { accentHue: Double, laps: [LapEntry], flightBatterySamples: [RaceFlightBatterySample], + heartRateSamples: [RaceHeartRateSample], coding: KeyedDecodingContainer?) throws { if endedAt < startedAt { try fail("endedAt < startedAt", key: .endedAt, coding: coding) @@ -199,6 +211,24 @@ struct RaceRecord: Identifiable, Codable, Equatable { } prevTRace = s.tRace } + var prevHeartTRace: TimeInterval? + for s in heartRateSamples { + if !s.tRace.isFinite { + try fail("heart rate tRace not finite", key: .heartRateSamples, coding: coding) + } + if s.tRace < -0.25 { + try fail("heart rate tRace out of range", key: .heartRateSamples, coding: coding) + } + // Generous physiological bounds — the point is to reject + // corrupted JSON, not to second-guess HealthKit. + if !(1...300).contains(s.bpm) { + try fail("heart rate bpm out of range", key: .heartRateSamples, coding: coding) + } + if let prev = prevHeartTRace, s.tRace + 1e-9 < prev { + try fail("heart rate samples not chronological", key: .heartRateSamples, coding: coding) + } + prevHeartTRace = s.tRace + } } private static func fail(_ reason: String, @@ -224,6 +254,7 @@ struct RaceRecord: Identifiable, Codable, Equatable { private enum CodingKeys: String, CodingKey { case id, startedAt, endedAt, sessionLimit, targetLapCount, accentHue, laps case flightBatterySamples + case heartRateSamples } } diff --git a/app/HDZap/Models/WatchHeartRateManager.swift b/app/HDZap/Models/WatchHeartRateManager.swift new file mode 100644 index 0000000..a07d2dd --- /dev/null +++ b/app/HDZap/Models/WatchHeartRateManager.swift @@ -0,0 +1,112 @@ +import Foundation +import Observation +import WatchConnectivity +import os + +/// Receives live heart-rate samples streamed by the HDZapWatch companion +/// app's workout session over WatchConnectivity, and relays race +/// START/STOP so the watch can auto-run its workout alongside the race. +/// +/// Mirrors `BluetoothManager`'s flight-battery telemetry surface so +/// `TimerView` can ingest both channels through the same pattern: +/// the latest sample is exposed as `lastHeartRateBpm` + +/// `lastHeartRateReceivedAt` (the pair moves in lockstep), and +/// `heartRateNotifyRevision` increments on every arrival so an +/// `.onChange` observer fires even when consecutive samples carry the +/// same bpm. +/// +/// WCSession delegate callbacks arrive on a background queue; every +/// mutation hops to the main actor first. `sendMessage` only reaches the +/// watch while the watch app is frontmost (`isReachable`), which is fine +/// here: the workout session keeps the watch app active for the whole +/// race, and samples that arrive while the phone app is backgrounded are +/// simply dropped by the OS — the race UI is foreground during a race +/// anyway. +@MainActor +@Observable +final class WatchHeartRateManager: NSObject { + /// Most recent bpm relayed from the watch. `nil` until the first + /// sample lands after launch. + private(set) var lastHeartRateBpm: Int? + /// Wall-clock timestamp of the most recent sample's arrival on the + /// phone. Moves in lockstep with `lastHeartRateBpm`. + private(set) var lastHeartRateReceivedAt: Date? + /// Increments on every sample arrival — the `.onChange` hook, same + /// role as `BluetoothManager.flightBatteryNotifyRevision`. + private(set) var heartRateNotifyRevision: UInt32 = 0 + /// True while the watch app is frontmost / workout-active and + /// messages can be exchanged. + private(set) var isWatchReachable = false + + private var session: WCSession? + + private static let log = Logger(subsystem: "sh.saqoo.HDZap", + category: "WatchHeartRateManager") + + override init() { + super.init() + // Not supported on iPad — `TARGETED_DEVICE_FAMILY` is iPhone-only + // today, but the guard keeps this safe if that ever changes. + guard WCSession.isSupported() else { return } + let s = WCSession.default + session = s + s.delegate = self + s.activate() + } + + /// Best-effort race-state relay: tells the watch to start/stop its + /// workout session in step with the phone's race. Silently no-ops + /// when the watch app isn't reachable — heart-rate capture is an + /// optional garnish on the race, never a blocker, so there's no + /// error surface beyond a debug log. + func sendRaceState(running: Bool) { + guard let s = session, s.activationState == .activated, s.isReachable else { return } + s.sendMessage(["race": running ? "start" : "stop"], replyHandler: nil) { error in + Self.log.debug("race-state relay failed: \(error.localizedDescription)") + } + } + + private func ingest(bpm: Int) { + lastHeartRateBpm = bpm + lastHeartRateReceivedAt = Date() + heartRateNotifyRevision &+= 1 + } + + private func updateReachability(_ reachable: Bool) { + isWatchReachable = reachable + } +} + +extension WatchHeartRateManager: WCSessionDelegate { + nonisolated func session(_ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error?) { + if let error { + Self.log.error("WCSession activation failed: \(error.localizedDescription)") + } + let reachable = session.isReachable + Task { @MainActor in self.updateReachability(reachable) } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in self.updateReachability(reachable) } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + // Watch switch: re-activate so the new paired watch can stream. + session.activate() + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + // Watch sends `["hr": Double, "ts": TimeInterval]` at ~1 Hz while + // its workout runs. `ts` (watch-side send time) is currently + // unused — arrival time on the phone is what anchors `tRace`, + // matching the flight-battery convention. + guard let bpm = message["hr"] as? Double, bpm.isFinite, bpm > 0 else { return } + let rounded = Int(bpm.rounded()) + Task { @MainActor in self.ingest(bpm: rounded) } + } +} diff --git a/app/HDZap/Views/RaceDetailView.swift b/app/HDZap/Views/RaceDetailView.swift index baa7c4b..25aca02 100644 --- a/app/HDZap/Views/RaceDetailView.swift +++ b/app/HDZap/Views/RaceDetailView.swift @@ -20,6 +20,8 @@ struct RaceDetailView: View { @State private var lastShareURL: URL? @State private var batteryShareItem: ShareItem? @State private var lastBatteryShareURL: URL? + @State private var heartRateShareItem: ShareItem? + @State private var lastHeartRateShareURL: URL? @State private var shareError: String? @State private var pendingDelete = false @@ -72,6 +74,12 @@ struct RaceDetailView: View { } .accessibilityLabel("Share flight battery CSV") } + if !record.heartRateSamples.isEmpty { + Button(action: { shareHeartRateCsvAction(record) }) { + Image(systemName: "heart.fill") + } + .accessibilityLabel("Share heart rate CSV") + } Button(action: shareAction) { Image(systemName: "square.and.arrow.up") } @@ -98,6 +106,7 @@ struct RaceDetailView: View { .onDisappear { cleanupShareTempFile() cleanupBatteryCsvTempFile() + cleanupHeartRateCsvTempFile() } .sheet(item: $shareItem, onDismiss: cleanupShareTempFile) { item in ShareSheet(url: item.url) @@ -105,6 +114,9 @@ struct RaceDetailView: View { .sheet(item: $batteryShareItem, onDismiss: cleanupBatteryCsvTempFile) { item in ShareSheet(url: item.url) } + .sheet(item: $heartRateShareItem, onDismiss: cleanupHeartRateCsvTempFile) { item in + ShareSheet(url: item.url) + } .alert( "Share Failed", isPresented: Binding( @@ -233,6 +245,37 @@ struct RaceDetailView: View { } } + private func cleanupHeartRateCsvTempFile() { + if let url = lastHeartRateShareURL { + ShareImageError.cleanupTempFile(at: url, log: Self.log) + lastHeartRateShareURL = nil + } + } + + /// Writes `record.heartRateCSVText()` to a temp `.csv` and opens the share sheet. + private func shareHeartRateCsvAction(_ record: RaceRecord) { + cleanupHeartRateCsvTempFile() + let csv = record.heartRateCSVText() + guard !csv.isEmpty else { return } + let base = RaceFormat.detailTitle.string(from: record.startedAt) + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ":", with: "-") + let name = "HDZap-heartrate-\(base).csv" + let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = dir.appendingPathComponent(name) + guard let data = csv.data(using: .utf8) else { + shareError = "Couldn't encode heart rate CSV as UTF-8." + return + } + do { + try data.write(to: url, options: .atomic) + lastHeartRateShareURL = url + heartRateShareItem = ShareItem(url: url) + } catch { + shareError = "Couldn't write heart rate CSV (\(error.localizedDescription))." + } + } + private static let log = Logger(subsystem: "sh.saqoo.HDZap", category: "RaceDetailView") } diff --git a/app/HDZap/Views/TimerView.swift b/app/HDZap/Views/TimerView.swift index f79d750..01a0c8f 100644 --- a/app/HDZap/Views/TimerView.swift +++ b/app/HDZap/Views/TimerView.swift @@ -14,6 +14,7 @@ struct TimerView: View { @Environment(LapAnnouncer.self) private var announcer @Environment(RaceHistoryStore.self) private var history @Environment(OSDLayoutSettings.self) private var osdLayout + @Environment(WatchHeartRateManager.self) private var watchHeartRate @AppStorage(RaceMetrics.targetLapCountStorageKey) private var targetLapCount = RaceMetrics.defaultTargetLapCount @AppStorage(RaceMetrics.raceSessionLimitStorageKey) private var raceSessionLimit: Int @@ -46,6 +47,8 @@ struct TimerView: View { @State private var savedRaceID: UUID? /// CRSF flight-pack battery samples for the in-memory session. @State private var raceFlightBatterySamples: [RaceFlightBatterySample] = [] + /// Apple Watch heart-rate samples for the in-memory session. + @State private var raceHeartRateSamples: [RaceHeartRateSample] = [] /// Captured once at the moment a lap is recorded. Kept stable so the /// displayed projection/diff doesn't tick every frame as the in-flight /// lap consumes the remaining window. Cleared on START and RESET. @@ -362,6 +365,15 @@ struct TimerView: View { // was rendering the all-visible buffer, so TIME LEFT could // land on the wrong grid row until the next full push. .onChange(of: lapTimer.isRunning) { _, running in + // Relay race state to the watch on every transition, before + // the BLE-readiness gate — the workout auto start/stop has + // no goggle dependency. Best-effort: silently no-ops when + // the watch app isn't reachable. + watchHeartRate.sendRaceState(running: running) + // Same race-start baseline idea as the flight battery below: + // a bpm cached from before START seeds the series at tRace=0 + // instead of waiting ~1 s for the next watch message. + if running { ingestHeartRate() } guard running, bluetooth.isReady else { return } pushOSDBuffer(osdLayout.snapshot, semanticRaws: [ RaceMetrics.timeLeftRaw(remainingSec: remaining), "", "", "", @@ -376,6 +388,9 @@ struct TimerView: View { .onChange(of: bluetooth.flightBatteryNotifyRevision) { _, _ in ingestFlightBatteryTelemetry() } + .onChange(of: watchHeartRate.heartRateNotifyRevision) { _, _ in + ingestHeartRate() + } // Haptic on LAP tap. Fires only on count growth so RESET (count → 0) // stays silent. `lastLapWasFinal` is set in `primaryAction()` before // the lap is recorded — reading `sessionEnded` here would depend on @@ -1170,6 +1185,7 @@ struct TimerView: View { lastLapAnnounced = lapTimer.elapsedTime >= sessionLimit - leadSec lapTimer.start() raceFlightBatterySamples.removeAll() + raceHeartRateSamples.removeAll() // Hold the audio session active for the whole race so each // announcement (start cue, countdown numbers, per-lap call, // final summary) is a bare `synthesizer.speak()` — without @@ -1292,6 +1308,7 @@ struct TimerView: View { readyShown = false savedRaceID = nil raceFlightBatterySamples.removeAll() + raceHeartRateSamples.removeAll() nextCountdownN = nil lastLapAnnounced = false } @@ -1381,6 +1398,28 @@ struct TimerView: View { raceFlightBatterySamples.append(sample) } + private func ingestHeartRate() { + guard lapTimer.isRunning, !sessionEnded, let started = lapTimer.sessionStartedAt else { return } + guard let bpm = watchHeartRate.lastHeartRateBpm else { return } + // Same anchoring + clamp rationale as the flight battery above: + // `lastHeartRateReceivedAt` moves in lockstep with the bpm, and a + // sample cached from BEFORE the race (watch workout already + // running during pre-race setup) lands the baseline at tRace = 0 + // instead of a negative value that would fail RaceRecord's + // validator and silently drop the whole record on save. + let receivedAt = watchHeartRate.lastHeartRateReceivedAt ?? Date() + let tRace = max(receivedAt, started).timeIntervalSince(started) + // Dedupe on arrival time, not on bpm: a steady heart rate is + // real 1 Hz series data worth keeping, but the race-start + // baseline call and the revision observer can both see the same + // staged sample. + if let previous = raceHeartRateSamples.last, + previous.receivedAt == receivedAt { return } + raceHeartRateSamples.append( + RaceHeartRateSample(tRace: tRace, receivedAt: receivedAt, bpm: bpm) + ) + } + private func saveRaceIfNeeded() { guard savedRaceID == nil else { return } #if DEBUG @@ -1400,13 +1439,15 @@ struct TimerView: View { return } let flightSamplesSorted = raceFlightBatterySamples.sortedChronologically() + let heartSamplesSorted = raceHeartRateSamples.sortedChronologically() guard let record = RaceRecord.snapshot( laps: lapTimer.laps, startedAt: startedAt, sessionLimit: sessionLimit, targetLapCount: clampedTargetLapCount, accentHue: accentHue, - flightBatterySamples: flightSamplesSorted + flightBatterySamples: flightSamplesSorted, + heartRateSamples: heartSamplesSorted ) else { // Empty / invalid sessions (timeUp without ever lapping) are // legitimately skipped, but log so the same skip doesn't diff --git a/app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/HDZapWatch/Assets.xcassets/Contents.json b/app/HDZapWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/app/HDZapWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/HDZapWatch/HDZapWatchApp.swift b/app/HDZapWatch/HDZapWatchApp.swift new file mode 100644 index 0000000..5d219b6 --- /dev/null +++ b/app/HDZapWatch/HDZapWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct HDZapWatchApp: App { + @State private var workout = WatchWorkoutManager() + + var body: some Scene { + WindowGroup { + WatchContentView() + .environment(workout) + } + } +} diff --git a/app/HDZapWatch/WatchContentView.swift b/app/HDZapWatch/WatchContentView.swift new file mode 100644 index 0000000..dc848bc --- /dev/null +++ b/app/HDZapWatch/WatchContentView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Single-screen companion UI: live bpm read-out + workout toggle. +/// The normal flow is hands-off — open this app before flying and the +/// phone's race START/STOP drives the workout — so the button mostly +/// exists for warm-up (get HR lock before the race) and recovery when +/// the relay message was missed. +struct WatchContentView: View { + @Environment(WatchWorkoutManager.self) private var workout + + var body: some View { + VStack(spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "heart.fill") + .foregroundStyle(.red) + .symbolEffect(.pulse, isActive: workout.isWorkoutRunning) + Text("HDZAP") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Text(workout.heartRateBpm.map(String.init) ?? "--") + .font(.system(size: 54, weight: .semibold, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText()) + + Text("BPM") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + + Button(workout.isWorkoutRunning ? "Stop" : "Start") { + workout.toggleWorkout() + } + .tint(workout.isWorkoutRunning ? .red : .green) + + if let error = workout.lastError { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + .lineLimit(2) + .minimumScaleFactor(0.7) + } + } + .padding(.horizontal, 4) + } +} diff --git a/app/HDZapWatch/WatchWorkoutManager.swift b/app/HDZapWatch/WatchWorkoutManager.swift new file mode 100644 index 0000000..0830dbc --- /dev/null +++ b/app/HDZapWatch/WatchWorkoutManager.swift @@ -0,0 +1,215 @@ +import Foundation +import HealthKit +import Observation +import WatchConnectivity +import os + +/// Runs the HealthKit workout session that makes the watch sample heart +/// rate at ~1 Hz, and streams each reading to the phone over +/// WatchConnectivity so `WatchHeartRateManager` (iOS) can stage it for +/// the race recorder. +/// +/// Why a workout session at all: outside one, watchOS samples HR only +/// every few minutes and syncs lazily — useless for a 90 s race. An +/// active `HKWorkoutSession` + `HKLiveWorkoutBuilder` is the only +/// supported way to get continuous live HR, and it also keeps this app +/// running with the wrist down for the whole flight. +/// +/// The phone relays race START/STOP as `["race": "start"|"stop"]` +/// messages (see `WatchHeartRateManager.sendRaceState`), so once this +/// app is open the workout follows the phone's race automatically; the +/// on-watch button is the manual fallback and the pre-race warm-up path. +/// +/// All state mutations run on the main actor; HealthKit and WCSession +/// delegate callbacks arrive on background queues and hop over first. +@MainActor +@Observable +final class WatchWorkoutManager: NSObject { + /// Latest bpm from the live workout builder; nil until the first + /// sample after a session starts (HR sensor lock-on takes a few + /// seconds) and cleared on teardown. + private(set) var heartRateBpm: Int? + private(set) var isWorkoutRunning = false + /// Human-readable last failure for the on-watch footer. Cleared on + /// a successful start. + private(set) var lastError: String? + + private let healthStore = HKHealthStore() + private var workoutSession: HKWorkoutSession? + private var builder: HKLiveWorkoutBuilder? + + private static let log = Logger(subsystem: "sh.saqoo.HDZap.watchkitapp", + category: "WatchWorkoutManager") + + override init() { + super.init() + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func toggleWorkout() { + isWorkoutRunning ? stopWorkout() : startWorkout() + } + + func startWorkout() { + guard !isWorkoutRunning, workoutSession == nil else { return } + guard HKHealthStore.isHealthDataAvailable() else { + lastError = "Health data unavailable" + return + } + // Share = the workout we save on finish; read = live HR. + // Requesting on every start is a cheap no-op once granted. + let share: Set = [HKObjectType.workoutType()] + let read: Set = [HKQuantityType(.heartRate)] + healthStore.requestAuthorization(toShare: share, read: read) { [weak self] granted, error in + Task { @MainActor [weak self] in + guard let self else { return } + if let error { + self.lastError = error.localizedDescription + return + } + guard granted else { + self.lastError = "Health access denied" + return + } + self.beginSession() + } + } + } + + func stopWorkout() { + // `end()` drives the delegate to `.ended`, which finishes the + // builder — teardown happens there so the two stop paths (button + // and phone relay) share one exit. + workoutSession?.end() + } + + private func beginSession() { + guard workoutSession == nil else { return } + let config = HKWorkoutConfiguration() + config.activityType = .other + config.locationType = .outdoor + do { + let session = try HKWorkoutSession(healthStore: healthStore, configuration: config) + let builder = session.associatedWorkoutBuilder() + builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, + workoutConfiguration: config) + session.delegate = self + builder.delegate = self + workoutSession = session + self.builder = builder + let start = Date() + session.startActivity(with: start) + builder.beginCollection(withStart: start) { [weak self] started, error in + Task { @MainActor [weak self] in + guard let self else { return } + guard started else { + self.lastError = error?.localizedDescription ?? "Couldn't start collection" + self.teardown() + return + } + self.isWorkoutRunning = true + self.lastError = nil + } + } + } catch { + lastError = error.localizedDescription + teardown() + } + } + + /// Called from the session delegate on the `.ended` transition. + /// Finishing (rather than discarding) saves the flight as a real + /// workout in the pilot's Activity history — the HR series stays + /// queryable in Health even if the phone missed messages. + private func finishCollection() { + guard let builder else { + teardown() + return + } + builder.endCollection(withEnd: Date()) { _, _ in + builder.finishWorkout { _, error in + if let error { + Self.log.error("finishWorkout failed: \(error.localizedDescription)") + } + Task { @MainActor [weak self] in self?.teardown() } + } + } + } + + private func teardown() { + workoutSession = nil + builder = nil + isWorkoutRunning = false + heartRateBpm = nil + } + + private func publish(bpm: Int) { + heartRateBpm = bpm + let session = WCSession.default + // Reachable == phone app foreground — exactly when a race can be + // recording. Drops outside that window are fine; this stream is + // a live feed, not a synced log (Health keeps the full series). + guard session.activationState == .activated, session.isReachable else { return } + session.sendMessage(["hr": Double(bpm), "ts": Date().timeIntervalSince1970], + replyHandler: nil) { error in + Self.log.debug("hr message failed: \(error.localizedDescription)") + } + } +} + +extension WatchWorkoutManager: HKWorkoutSessionDelegate { + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date) { + guard toState == .ended else { return } + Task { @MainActor in self.finishCollection() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, + didFailWithError error: Error) { + let message = error.localizedDescription + Task { @MainActor in + self.lastError = message + self.teardown() + } + } +} + +extension WatchWorkoutManager: HKLiveWorkoutBuilderDelegate { + nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, + didCollectDataOf collectedTypes: Set) { + let hrType = HKQuantityType(.heartRate) + guard collectedTypes.contains(hrType), + let quantity = workoutBuilder.statistics(for: hrType)?.mostRecentQuantity() else { return } + let bpm = quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + guard bpm.isFinite, bpm > 0 else { return } + let rounded = Int(bpm.rounded()) + Task { @MainActor in self.publish(bpm: rounded) } + } + + nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} +} + +extension WatchWorkoutManager: WCSessionDelegate { + nonisolated func session(_ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error?) { + if let error { + Self.log.error("WCSession activation failed: \(error.localizedDescription)") + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let race = message["race"] as? String else { return } + Task { @MainActor in + switch race { + case "start": self.startWorkout() + case "stop": self.stopWorkout() + default: break + } + } + } +} diff --git a/app/project.yml b/app/project.yml index e032e76..3ceefbd 100644 --- a/app/project.yml +++ b/app/project.yml @@ -23,6 +23,9 @@ targets: TARGETED_DEVICE_FAMILY: "1" sources: - HDZap + dependencies: + # watchOS companion — xcodegen embeds it via "Embed Watch Content". + - target: HDZapWatch info: path: HDZap/Info.plist properties: @@ -35,6 +38,37 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) UIRequiresFullScreen: YES UILaunchScreen: {} + # Companion app that streams live heart rate from an HKWorkoutSession to + # the phone over WatchConnectivity (see WatchHeartRateManager on iOS). + # Bundle id MUST stay `.watchkitapp` and + # WKCompanionAppBundleIdentifier MUST match the iOS bundle id, or the + # watch app won't install alongside HDZap. + HDZapWatch: + type: application + platform: watchOS + deploymentTarget: "11.0" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: sh.saqoo.HDZap.watchkitapp + TARGETED_DEVICE_FAMILY: "4" + sources: + - HDZapWatch + entitlements: + path: HDZapWatch/HDZapWatch.entitlements + properties: + com.apple.developer.healthkit: true + info: + path: HDZapWatch/Info.plist + properties: + WKApplication: true + WKCompanionAppBundleIdentifier: sh.saqoo.HDZap + WKBackgroundModes: + - workout-processing + NSHealthShareUsageDescription: "Read your heart rate during flight sessions so each race can be saved with it." + NSHealthUpdateUsageDescription: "Save each flight session as a workout." + CFBundleDisplayName: HDZap + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) schemes: HDZap: build: