Files
coach-ios/ios/App/CoachWatch/WorkoutManager.swift
Sylvain Bettinelli d5330b8f1a La FC ne reste plus affichée quand le capteur a décroché
`didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand le capteur
optique décroche — bracelet desserré, froid, arythmie, et la FA thermoablée est
précisément le terrain à risque — les calories continuent d'arriver et `hr`
vaut nil. La ligne `if let hr { heartRate = hr }` gardait alors la dernière
valeur connue, et `sendSample` la renvoyait à l'iPhone avec un `ts` NEUF.

Conséquence en trois étages : au poignet, une FC morte s'affichait stable avec
sa zone colorée, indéfiniment ; côté iPhone, `LiveStore` posait
`lastSampleAt = Date()` et `status = .live`, donc son watchdog — qui ne regarde
que l'arrivée de samples — ne pouvait JAMAIS la déclarer perdue ; et aucun log
ne le signalait. De quoi décider de tenir une pente sur un chiffre qui
n'existait plus.

La FC porte désormais la date de sa dernière mesure réelle. `freshHeartRate`
rend 0 au-delà de `heartRateStaleAfter`, et c'est lui qui est affiché et émis.
0 est la convention « inconnu » déjà partagée par toute la chaîne :
`HeartRateMetric` cesse d'afficher une zone, `heartRateText` rend « — » côté
iPhone. Rien à changer dans LiveStore, qui reste à `.live` à juste titre — la
connexion est vivante, c'est la mesure qui manque, et les deux méritent d'être
distingués.

Le seuil de 10 s est un choix d'ingénierie, pas une donnée physiologique :
Apple ne publie aucune cadence garantie d'échantillonnage en séance. Il est
posé au-dessus du `lostThreshold` de 8 s de LiveStore pour que la FC ne se
périme jamais AVANT que la connexion soit déclarée perdue — deux signaux
contradictoires seraient illisibles au poignet. Commenté comme tel, avec la
consigne de l'augmenter si « — » clignote plutôt que de retirer la péremption.

Trouvé au passage : `WorkoutManager` est un singleton et ne remettait RIEN à
zéro entre deux séances. La FC, les calories et la distance de la séance
précédente restaient affichées jusqu'au premier échantillon de la suivante.
`resetMetrics()` est appelé au démarrage, séance réelle comme simulation.

Limite assumée et commentée : la péremption est évaluée dans `ingest`, donc
elle suppose qu'un échantillon d'un type quelconque continue d'arriver — le cas
visé. 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 un cas déjà visible.

⚠️ Vérifié ici par `swiftc -parse` (syntaxe) et les 88 tests Linux, qui ne
couvrent PAS ce fichier : WatchKit et HealthKit n'existent pas sur Linux. Reste
à compiler et à essayer sur la montre — notamment que « — » n'apparaisse pas en
usage normal.

Relevé § 1.1 de docs/AUDIT-2026-09-22.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 07:48:07 +00:00

465 lines
22 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Void, Never>?
/// 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<HKObjectType> = [hr, energy, distanceType, speedType]
var share: Set<HKSampleType> = [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<HKSampleType>) {
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()
try? await builder.endCollection(at: date)
// ⚠️ `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.
let workout = try? await builder.finishWorkout()
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()
}
}
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<HKSource>, HKWorkout, Set<HKSourceRevision>,
// Set<HKDevice>) 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)")
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
self.isRunning = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
}
}