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
}
}