import Foundation import HealthKit import os private let workoutLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "workout") /// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone. /// `@MainActor` (donc Sendable) : l'état observable vit sur le main ; les callbacks /// HealthKit, livrés hors-main, sont `nonisolated` et hoppent vers le main. @MainActor final class WorkoutManager: NSObject, ObservableObject { static let shared = WorkoutManager() @Published private(set) var heartRate = 0.0 @Published private(set) var activeEnergyKcal = 0.0 @Published private(set) var distanceMeters = 0.0 @Published private(set) var speedKmh = 0.0 @Published private(set) var elapsedSec = 0.0 @Published private(set) var isRunning = false @Published private(set) var isPaused = false @Published private(set) var statusMessage: String? private let healthStore = HKHealthStore() private var session: HKWorkoutSession? private var builder: HKLiveWorkoutBuilder? private var lastSentAt = Date.distantPast // Throttle léger anti-saturation BLE pour les metrics à haute fréquence // (calories/distance). La FC, elle, est poussée immédiatement à chaque // nouvel échantillon — comme l'app Exercice native d'Apple. private let minSendInterval: TimeInterval = 0.5 private var simTask: Task? /// Date du dernier échantillon de FC **réellement mesuré**. Posée nulle /// part ailleurs que là où HealthKit nous donne une valeur. private var heartRateAt: Date? /// Au-delà de ce délai sans nouvel échantillon, on cesse d'afficher et /// d'émettre la dernière FC connue. /// /// ⚠️ Cette valeur est un **choix d'ingénierie, pas une donnée /// physiologique** : Apple ne publie pas de cadence garantie pour /// l'échantillonnage de la FC en séance. Elle est posée au-dessus du /// `lostThreshold` de 8 s de `LiveStore` côté iPhone, pour que la FC ne se /// périme jamais *avant* que la connexion elle-même soit déclarée perdue — /// deux signaux contradictoires seraient illisibles au poignet. /// /// Si « — » clignote en usage normal, c'est cette valeur qu'il faut /// augmenter, pas la péremption qu'il faut retirer. /// /// ⚠️ Limite assumée : la péremption est évaluée dans `ingest`, donc elle /// suppose qu'un échantillon — d'un type quelconque — continue d'arriver. /// C'est le cas du scénario visé (le capteur FC décroche pendant que /// calories et distance continuent). Si plus RIEN n'arrive, l'écran entier /// se fige, chronomètre compris, ce qui se voit ; un timer dédié coûterait /// de la batterie pour couvrir un cas déjà visible autrement. private let heartRateStaleAfter: TimeInterval = 10 /// La FC connue si elle est encore fraîche, 0 sinon. /// /// 0 est la convention « inconnu » partagée par toute la chaîne : /// `HeartRateMetric` n'affiche alors plus de zone, et `heartRateText` côté /// iPhone rend « — ». private var freshHeartRate: Double { guard let at = heartRateAt, Date().timeIntervalSince(at) <= heartRateStaleAfter else { return 0 } return heartRate } private override init() { super.init() } /// Remet les mesures à « inconnu » avant une nouvelle séance. /// /// `WorkoutManager` est un singleton : sans ça, les valeurs de la séance /// précédente restaient affichées jusqu'au premier échantillon de la /// nouvelle — une FC d'hier présentée comme celle de maintenant. private func resetMetrics() { heartRate = 0 heartRateAt = nil activeEnergyKcal = 0 distanceMeters = 0 speedKmh = 0 elapsedSec = 0 lastSentAt = .distantPast } func startWorkout(activityType: HKWorkoutActivityType, locationType: HKWorkoutSessionLocationType) async { guard !isRunning, session == nil else { return } guard HKHealthStore.isHealthDataAvailable() else { statusMessage = "HealthKit indisponible sur cet appareil" return } resetMetrics() let distanceType = Self.distanceType(for: activityType) let hr = HKQuantityType(.heartRate) let energy = HKQuantityType(.activeEnergyBurned) // Vitesse instantanée mesurée par la montre. HKLiveWorkoutDataSource la // collecte d'office (course extérieure sur Series 6+, vélo sur // watchOS 11+), mais la lecture doit être autorisée. Sur un appareil qui // ne la produit pas, les stats restent vides : `ingest` retombe alors // sur la vitesse moyenne dérivée de la distance. let speedType = Self.speedType(for: activityType) // La trace exige d'être demandée en lecture ET en écriture — Apple : // « you must request permission to read and share both HKWorkout and // HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la // sortie n'a pas de parcours. let route = HKSeriesType.workoutRoute() let outdoor = (locationType == .outdoor) var read: Set = [hr, energy, distanceType, speedType] var share: Set = [HKQuantityType.workoutType(), hr, energy, distanceType] if outdoor { read.insert(route) share.insert(route) } do { try await healthStore.requestAuthorization(toShare: share, read: read) let woStatus = healthStore.authorizationStatus(for: HKQuantityType.workoutType()) workoutLog.info("requestAuthorization OK; workoutType share status=\(woStatus.rawValue)") if woStatus == .sharingDenied { statusMessage = "Accès Santé refusé. Active Coach dans Réglages → Santé → Accès aux données, puis relance." return } } catch { workoutLog.error("requestAuthorization threw: \(error.localizedDescription)") statusMessage = "Autorisation Santé refusée : \(error.localizedDescription)" return } // Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille // d'autorisation depuis l'arrière-plan. Volontairement « quand // l'app est active » et jamais « toujours » — sur watchOS ce dernier // mène à un parcours d'autorisation qui boucle. if outdoor { LocationTracker.shared.requestAuthorization() } let config = HKWorkoutConfiguration() config.activityType = activityType config.locationType = locationType do { let session = try HKWorkoutSession(healthStore: healthStore, configuration: config) let builder = session.associatedWorkoutBuilder() builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config) session.delegate = self builder.delegate = self self.session = session self.builder = builder let start = Date() session.startActivity(with: start) workoutLog.info("startActivity called, beginning collection…") try await builder.beginCollection(at: start) isRunning = true statusMessage = nil workoutLog.info("beginCollection OK, isRunning=true") // GPS uniquement en extérieur : en salle il ne ferait que vider la // batterie pour une trace immobile. if outdoor { // Apple déconseille d'instancier un HKWorkoutRouteBuilder // directement : celui du builder de séance associe la trace. let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute()) as? HKWorkoutRouteBuilder if routeBuilder == nil { workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder") } LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder) } } catch { self.session = nil self.builder = nil let ns = error as NSError workoutLog.error("start failed domain=\(ns.domain) code=\(ns.code): \(error.localizedDescription)") statusMessage = "Démarrage séance impossible : \(error.localizedDescription)" } } /// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la /// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`). /// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le /// changement d'état HealthKit, seule source fiable (la montre peut aussi /// mettre en pause d'elle-même). func pause() { guard simTask == nil else { setSimPaused(true); return } session?.pause() } func resume() { guard simTask == nil else { setSimPaused(false); return } session?.resume() } /// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun /// changement d'état, l'iPhone doit donc être prévenu ici. private func setSimPaused(_ paused: Bool) { isPaused = paused ConnectivityManager.shared.sendPaused(paused) } func end() { if let simTask { simTask.cancel() self.simTask = nil isRunning = false statusMessage = nil // Le mode test ne passe pas par HKWorkoutSession : c'est ici le // seul endroit où l'iPhone peut apprendre que le flux s'arrête. ConnectivityManager.shared.sendEnded(reason: "sim") } else { session?.end() } } /// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live. func startSimulation() { guard !isRunning else { return } resetMetrics() isRunning = true statusMessage = "Mode test — FC simulée" let startedAt = Date() simTask = Task { [weak self] in while !Task.isCancelled { guard let self else { return } let elapsed = Date().timeIntervalSince(startedAt) let hr = 110 + 40 * abs(sin(elapsed / 30)) let kcal = elapsed / 6 let dist = elapsed * 2.6 self.heartRate = hr self.heartRateAt = Date() // sinon la FC simulée se périme self.activeEnergyKcal = kcal self.distanceMeters = dist self.elapsedSec = elapsed self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s ConnectivityManager.shared.sendSample([ "heartRate": hr, "activeEnergyKcal": kcal, "distanceMeters": dist, "speedKmh": self.speedKmh, "elapsedSec": elapsed, "ts": Date().timeIntervalSince1970, "sim": true ]) try? await Task.sleep(nanoseconds: 2_000_000_000) } } } /// Appelé sur le main depuis le callback de collecte (hors-main). private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?, elapsed: TimeInterval) { // ⚠️ `didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand // le capteur optique décroche — bracelet desserré, froid, arythmie — // les calories continuent d'arriver et `hr` vaut nil. Sans la date // ci-dessous, la dernière FC connue restait affichée, et repartait vers // l'iPhone avec un horodatage NEUF : le watchdog de `LiveStore`, qui // ne regarde que l'arrivée de samples, ne pouvait alors jamais la // déclarer perdue. Une FC morte s'affichait stable, avec sa zone // colorée, indéfiniment — de quoi valider un effort sur un chiffre qui // n'existe plus. if let hr { heartRate = hr heartRateAt = Date() } else if freshHeartRate == 0 { // Périmée : on retombe sur la convention « inconnu » plutôt que de // laisser la vue afficher une valeur que plus rien ne soutient. heartRate = 0 heartRateAt = nil } if let kcal { activeEnergyKcal = kcal } if let dist { distanceMeters = dist } elapsedSec = elapsed // Vitesse instantanée de la montre si elle la mesure, sinon moyenne // depuis le départ (distance / temps). Les deux en km/h. if let speedMs { speedKmh = speedMs * 3.6 } else if elapsed > 5, distanceMeters > 0 { speedKmh = (distanceMeters / elapsed) * 3.6 } let now = Date() // Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à // jour calories/distance seule), on applique le throttle anti-saturation. let heartRateUpdated = hr != nil guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return } lastSentAt = now ConnectivityManager.shared.sendSample([ // `freshHeartRate`, pas `heartRate` : ne jamais envoyer une valeur // périmée sous un horodatage neuf. L'iPhone ne peut pas deviner // l'âge d'un chiffre qu'on lui présente comme actuel. "heartRate": freshHeartRate, "activeEnergyKcal": activeEnergyKcal, "distanceMeters": distanceMeters, "speedKmh": speedKmh, "elapsedSec": elapsed, "ts": now.timeIntervalSince1970 ]) } private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType { activity == .cycling ? HKQuantityType(.distanceCycling) : HKQuantityType(.distanceWalkingRunning) } /// La marche n'a pas de type de vitesse dédié : on lui donne celui de la /// course, qui restera vide — la vitesse moyenne dérivée prend le relais. private static func speedType(for activity: HKWorkoutActivityType) -> HKQuantityType { activity == .cycling ? HKQuantityType(.cyclingSpeed) : HKQuantityType(.runningSpeed) } } extension WorkoutManager: HKLiveWorkoutBuilderDelegate { nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { let hrType = HKQuantityType(.heartRate) let energyType = HKQuantityType(.activeEnergyBurned) let walkRun = HKQuantityType(.distanceWalkingRunning) let cycling = HKQuantityType(.distanceCycling) let runSpeed = HKQuantityType(.runningSpeed) let cycleSpeed = HKQuantityType(.cyclingSpeed) var hr: Double? var kcal: Double? var dist: Double? var speedMs: Double? for type in collectedTypes { guard let qType = type as? HKQuantityType, let stats = workoutBuilder.statistics(for: qType) else { continue } if qType == hrType { let bpm = HKUnit.count().unitDivided(by: .minute()) hr = stats.mostRecentQuantity()?.doubleValue(for: bpm) } else if qType == energyType { kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) } else if qType == walkRun || qType == cycling { dist = stats.sumQuantity()?.doubleValue(for: .meter()) } else if qType == runSpeed || qType == cycleSpeed { // mostRecent : c'est bien la vitesse à l'instant t qu'on veut // afficher, pas la moyenne de la séance. let ms = HKUnit.meter().unitDivided(by: .second()) speedMs = stats.mostRecentQuantity()?.doubleValue(for: ms) } } let elapsed = workoutBuilder.elapsedTime Task { @MainActor in self.ingest(hr: hr, kcal: kcal, dist: dist, speedMs: speedMs, elapsed: elapsed) } } nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} } extension WorkoutManager: HKWorkoutSessionDelegate { nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)") // Pause / reprise : c'est la session HealthKit qui fait foi, pas // l'appel à pause() — la montre peut mettre en pause d'elle-même // (auto-pause) sans passer par nous. if toState == .paused || toState == .running { let paused = (toState == .paused) Task { @MainActor in self.isPaused = paused // Le flux GPS reste ouvert pendant la pause : watchOS interdit // de redémarrer la localisation depuis l'arrière-plan, donc on // ignore les points au lieu de couper le manager. LocationTracker.shared.setPaused(paused) ConnectivityManager.shared.sendPaused(paused) } } guard toState == .ended else { return } let builder = workoutSession.associatedWorkoutBuilder() Task { @MainActor in // Ordre imposé par Apple : couper le GPS, clore la collecte, // sauvegarder la séance, et SEULEMENT ensuite clore la trace en // l'associant au workout obtenu. LocationTracker.shared.stopTracking() /* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux situations OPPOSÉES, toutes deux menant à `workout == nil` : · montre verrouillée à l'arrêt → la séance EST écrite ; · `endCollection` a levé → il n'y a rien à retrouver. Elles tombaient dans le même `else`, et seul le sort de la TRACE était journalisé — pas celui de la séance. Après deux heures de sortie, l'écran de fin restait le même. § 2.3 du diagnostic du 24/09/2026. La logique de récupération ci-dessous n'a pas changé : seul l'échec devient visible. */ var collecteClose = true do { try await builder.endCollection(at: date) } catch { collecteClose = false workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)") } // ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est // verrouillée : ce n'est pas un échec, la séance EST écrite dans // HealthKit — seul l'objet manque. Comme `finishRoute(with:)` // exige un `HKWorkout` non optionnel (vérifié dans la doc Apple), // on va rechercher celle qui vient d'être sauvegardée. var workout: HKWorkout? do { workout = try await builder.finishWorkout() } catch { workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)") } if let workout { await LocationTracker.shared.finishRoute(with: workout) } else if let recovered = await self.recentlySavedWorkout() { workoutLog.info("workout recupere apres un finishWorkout() nil") await LocationTracker.shared.finishRoute(with: recovered) } else { LocationTracker.shared.discardRoute() if collecteClose { // La collecte s'est close proprement mais rien n'est // retrouvable : anormal, et jamais journalisé jusqu'ici. workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace") } else { workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit") /* Le dire à l'écran. C'est le cœur du constat : l'utilisateur voyait l'écran de fin habituel après une séance perdue. Uniquement dans ce cas — une montre verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte. */ self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé" } } } Task { @MainActor in self.isRunning = false self.isPaused = false self.session = nil self.builder = nil // Fin explicite vers l'iPhone : sans elle, sa Live Activity reste // affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit. ConnectivityManager.shared.sendEnded(reason: "session-ended") } } /// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()` /// n'a rien rendu. /// /// ⚠️ La fenêtre porte sur le **chevauchement**, pas sur la date de début : /// sans `.strictStartDate`, un workout est retenu dès qu'il croise /// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus /// de quelques minutes — c'est le piège qui avait rendu muettes les /// notifications de fin de séance côté iPhone. /// /// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans /// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple /// pourrait récupérer notre trace. private func recentlySavedWorkout() async -> HKWorkout? { let recent = HKQuery.predicateForSamples( withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil) // Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq // surcharges (HKSource, Set, HKWorkout, Set, // Set) et un `.default()` abrégé s'y résout mal. let mine = HKQuery.predicateForObjects(from: HKSource.default()) let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine]) let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) return await withCheckedContinuation { continuation in let query = HKSampleQuery(sampleType: .workoutType(), predicate: predicate, limit: 1, sortDescriptors: [newestFirst]) { _, samples, error in if let error { workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)") } continuation.resume(returning: samples?.first as? HKWorkout) } healthStore.execute(query) } } /// Fenêtre de recherche du workout de repli. Large assez pour couvrir une /// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant. private static let recoveryWindow: TimeInterval = 5 * 60 nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { let message = error.localizedDescription let ns = error as NSError workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)") let builder = workoutSession.associatedWorkoutBuilder() Task { @MainActor in /* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait `stopTracking()`, `endCollection`, `finishWorkout` et `finishRoute`/`discardRoute` — et `self.builder = nil` détruisait la seule référence au builder : la perte devenait définitive dans l'instruction même qui l'ignorait. Le GPS, lui, continuait de tourner après la fin apparente de la séance, sans que rien ne l'indique. § 2.4 du diagnostic du 24/09/2026. Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des heures. La sauvegarde ensuite, en best-effort — une session qui a échoué refusera souvent de clore, mais ne pas essayer garantit la perte. */ LocationTracker.shared.stopTracking() var sauvee: HKWorkout? do { try await builder.endCollection(at: Date()) sauvee = try await builder.finishWorkout() } catch { workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)") } if let sauvee { workoutLog.info("seance sauvee malgre l'echec de session") await LocationTracker.shared.finishRoute(with: sauvee) self.statusMessage = "Séance interrompue, mais enregistrée" } else if let recovered = await self.recentlySavedWorkout() { workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session") await LocationTracker.shared.finishRoute(with: recovered) self.statusMessage = "Séance interrompue, mais enregistrée" } else { LocationTracker.shared.discardRoute() workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve") self.statusMessage = "Séance perdue : \(message)" } self.isRunning = false self.isPaused = false self.session = nil self.builder = nil ConnectivityManager.shared.sendEnded(reason: "session-failed") } } }