268 lines
8.9 KiB
Swift
268 lines
8.9 KiB
Swift
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())
|
||
}
|
||
}
|
||
}
|