From d5330b8f1adfbdc161f747c93acd8d69610e6d5f Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 22 Sep 2026 07:48:07 +0000 Subject: [PATCH] =?UTF-8?q?La=20FC=20ne=20reste=20plus=20affich=C3=A9e=20q?= =?UTF-8?q?uand=20le=20capteur=20a=20d=C3=A9croch=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- ios/App/CoachWatch/WorkoutManager.swift | 78 ++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index da93ca2..8980fa8 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -31,8 +31,59 @@ final class WorkoutManager: NSObject, ObservableObject { 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 } @@ -40,6 +91,7 @@ final class WorkoutManager: NSObject, ObservableObject { statusMessage = "HealthKit indisponible sur cet appareil" return } + resetMetrics() let distanceType = Self.distanceType(for: activityType) let hr = HKQuantityType(.heartRate) let energy = HKQuantityType(.activeEnergyBurned) @@ -164,6 +216,7 @@ final class WorkoutManager: NSObject, ObservableObject { /// 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() @@ -175,6 +228,7 @@ final class WorkoutManager: NSObject, ObservableObject { 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 @@ -196,7 +250,24 @@ final class WorkoutManager: NSObject, ObservableObject { /// Appelé sur le main depuis le callback de collecte (hors-main). private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?, elapsed: TimeInterval) { - if let hr { heartRate = hr } + // ⚠️ `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 @@ -215,7 +286,10 @@ final class WorkoutManager: NSObject, ObservableObject { guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return } lastSentAt = now ConnectivityManager.shared.sendSample([ - "heartRate": heartRate, + // `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,