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>
This commit is contained in:
@@ -31,8 +31,59 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
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 }
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user