feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user