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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<iOS bundle id>.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.
Expand Down
2 changes: 2 additions & 0 deletions app/HDZap/HDZapApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions app/HDZap/Models/RaceHeartRateSample.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
39 changes: 39 additions & 0 deletions app/HDZap/Models/RaceRecord+HeartRate.swift
Original file line number Diff line number Diff line change
@@ -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),
]
}
}
39 changes: 35 additions & 4 deletions app/HDZap/Models/RaceRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -62,13 +66,16 @@ 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,
targetLapCount: targetLapCount,
accentHue: accentHue,
laps: laps,
flightBatterySamples: flightBatterySamples,
heartRateSamples: heartRateSamples,
coding: c)
self.init(id: id,
startedAt: startedAt,
Expand All @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -128,6 +137,7 @@ struct RaceRecord: Identifiable, Codable, Equatable {
accentHue: accentHue,
laps: entries,
flightBatterySamples: flightBatterySamples,
heartRateSamples: heartRateSamples,
coding: nil)) != nil else {
return nil
}
Expand All @@ -139,7 +149,8 @@ struct RaceRecord: Identifiable, Codable, Equatable {
targetLapCount: targetLapCount,
accentHue: normalizedHue(accentHue),
laps: entries,
flightBatterySamples: flightBatterySamples
flightBatterySamples: flightBatterySamples,
heartRateSamples: heartRateSamples
)
}

Expand All @@ -156,6 +167,7 @@ struct RaceRecord: Identifiable, Codable, Equatable {
accentHue: Double,
laps: [LapEntry],
flightBatterySamples: [RaceFlightBatterySample],
heartRateSamples: [RaceHeartRateSample],
coding: KeyedDecodingContainer<CodingKeys>?) throws {
if endedAt < startedAt {
try fail("endedAt < startedAt", key: .endedAt, coding: coding)
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}

Expand Down
112 changes: 112 additions & 0 deletions app/HDZap/Models/WatchHeartRateManager.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Loading