La cible watchOS ne contenait pas une ligne de localisation : ni CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie n'aurait ni carte, ni parcours dans Santé. Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici : rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts impossibles, des doublons et des points arrivés dans le désordre, puis cumul de la distance (haversine) et du dénivelé. Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une pointe franchie en quelques points est écrêtée, donc sous-comptée — sans conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit dans les métadonnées de la séance. Quatre pièges documentés, tous traités dans le code : - `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location` TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau. - Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme « mostly a placeholder » et le parcours comme un comportement indéfini. - Interdit de relancer la localisation depuis l'arrière-plan : la pause garde le flux ouvert et ignore les points, au lieu de couper le manager. - Le CPU tue le GPS — d'où l'insertion des points par lots et la trace d'affichage bornée à 1500 points. Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout` et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans association plutôt que de la jeter. 15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) : aucun « Add Files » à faire sur le Mac. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
342 lines
15 KiB
Swift
342 lines
15 KiB
Swift
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>?
|
||
|
||
private override init() { super.init() }
|
||
|
||
func startWorkout(activityType: HKWorkoutActivityType,
|
||
locationType: HKWorkoutSessionLocationType) async {
|
||
guard !isRunning, session == nil else { return }
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
statusMessage = "HealthKit indisponible sur cet appareil"
|
||
return
|
||
}
|
||
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 }
|
||
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.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) {
|
||
if let hr { heartRate = hr }
|
||
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([
|
||
"heartRate": heartRate,
|
||
"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. On poursuit avec `nil`, la
|
||
// trace sera sauvegardée sans association plutôt que perdue.
|
||
let workout = try? await builder.finishWorkout()
|
||
await LocationTracker.shared.finishRoute(with: workout)
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
}
|