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,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())
}
}
}