Files
haptika/ios/HaptikaWatch/Services/WatchHealthMeasurementService.swift
2026-07-31 16:20:10 +02:00

268 lines
8.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}
}