feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
public final class WatchConnectivityService: NSObject, ObservableObject {
|
||||
@Published public private(set) var activationState: WCSessionActivationState = .notActivated
|
||||
@Published public private(set) var isReachable = false
|
||||
@Published public private(set) var lastSynchronizedAt: Date?
|
||||
@Published public private(set) var lastError: String?
|
||||
|
||||
public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)?
|
||||
public var onAcknowledgement: ((Set<UUID>) -> Void)?
|
||||
|
||||
private let session: WCSession?
|
||||
private let encoder = WatchCoding.encoder
|
||||
private let decoder = WatchCoding.decoder
|
||||
|
||||
public override init() {
|
||||
session = WCSession.isSupported() ? .default : nil
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func activate() {
|
||||
guard let session else { return }
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
activationState = session.activationState
|
||||
isReachable = session.isReachable
|
||||
}
|
||||
|
||||
public func sendLive(_ envelope: WatchSyncEnvelopeV1) {
|
||||
guard let session, session.isReachable else { return }
|
||||
do {
|
||||
let data = try encoder.encode(envelope)
|
||||
session.sendMessageData(data, replyHandler: nil) { [weak self] error in
|
||||
Task { @MainActor in self?.lastError = error.localizedDescription }
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline.
|
||||
public func transfer(_ envelope: WatchSyncEnvelopeV1) {
|
||||
guard let session else { return }
|
||||
do {
|
||||
session.transferUserInfo(["envelope": try encoder.encode(envelope)])
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public func flush(outbox: [WatchSyncEnvelopeV1]) {
|
||||
for envelope in outbox { transfer(envelope) }
|
||||
}
|
||||
|
||||
public func updateApplicationContext(preferences: WatchPreferencesV1) {
|
||||
guard let session else { return }
|
||||
do {
|
||||
try session.updateApplicationContext(["preferences": try encoder.encode(preferences)])
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(_ data: Data) {
|
||||
do {
|
||||
let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data)
|
||||
lastSynchronizedAt = Date()
|
||||
if envelope.kind == .acknowledgement,
|
||||
let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) {
|
||||
onAcknowledgement?(Set(acknowledgement.eventIDs))
|
||||
} else {
|
||||
onEnvelope?(envelope)
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchConnectivityService: WCSessionDelegate {
|
||||
nonisolated public func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activationState = activationState
|
||||
self?.isReachable = session.isReachable
|
||||
self?.lastError = error?.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
Task { @MainActor [weak self] in self?.isReachable = session.isReachable }
|
||||
}
|
||||
|
||||
nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
|
||||
Task { @MainActor [weak self] in self?.receive(messageData) }
|
||||
}
|
||||
|
||||
nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
guard let data = userInfo["envelope"] as? Data else { return }
|
||||
Task { @MainActor [weak self] in self?.receive(data) }
|
||||
}
|
||||
|
||||
nonisolated public func session(
|
||||
_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]
|
||||
) {
|
||||
guard let data = applicationContext["preferences"] as? Data else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self,
|
||||
let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return }
|
||||
let payload = try? self.encoder.encode(preferences)
|
||||
if let payload {
|
||||
self.onEnvelope?(.init(
|
||||
deviceID: "iphone",
|
||||
sequence: 0,
|
||||
kind: .preferences,
|
||||
payload: payload
|
||||
))
|
||||
}
|
||||
self.lastSynchronizedAt = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import WatchKit
|
||||
|
||||
@MainActor
|
||||
public final class WatchHapticService: ObservableObject {
|
||||
@Published public private(set) var isRunning = false
|
||||
@Published public private(set) var elapsedSeconds: TimeInterval = 0
|
||||
|
||||
private let arbiter: WatchResourceArbiter
|
||||
private var task: Task<Void, Never>?
|
||||
private var activeRunID: UUID?
|
||||
|
||||
public init(arbiter: WatchResourceArbiter = .shared) {
|
||||
self.arbiter = arbiter
|
||||
}
|
||||
|
||||
public func start(protocol regulation: WatchRegulationProtocol) async throws {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
activeRunID = nil
|
||||
await arbiter.forceRelease()
|
||||
try await arbiter.acquire(.hapticRegulation)
|
||||
let runID = UUID()
|
||||
activeRunID = runID
|
||||
isRunning = true
|
||||
elapsedSeconds = 0
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
|
||||
task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
Task { @MainActor in await self.finishRun(runID) }
|
||||
}
|
||||
|
||||
if regulation.id == "sosStress" {
|
||||
for _ in 0..<3 {
|
||||
guard !Task.isCancelled else { return }
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
}
|
||||
}
|
||||
|
||||
var previousPhaseIndex = -1
|
||||
var previousCycleIndex = -1
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled {
|
||||
let elapsed = Date().timeIntervalSince(startedAt)
|
||||
self.elapsedSeconds = min(regulation.durationSeconds, elapsed)
|
||||
if elapsed >= regulation.durationSeconds {
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
return
|
||||
}
|
||||
|
||||
if regulation.id == "sosStress" {
|
||||
let second = Int(elapsed.rounded(.down))
|
||||
if second > 0 && second.isMultiple(of: 10) {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
continue
|
||||
}
|
||||
} else if let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed),
|
||||
position.phaseIndex != previousPhaseIndex || position.cycleIndex != previousCycleIndex {
|
||||
if previousPhaseIndex >= 0 { WKInterfaceDevice.current().play(.click) }
|
||||
previousPhaseIndex = position.phaseIndex
|
||||
previousCycleIndex = position.cycleIndex
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func startCardiacMirror(
|
||||
initialBPM: Double,
|
||||
durationSeconds: TimeInterval = 120,
|
||||
decelerationBPMPerMinute: Double = 1.5
|
||||
) async throws {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
activeRunID = nil
|
||||
await arbiter.forceRelease()
|
||||
try await arbiter.acquire(.hapticRegulation)
|
||||
let runID = UUID()
|
||||
activeRunID = runID
|
||||
isRunning = true
|
||||
elapsedSeconds = 0
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
Task { @MainActor in await self.finishRun(runID) }
|
||||
}
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled {
|
||||
let elapsed = Date().timeIntervalSince(startedAt)
|
||||
self.elapsedSeconds = min(durationSeconds, elapsed)
|
||||
guard elapsed < durationSeconds else {
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
return
|
||||
}
|
||||
let targetBPM = max(35, initialBPM - decelerationBPMPerMinute * elapsed / 60)
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
try? await Task.sleep(for: .seconds(60 / targetBPM))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel(playStop: Bool = true) {
|
||||
guard task != nil || isRunning else { return }
|
||||
task?.cancel()
|
||||
task = nil
|
||||
activeRunID = nil
|
||||
isRunning = false
|
||||
if playStop { WKInterfaceDevice.current().play(.stop) }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.activeRunID == nil else { return }
|
||||
await self.arbiter.forceRelease()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRun(_ runID: UUID) async {
|
||||
guard activeRunID == runID else { return }
|
||||
activeRunID = nil
|
||||
task = nil
|
||||
isRunning = false
|
||||
try? await arbiter.release(.hapticRegulation)
|
||||
}
|
||||
}
|
||||
@@ -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