feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -0,0 +1,267 @@
import Combine
import Foundation
import HealthKit
import WatchKit
public enum WatchCollectionMode: Equatable, Sendable {
case technicalMeasurement
case guidedMindAndBody(saveToHealth: Bool)
}
public enum WatchMeasurementState: Equatable, Sendable {
case idle
case authorizing
case waitingForFirstSample(secondsRemaining: Int)
case collecting(progress: Double)
case finishing
case completed(WatchMeasurementSummaryV1)
case failed(String)
}
@MainActor
public final class WatchHealthMeasurementService: NSObject, ObservableObject {
@Published public private(set) var state: WatchMeasurementState = .idle
@Published public private(set) var currentBPM: Double?
@Published public private(set) var samples: [WatchHeartRateSampleV1] = []
@Published public private(set) var lastWorkoutUUID: UUID?
public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)?
public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)?
private let healthStore: HKHealthStore
private let arbiter: WatchResourceArbiter
private var workoutSession: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var collectionMode: WatchCollectionMode = .technicalMeasurement
private var startedAt: Date?
private var validCollectionStartedAt: Date?
private var timeoutTask: Task<Void, Never>?
private var finishTask: Task<Void, Never>?
private var collectionDurationSeconds: TimeInterval = 60
public init(
healthStore: HKHealthStore = HKHealthStore(),
arbiter: WatchResourceArbiter = .shared
) {
self.healthStore = healthStore
self.arbiter = arbiter
super.init()
}
public func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable(),
let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else {
throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."])
}
state = .authorizing
try await healthStore.requestAuthorization(
toShare: [HKObjectType.workoutType()],
read: [heartRate]
)
state = .idle
}
public func start(
mode: WatchCollectionMode = .technicalMeasurement,
collectionDurationSeconds: TimeInterval = 60
) async throws {
guard case .idle = state else { return }
try await arbiter.acquire(.heartRateMeasurement)
do {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .mindAndBody
configuration.locationType = .unknown
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
session.delegate = self
builder.delegate = self
workoutSession = session
self.builder = builder
collectionMode = mode
self.collectionDurationSeconds = max(60, collectionDurationSeconds)
samples = []
currentBPM = nil
lastWorkoutUUID = nil
let now = Date()
startedAt = now
validCollectionStartedAt = nil
state = .waitingForFirstSample(secondsRemaining: 30)
session.startActivity(with: now)
try await builder.beginCollection(at: now)
scheduleFirstSampleTimeout()
} catch {
await arbiter.forceRelease()
state = .failed(error.localizedDescription)
throw error
}
}
public func cancel() {
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let builder = builder
Task { @MainActor [weak self] in
if let builder {
try? await builder.endCollection(at: Date())
builder.discardWorkout()
}
await self?.arbiter.forceRelease()
self?.resetResources()
self?.state = .idle
}
}
public func finish(interrupted: Bool = false) {
guard builder != nil else { return }
state = .finishing
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let endDate = Date()
let startDate = startedAt ?? endDate
let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted)
let summary = WatchMeasurementSummaryV1(
startedAt: startDate,
endedAt: endDate,
device: WatchDeviceIdentityV1(
id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch",
name: WKInterfaceDevice.current().name,
model: WKInterfaceDevice.current().model,
systemVersion: WKInterfaceDevice.current().systemVersion
),
currentBPM: validation.currentBPM,
averageBPM: validation.averageBPM,
minimumBPM: validation.minimumBPM,
maximumBPM: validation.maximumBPM,
samples: validation.validSamples,
coverageSeconds: validation.coverageSeconds,
quality: validation.quality,
isValid: validation.isValid,
invalidReasons: validation.invalidReasons
)
let mode = collectionMode
let builder = self.builder
finishTask = Task { @MainActor [weak self] in
guard let self, let builder else { return }
do {
try await builder.endCollection(at: endDate)
switch mode {
case .technicalMeasurement:
builder.discardWorkout()
case let .guidedMindAndBody(saveToHealth):
if saveToHealth {
self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid
} else {
builder.discardWorkout()
}
}
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .completed(summary)
self.onCompleted?(summary)
} catch {
builder.discardWorkout()
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .failed(error.localizedDescription)
}
}
}
public func resetAfterResult() {
guard workoutSession == nil else { return }
state = .idle
samples = []
currentBPM = nil
lastWorkoutUUID = nil
}
private func scheduleFirstSampleTimeout() {
timeoutTask?.cancel()
timeoutTask = Task { @MainActor [weak self] in
for remaining in stride(from: 30, through: 1, by: -1) {
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.state = .waitingForFirstSample(secondsRemaining: remaining)
try? await Task.sleep(for: .seconds(1))
}
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.finish()
}
}
private func scheduleCollectionFinish(from firstSampleDate: Date) {
timeoutTask?.cancel()
finishTask?.cancel()
finishTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
let elapsed = Date().timeIntervalSince(firstSampleDate)
self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds))
if elapsed >= self.collectionDurationSeconds {
self.finish()
return
}
try? await Task.sleep(for: .milliseconds(250))
}
}
}
private func acceptHeartRate(_ bpm: Double, at date: Date) {
guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return }
let sample = WatchHeartRateSampleV1(date: date, bpm: bpm)
samples.append(sample)
currentBPM = bpm
onLiveHeartRate?(sample)
if validCollectionStartedAt == nil {
validCollectionStartedAt = date
scheduleCollectionFinish(from: date)
}
}
private func resetResources(keepSamples: Bool = false) {
workoutSession = nil
builder = nil
startedAt = nil
validCollectionStartedAt = nil
timeoutTask = nil
finishTask = nil
if !keepSamples { samples = [] }
}
}
extension WatchHealthMeasurementService: HKWorkoutSessionDelegate {
nonisolated public func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {}
nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
Task { @MainActor [weak self] in
self?.finish(interrupted: true)
}
}
}
extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate {
nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
nonisolated public func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
collectedTypes.contains(heartRateType),
let statistics = workoutBuilder.statistics(for: heartRateType),
let quantity = statistics.mostRecentQuantity() else { return }
let unit = HKUnit.count().unitDivided(by: .minute())
let bpm = quantity.doubleValue(for: unit)
Task { @MainActor [weak self] in
self?.acceptHeartRate(bpm, at: Date())
}
}
}