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,181 @@
import Foundation
public enum RegulationFamily: String, Codable, Sendable {
case breathe
case ground
case recenter
}
public enum RegulationMode: String, Codable, Sendable {
case haptic
case liveHeartRate
}
public enum RegulationPhaseKind: String, Codable, Sendable {
case inhale
case hold
case exhale
case anchor
}
public struct RegulationPhase: Codable, Equatable, Sendable, Identifiable {
public var id: String { "\(kind.rawValue)-\(durationSeconds)-\(label)" }
public let kind: RegulationPhaseKind
public let durationSeconds: TimeInterval
public let label: String
public init(kind: RegulationPhaseKind, durationSeconds: TimeInterval, label: String) {
self.kind = kind
self.durationSeconds = durationSeconds
self.label = label
}
}
public struct WatchRegulationProtocol: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let version: Int
public let family: RegulationFamily
public let mode: RegulationMode
public let eyebrow: String
public let title: String
public let summary: String
public let durationSeconds: TimeInterval
public let phases: [RegulationPhase]
public init(
id: String,
version: Int = 1,
family: RegulationFamily,
mode: RegulationMode,
eyebrow: String,
title: String,
summary: String,
durationSeconds: TimeInterval,
phases: [RegulationPhase]
) {
self.id = id
self.version = version
self.family = family
self.mode = mode
self.eyebrow = eyebrow
self.title = title
self.summary = summary
self.durationSeconds = durationSeconds
self.phases = phases
}
}
public enum WatchProtocolCatalog {
private struct Manifest: Decodable {
let schemaVersion: Int
let protocols: [ManifestProtocol]
}
private struct ManifestProtocol: Decodable {
let kind: String
let family: RegulationFamily
let mode: String
let eyebrow: String
let title: String
let description: String
let durationSeconds: TimeInterval
let phases: [RegulationPhase]
var watchProtocol: WatchRegulationProtocol {
WatchRegulationProtocol(
id: kind,
version: 1,
family: family,
mode: mode == "haptic" ? .haptic : .liveHeartRate,
eyebrow: eyebrow,
title: title,
summary: description,
durationSeconds: durationSeconds,
phases: phases
)
}
}
public static let protocols: [WatchRegulationProtocol] = loadBundledManifest() ?? fallbackProtocols
private static let fallbackProtocols: [WatchRegulationProtocol] = [
.init(id: "myCoherence", family: .breathe, mode: .liveHeartRate, eyebrow: "BIOFEEDBACK", title: "Ma Cohérence", summary: "Respiration visuelle 5–5 avec tendance cardiaque.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez"),
]),
.init(id: "coherence55", family: .breathe, mode: .haptic, eyebrow: "ÉQUILIBRE", title: "Cohérence 5–5", summary: "Une respiration régulière pour retrouver un rythme calme.", durationSeconds: 300, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez lentement"),
]),
.init(id: "box4444", family: .breathe, mode: .haptic, eyebrow: "STABILITÉ", title: "Respiration carrée", summary: "Quatre temps égaux pour structurer l’attention.", durationSeconds: 240, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 4, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 4, label: "Expirez"),
.init(kind: .hold, durationSeconds: 4, label: "Restez vide"),
]),
.init(id: "breathing478", family: .breathe, mode: .haptic, eyebrow: "RALENTISSEMENT", title: "Respiration 4–7–8", summary: "Un cycle court avec une expiration longue.", durationSeconds: 76, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 7, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 8, label: "Expirez lentement"),
]),
.init(id: "relax46", family: .breathe, mode: .haptic, eyebrow: "RELÂCHEMENT", title: "Expiration 4–6", summary: "Une expiration plus longue pour accompagner la détente.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 6, label: "Expirez lentement"),
]),
.init(id: "sosStress", family: .recenter, mode: .haptic, eyebrow: "RETOUR AU CALME", title: "SOS tension", summary: "Trois impulsions puis un repère toutes les dix secondes.", durationSeconds: 62, phases: [
.init(kind: .anchor, durationSeconds: 62, label: "Restez avec la sensation"),
]),
.init(id: "microAnchor", family: .ground, mode: .haptic, eyebrow: "FORMAT COURT", title: "Micro-ancrage", summary: "Un repère tactile discret pour revenir aux sensations.", durationSeconds: 37, phases: [
.init(kind: .anchor, durationSeconds: 37, label: "Sentez les points de contact"),
]),
]
public static func protocolWithID(_ id: String) -> WatchRegulationProtocol? {
protocols.first { $0.id == id }
}
private static func loadBundledManifest(bundle: Bundle = .main) -> [WatchRegulationProtocol]? {
guard let url = bundle.url(forResource: "regulation-protocols.v1", withExtension: "json"),
let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
manifest.schemaVersion == 1,
manifest.protocols.count == 7 else {
return nil
}
return manifest.protocols.map(\.watchProtocol)
}
}
public struct RegulationPhasePosition: Equatable, Sendable {
public let phaseIndex: Int
public let phase: RegulationPhase
public let progress: Double
public let cycleIndex: Int
}
public enum RegulationPhaseEngine {
public static func position(
in regulation: WatchRegulationProtocol,
elapsedSeconds: TimeInterval
) -> RegulationPhasePosition? {
guard !regulation.phases.isEmpty else { return nil }
let cycleDuration = regulation.phases.reduce(0) { $0 + $1.durationSeconds }
guard cycleDuration > 0 else { return nil }
let clamped = max(0, min(elapsedSeconds, max(0, regulation.durationSeconds - 0.001)))
let cycleElapsed = clamped.truncatingRemainder(dividingBy: cycleDuration)
var cursor: TimeInterval = 0
for (index, phase) in regulation.phases.enumerated() {
let end = cursor + phase.durationSeconds
if cycleElapsed < end || index == regulation.phases.count - 1 {
return .init(
phaseIndex: index,
phase: phase,
progress: min(1, max(0, (cycleElapsed - cursor) / phase.durationSeconds)),
cycleIndex: Int(clamped / cycleDuration)
)
}
cursor = end
}
return nil
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
public enum WatchBaselineMaturity: String, Codable, Sendable {
case unavailable
case provisional
case personalized
}
public enum WatchBaselineComparison: String, Codable, Sendable {
case unavailable
case usual
case slightlyDifferent
case clearlyDifferent
}
public struct WatchBaselineSnapshot: Codable, Equatable, Sendable {
public let maturity: WatchBaselineMaturity
public let comparableMeasurements: Int
public let medianBPM: Double?
public let medianAbsoluteDeviation: Double?
public init(measurements: [WatchMeasurementSummaryV1]) {
let values = measurements
.filter { $0.source == .appleWatchOptical && $0.isValid && $0.quality >= 0.65 && $0.context.isBaselineComparable }
.compactMap(\.averageBPM)
.filter(\.isFinite)
comparableMeasurements = values.count
medianBPM = Self.median(values)
medianAbsoluteDeviation = medianBPM.map { median in
Self.median(values.map { abs($0 - median) }) ?? 0
}
if values.count >= 10 {
maturity = .personalized
} else if values.count >= 5 {
maturity = .provisional
} else {
maturity = .unavailable
}
}
public func compare(bpm: Double?) -> WatchBaselineComparison {
guard maturity != .unavailable, let bpm, bpm.isFinite, let medianBPM else {
return .unavailable
}
let scale = max(medianAbsoluteDeviation ?? 0, 2)
let robustDistance = abs(bpm - medianBPM) / scale
if robustDistance < 2 { return .usual }
if robustDistance < 4 { return .slightlyDifferent }
return .clearlyDifferent
}
private static func median(_ values: [Double]) -> Double? {
guard !values.isEmpty else { return nil }
let sorted = values.sorted()
let middle = sorted.count / 2
return sorted.count.isMultiple(of: 2)
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
}
public extension WatchBaselineComparison {
var frenchLabel: String {
switch self {
case .unavailable: "Baseline en construction"
case .usual: "Habituel"
case .slightlyDifferent: "Légèrement différent"
case .clearlyDifferent: "Clairement différent"
}
}
}
@@ -0,0 +1,73 @@
import Foundation
public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable {
case noValidHeartRate
case insufficientSamples
case insufficientCoverage
case staleData
case interrupted
}
public struct WatchMeasurementValidation: Equatable, Sendable {
public let validSamples: [WatchHeartRateSampleV1]
public let currentBPM: Double?
public let averageBPM: Double?
public let minimumBPM: Double?
public let maximumBPM: Double?
public let coverageSeconds: TimeInterval
public let maximumGapSeconds: TimeInterval
public let quality: Double
public let invalidReasons: [WatchMeasurementInvalidReason]
public var isValid: Bool { invalidReasons.isEmpty }
}
public enum WatchMeasurementValidator {
public static let minimumBPM = 35.0
public static let maximumBPM = 220.0
public static let minimumSamples = 8
public static let minimumCoverageSeconds = 45.0
public static let maximumSilenceSeconds = 15.0
public static let targetDurationSeconds = 60.0
public static func validate(
samples: [WatchHeartRateSampleV1],
interrupted: Bool = false
) -> WatchMeasurementValidation {
let filtered = samples
.filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) }
.sorted { $0.date < $1.date }
let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0)
let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) }
let maximumGap = max(0, gaps.max() ?? 0)
let bpms = filtered.map(\.bpm)
let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count)
var reasons: [WatchMeasurementInvalidReason] = []
if filtered.isEmpty { reasons.append(.noValidHeartRate) }
if filtered.count < minimumSamples { reasons.append(.insufficientSamples) }
if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) }
if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) }
if interrupted { reasons.append(.interrupted) }
let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples))
let coverageScore = min(1, coverage / targetDurationSeconds)
let continuityScore = maximumGap <= maximumSilenceSeconds
? 1
: max(0, maximumSilenceSeconds / maximumGap)
let quality = min(1, max(0, sampleScore * coverageScore * continuityScore))
return WatchMeasurementValidation(
validSamples: filtered,
currentBPM: filtered.last?.bpm,
averageBPM: average,
minimumBPM: bpms.min(),
maximumBPM: bpms.max(),
coverageSeconds: coverage,
maximumGapSeconds: maximumGap,
quality: quality,
invalidReasons: reasons
)
}
}
+377
View File
@@ -0,0 +1,377 @@
import Foundation
public enum MeasurementSource: String, Codable, CaseIterable, Sendable {
case iphoneCameraPPG
case appleWatchOptical
}
public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
public var date: Date
public var bpm: Double
public init(id: UUID = UUID(), date: Date, bpm: Double) {
self.id = id
self.date = date
self.bpm = bpm
}
}
public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable {
public var id: String
public var name: String
public var model: String?
public var systemVersion: String?
public init(id: String, name: String, model: String?, systemVersion: String?) {
self.id = id
self.name = name
self.model = model
self.systemVersion = systemVersion
}
private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) }
if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) }
}
}
public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable {
case seated
case standing
case lying
case unknown
}
public struct WatchMeasurementContextV1: Codable, Equatable, Sendable {
public var posture: WatchMeasurementPosture
public var isAtRest: Bool
public var recentCaffeine: Bool
public var recentMeal: Bool
public var recentExercise: Bool
public var note: String?
public init(
posture: WatchMeasurementPosture = .unknown,
isAtRest: Bool = true,
recentCaffeine: Bool = false,
recentMeal: Bool = false,
recentExercise: Bool = false,
note: String? = nil
) {
self.posture = posture
self.isAtRest = isAtRest
self.recentCaffeine = recentCaffeine
self.recentMeal = recentMeal
self.recentExercise = recentExercise
self.note = note
}
public var isBaselineComparable: Bool {
isAtRest && !recentCaffeine && !recentMeal && !recentExercise
}
}
public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID
public var updatedAt: Date
public var startedAt: Date
public var endedAt: Date
public var source: MeasurementSource
public var device: WatchDeviceIdentityV1
public var currentBPM: Double?
public var averageBPM: Double?
public var minimumBPM: Double?
public var maximumBPM: Double?
public var samples: [WatchHeartRateSampleV1]
public var coverageSeconds: TimeInterval
public var quality: Double
public var isValid: Bool
public var invalidReasons: [WatchMeasurementInvalidReason]
public var context: WatchMeasurementContextV1
public var feeling: Int?
public var feelingRecordedAt: Date?
public init(
id: UUID = UUID(),
updatedAt: Date = Date(),
startedAt: Date,
endedAt: Date,
source: MeasurementSource = .appleWatchOptical,
device: WatchDeviceIdentityV1,
currentBPM: Double?,
averageBPM: Double?,
minimumBPM: Double?,
maximumBPM: Double?,
samples: [WatchHeartRateSampleV1],
coverageSeconds: TimeInterval,
quality: Double,
isValid: Bool,
invalidReasons: [WatchMeasurementInvalidReason] = [],
context: WatchMeasurementContextV1 = .init(),
feeling: Int? = nil,
feelingRecordedAt: Date? = nil
) {
self.id = id
self.updatedAt = updatedAt
self.startedAt = startedAt
self.endedAt = endedAt
self.source = source
self.device = device
self.currentBPM = currentBPM
self.averageBPM = averageBPM
self.minimumBPM = minimumBPM
self.maximumBPM = maximumBPM
self.samples = samples
self.coverageSeconds = coverageSeconds
self.quality = quality
self.isValid = isValid
self.invalidReasons = invalidReasons
self.context = context
self.feeling = feeling
self.feelingRecordedAt = feelingRecordedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, updatedAt, startedAt, endedAt, source, device
case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality
case valid, invalidReason, context, feeling
}
private struct WireSample: Codable {
let offsetSeconds: TimeInterval
let bpm: Double
}
private struct WireContext: Codable {
let atRest: Bool
let posture: String?
let recentExercise: Bool?
let recentCaffeine: Bool?
let recentMeal: Bool?
}
private struct WireFeeling: Codable {
let score: Int
let recordedAt: Date
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(source, forKey: .source)
try container.encode(device, forKey: .device)
if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) }
else { try container.encodeNil(forKey: .currentBpm) }
try container.encodeIfPresent(averageBPM, forKey: .averageBpm)
try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm)
try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm)
try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples)
try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage)
try container.encode(quality, forKey: .quality)
try container.encode(isValid, forKey: .valid)
if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) }
else { try container.encodeNil(forKey: .invalidReason) }
try container.encode(WireContext(
atRest: context.isAtRest,
posture: context.posture == .unknown ? nil : context.posture.rawValue,
recentExercise: context.recentExercise,
recentCaffeine: context.recentCaffeine,
recentMeal: context.recentMeal
), forKey: .context)
if let feeling {
try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling)
} else {
try container.encodeNil(forKey: .feeling)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema")
}
id = try container.decode(UUID.self, forKey: .id)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
source = try container.decode(MeasurementSource.self, forKey: .source)
device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device)
currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm)
averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm)
minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm)
maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm)
let wireSamples = try container.decode([WireSample].self, forKey: .samples)
let decodedStartedAt = startedAt
samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) }
coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60
quality = try container.decode(Double.self, forKey: .quality)
isValid = try container.decode(Bool.self, forKey: .valid)
invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason))
.flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? []
let wireContext = try container.decode(WireContext.self, forKey: .context)
context = .init(
posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown,
isAtRest: wireContext.atRest,
recentCaffeine: wireContext.recentCaffeine ?? false,
recentMeal: wireContext.recentMeal ?? false,
recentExercise: wireContext.recentExercise ?? false
)
let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling)
feeling = wireFeeling?.score
feelingRecordedAt = wireFeeling?.recordedAt
}
}
public enum WatchRegulationSessionState: String, Codable, Sendable {
case inProgress
case awaitingAfter
case completed
case cancelled
}
public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable {
public static let currentSchemaVersion = 1
public let schemaVersion = 1
public var id: UUID
public var protocolID: String
public var protocolVersion: Int = 1
public var startedAt: Date
public var endedAt: Date
public var durationSeconds: TimeInterval
public var state: WatchRegulationSessionState
public var beforeMeasurementID: UUID?
public var afterMeasurementID: UUID?
public var feelingBefore: Int?
public var feelingAfter: Int?
public var healthKitWorkoutID: UUID?
public var updatedAt: Date
public init(
id: UUID = UUID(),
protocolID: String,
protocolVersion: Int = 1,
startedAt: Date,
endedAt: Date,
durationSeconds: TimeInterval,
state: WatchRegulationSessionState,
beforeMeasurementID: UUID? = nil,
afterMeasurementID: UUID? = nil,
feelingBefore: Int? = nil,
feelingAfter: Int? = nil,
healthKitWorkoutID: UUID? = nil,
updatedAt: Date = Date()
) {
self.id = id
self.protocolID = protocolID
self.protocolVersion = protocolVersion
self.startedAt = startedAt
self.endedAt = endedAt
self.durationSeconds = durationSeconds
self.state = state
self.beforeMeasurementID = beforeMeasurementID
self.afterMeasurementID = afterMeasurementID
self.feelingBefore = feelingBefore
self.feelingAfter = feelingAfter
self.healthKitWorkoutID = healthKitWorkoutID
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state
case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId"
case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(protocolID, forKey: .protocolID)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(durationSeconds, forKey: .durationSeconds)
try container.encode(state, forKey: .state)
try container.encode(updatedAt, forKey: .updatedAt)
if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) }
else { try container.encodeNil(forKey: .beforeMeasurementID) }
if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) }
else { try container.encodeNil(forKey: .afterMeasurementID) }
if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) }
else { try container.encodeNil(forKey: .feelingBefore) }
if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) }
else { try container.encodeNil(forKey: .feelingAfter) }
if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) }
else { try container.encodeNil(forKey: .healthKitWorkoutID) }
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema")
}
id = try container.decode(UUID.self, forKey: .id)
protocolID = try container.decode(String.self, forKey: .protocolID)
protocolVersion = 1
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds)
state = try container.decode(WatchRegulationSessionState.self, forKey: .state)
beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID)
afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID)
feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore)
feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter)
healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable {
public let schemaVersion = 1
public let id: UUID = UUID()
public let entityID: UUID
public let entityKind: String
public let deletedAt: Date
public let updatedAt: Date
public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) {
self.entityID = entityID
self.entityKind = entityKind
self.deletedAt = deletedAt
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt
}
}
public struct WatchPreferencesV1: Codable, Equatable, Sendable {
public var schemaVersion = 1
public var saveMindAndBodyWorkouts: Bool
public var protocolCatalogVersion: String
public var updatedAt: Date
public init(
saveMindAndBodyWorkouts: Bool = true,
protocolCatalogVersion: String = "1.0.0",
updatedAt: Date = Date()
) {
self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts
self.protocolCatalogVersion = protocolCatalogVersion
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,40 @@
import Foundation
public enum WatchExclusiveResource: String, Codable, Sendable {
case heartRateMeasurement
case hapticRegulation
}
public enum WatchResourceError: LocalizedError, Equatable {
case busy(WatchExclusiveResource)
case releaseMismatch
public var errorDescription: String? {
switch self {
case let .busy(resource): "Une activité \(resource.rawValue) est déjà en cours."
case .releaseMismatch: "La ressource libérée ne correspond pas à l’activité en cours."
}
}
}
public actor WatchResourceArbiter {
public static let shared = WatchResourceArbiter()
public private(set) var current: WatchExclusiveResource?
public init() {}
public func acquire(_ resource: WatchExclusiveResource) throws {
if let current { throw WatchResourceError.busy(current) }
current = resource
}
public func release(_ resource: WatchExclusiveResource) throws {
guard current == resource else { throw WatchResourceError.releaseMismatch }
current = nil
}
public func forceRelease() {
current = nil
}
}
+134
View File
@@ -0,0 +1,134 @@
import Combine
import Foundation
@MainActor
public final class WatchStore: ObservableObject {
@Published public private(set) var measurements: [WatchMeasurementSummaryV1] = []
@Published public private(set) var sessions: [WatchRegulationSessionV1] = []
@Published public private(set) var outbox: [WatchSyncEnvelopeV1] = []
@Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = []
@Published public var preferences: WatchPreferencesV1 {
didSet { persist() }
}
public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) }
private let defaults: UserDefaults
private let storageKey: String
private struct Snapshot: Codable {
var measurements: [WatchMeasurementSummaryV1]
var sessions: [WatchRegulationSessionV1]
var outbox: [WatchSyncEnvelopeV1]
var tombstones: [WatchDeletionTombstoneV1]
var preferences: WatchPreferencesV1
}
public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") {
self.defaults = defaults
self.storageKey = storageKey
if let data = defaults.data(forKey: storageKey),
let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) {
measurements = snapshot.measurements
sessions = snapshot.sessions
outbox = snapshot.outbox
tombstones = snapshot.tombstones
preferences = snapshot.preferences
} else {
preferences = .init()
}
}
public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }),
tombstone.updatedAt >= measurement.updatedAt { return }
measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt }
if !measurements.contains(where: { $0.id == measurement.id }) {
measurements.append(measurement)
}
measurements.sort { $0.startedAt > $1.startedAt }
measurements = Array(measurements.prefix(30))
if let envelope { enqueue(envelope) }
persist()
}
public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == session.id }),
tombstone.updatedAt >= session.updatedAt { return }
sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt }
if !sessions.contains(where: { $0.id == session.id }) {
sessions.append(session)
}
sessions.sort { $0.startedAt > $1.startedAt }
sessions = Array(sessions.prefix(20))
if let envelope { enqueue(envelope) }
persist()
}
@discardableResult
public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
measurements.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
@discardableResult
public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
sessions.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
public func apply(_ tombstone: WatchDeletionTombstoneV1) {
if tombstone.entityKind == "measurement" {
measurements.removeAll { $0.id == tombstone.entityID }
} else if tombstone.entityKind == "regulationSession" {
sessions.removeAll { $0.id == tombstone.entityID }
}
if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }),
existing.updatedAt >= tombstone.updatedAt { return }
tombstones.removeAll { $0.entityID == tombstone.entityID }
tombstones.append(tombstone)
persist()
}
public func enqueue(_ envelope: WatchSyncEnvelopeV1) {
guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return }
outbox.append(envelope)
persist()
}
public func acknowledge(eventIDs: Set<UUID>) {
outbox.removeAll { eventIDs.contains($0.eventID) }
persist()
}
public func replaceFromCompanion(
measurements incomingMeasurements: [WatchMeasurementSummaryV1],
sessions incomingSessions: [WatchRegulationSessionV1]
) {
for measurement in incomingMeasurements { upsert(measurement) }
for session in incomingSessions { upsert(session) }
}
private func persist() {
let snapshot = Snapshot(
measurements: measurements,
sessions: sessions,
outbox: outbox,
tombstones: tombstones,
preferences: preferences
)
if let data = try? WatchCoding.encoder.encode(snapshot) {
defaults.set(data, forKey: storageKey)
}
}
}
@@ -0,0 +1,169 @@
import Foundation
public enum WatchSyncKind: String, Codable, Sendable {
case liveHeartRate
case measurementUpsert
case regulationSessionUpsert
case feelingUpsert
case tombstone
case preferences
case acknowledgement = "ack"
}
/// The payload is encoded JSON data. Keeping the transport wrapper non-generic lets the
/// offline outbox contain measurements, sessions and tombstones in a single queue.
public struct WatchSyncEnvelopeV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID { eventID }
public let eventID: UUID
public let deviceID: String
public let sequence: UInt64
public let sentAt: Date
public let kind: WatchSyncKind
public let payload: Data
public init(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Data
) {
self.eventID = eventID
self.deviceID = deviceID
self.sequence = sequence
self.sentAt = sentAt
self.kind = kind
self.payload = payload
}
public init<Payload: Encodable>(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Payload,
encoder: JSONEncoder = WatchCoding.encoder
) throws {
self.init(
eventID: eventID,
deviceID: deviceID,
sequence: sequence,
sentAt: sentAt,
kind: kind,
payload: try encoder.encode(payload)
)
}
public func decodePayload<Payload: Decodable>(
_ type: Payload.Type,
decoder: JSONDecoder = WatchCoding.decoder
) throws -> Payload {
try decoder.decode(type, from: payload)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, eventID = "eventId", deviceID = "deviceId", sequence, sentAt, kind, payload
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(eventID, forKey: .eventID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(sequence, forKey: .sequence)
try container.encode(sentAt, forKey: .sentAt)
try container.encode(kind, forKey: .kind)
try container.encode(try WatchJSONValue(data: payload), forKey: .payload)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch envelope schema")
}
eventID = try container.decode(UUID.self, forKey: .eventID)
deviceID = try container.decode(String.self, forKey: .deviceID)
sequence = try container.decode(UInt64.self, forKey: .sequence)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(WatchSyncKind.self, forKey: .kind)
payload = try container.decode(WatchJSONValue.self, forKey: .payload).data()
}
}
private enum WatchJSONValue: Codable, Equatable {
case object([String: WatchJSONValue])
case array([WatchJSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
init(data: Data) throws {
self = try WatchCoding.decoder.decode(Self.self, from: data)
}
func data() throws -> Data { try WatchCoding.encoder.encode(self) }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([WatchJSONValue].self) { self = .array(value) }
else { self = .object(try container.decode([String: WatchJSONValue].self)) }
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .object(value): try container.encode(value)
case let .array(value): try container.encode(value)
case let .string(value): try container.encode(value)
case let .number(value): try container.encode(value)
case let .bool(value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
public struct WatchAcknowledgementV1: Codable, Equatable, Sendable {
public let eventIDs: [UUID]
public let receivedAt: Date
public init(eventIDs: [UUID], receivedAt: Date = Date()) {
self.eventIDs = eventIDs
self.receivedAt = receivedAt
}
}
public struct WatchLiveHeartRateV1: Codable, Equatable, Sendable {
public let bpm: Double
public let sampledAt: Date
public let measurementID: UUID?
public init(bpm: Double, sampledAt: Date, measurementID: UUID? = nil) {
self.bpm = bpm
self.sampledAt = sampledAt
self.measurementID = measurementID
}
}
public enum WatchCoding {
public static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
return encoder
}
public static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}