Compare commits
3 Commits
be1ffec5c1
...
19fbfd116e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19fbfd116e | ||
|
|
eb523ff205 | ||
|
|
acbb2c6896 |
55
COWORK.md
55
COWORK.md
@@ -76,10 +76,11 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
|
|||||||
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
||||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||||
|
|
||||||
## 🆕 Moteur d'intervalles (2026-08-20, dev — non câblé, non compilé)
|
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
|
||||||
|
|
||||||
Branche **`feat/watch-outdoor`** (poussée sur Gitea le 2026-08-20), commit
|
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
|
||||||
`f2fa3cc`. `IntervalEngine` guide les intervalles d'une séance au poignet.
|
`a04dfc4` (trace GPS). `IntervalEngine` guide les intervalles d'une séance au
|
||||||
|
poignet ; `RouteFilter` + `LocationTracker` enregistrent le parcours.
|
||||||
|
|
||||||
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
|
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
|
||||||
structurée dans une app tierce — son seul point d'exécution public est
|
structurée dans une app tierce — son seul point d'exécution public est
|
||||||
@@ -91,22 +92,42 @@ horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et
|
|||||||
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
||||||
qui exclut déjà les pauses.
|
qui exclut déjà les pauses.
|
||||||
|
|
||||||
✅ **42 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
✅ **57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||||
`~/workspace/toolchains/bin`), dont les 17 d'`IntervalEngine`. La logique est
|
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
|
||||||
donc validée — ce qui reste est l'intégration, que Linux ne peut pas compiler.
|
Les deux ont été **vérifiés rouges** en neutralisant leur correction (5 échecs
|
||||||
|
sans les frontières théoriques du moteur, 4 sans le lissage du dénivelé). La
|
||||||
|
logique est donc validée — reste l'intégration, que Linux ne peut pas compiler.
|
||||||
|
|
||||||
⚠️ **ÉTAPE MAC OBLIGATOIRE — le fichier n'est PAS dans le projet Xcode.**
|
✅ **Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
|
||||||
Vérifié le 2026-08-20 : `grep -c "IntervalEngine" ios/App/App.xcodeproj/project.pbxproj`
|
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
|
||||||
rend **0**. Xcode l'ignore donc totalement, et **ça compile très bien sans lui** —
|
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
|
||||||
même piège que `VmaTestView.swift`. À faire :
|
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
|
||||||
|
|
||||||
1. `cp ios/App/App.xcodeproj/project.pbxproj ios/App/App.xcodeproj/project.pbxproj.bak-interval`
|
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
|
||||||
2. Clic droit sur le dossier **CoachWatch** → *Add Files to "App"…* →
|
compilation, soit 1 cible) :
|
||||||
`ios/App/CoachWatch/IntervalEngine.swift`
|
```bash
|
||||||
— **décocher** « Copy items if needed », **cocher la cible `CoachWatch`**.
|
for f in IntervalEngine RouteFilter LocationTracker; do
|
||||||
3. Contrôle : `grep -c "IntervalEngine.swift in Sources" ios/App/App.xcodeproj/project.pbxproj`
|
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
|
||||||
doit rendre **1**.
|
done
|
||||||
4. Commit + push du `pbxproj` immédiatement (règle 3 de ce fichier).
|
```
|
||||||
|
|
||||||
|
⚠️ **À vérifier au premier build, dans cet ordre** :
|
||||||
|
1. **Une séance extérieure demande la localisation** (feuille « lorsque l'app
|
||||||
|
est active »). Si rien n'apparaît, la trace ne partira pas.
|
||||||
|
2. **La sortie a bien un parcours dans Santé** à la fin. C'est le test qui
|
||||||
|
compte : `finishRoute` doit être appelé APRÈS `finishWorkout`, sinon la
|
||||||
|
trace existe sans être associée à la séance.
|
||||||
|
3. **La FC remonte toujours sur `/live`** — `WorkoutManager` a été modifié,
|
||||||
|
c'est le test de non-régression prioritaire.
|
||||||
|
4. Écran éteint, poignet baissé : les positions continuent d'arriver. Si elles
|
||||||
|
s'arrêtent, chercher du côté du CPU (une boucle d'affichage trop rapide fait
|
||||||
|
suspendre l'app), pas du côté des autorisations.
|
||||||
|
|
||||||
|
⚠️ `Info.plist` de `CoachWatch` gagne `UIBackgroundModes = [location]` et
|
||||||
|
`NSLocationWhenInUseUsageDescription`. **La clé de background est vitale** :
|
||||||
|
armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
|
||||||
|
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
||||||
|
retomberait silencieusement au premier plan seul.
|
||||||
|
|
||||||
## 👉 À reprendre
|
## 👉 À reprendre
|
||||||
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||||
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
||||||
|
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
||||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
@@ -162,6 +164,8 @@
|
|||||||
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
||||||
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
|
||||||
|
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
|
||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
||||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||||
@@ -336,6 +340,8 @@
|
|||||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
||||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||||
|
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
|
||||||
|
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
|
||||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||||
);
|
);
|
||||||
path = CoachWatch;
|
path = CoachWatch;
|
||||||
@@ -598,6 +604,8 @@
|
|||||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,18 @@
|
|||||||
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé.</string>
|
||||||
|
<!-- ⚠️ `location` est requis par `allowsBackgroundLocationUpdates` : armer ce
|
||||||
|
drapeau sans la clé TERMINE l'app (« is a fatal error that terminates
|
||||||
|
the app », doc Apple). `LocationTracker.backgroundModeDeclared` vérifie
|
||||||
|
donc cette clé avant d'armer quoi que ce soit. La clé qui porte
|
||||||
|
`location` sur watchOS est `UIBackgroundModes`, distincte de
|
||||||
|
`WKBackgroundModes` qui porte `workout-processing`. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>location</string>
|
||||||
|
</array>
|
||||||
<key>WKApplication</key>
|
<key>WKApplication</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>WKBackgroundModes</key>
|
<key>WKBackgroundModes</key>
|
||||||
|
|||||||
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
import HealthKit
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location")
|
||||||
|
|
||||||
|
/* GPS de la séance : flux de positions, trace HealthKit, distance et D+.
|
||||||
|
*
|
||||||
|
* Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce
|
||||||
|
* fichier ne fait que le brancher sur CoreLocation et HealthKit — c'est la
|
||||||
|
* seule partie qui exige un poignet pour être validée.
|
||||||
|
*
|
||||||
|
* **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient
|
||||||
|
* de l'app *Exercice* d'Apple, relues après coup côté iPhone par
|
||||||
|
* `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus
|
||||||
|
* personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait
|
||||||
|
* aucune trace, ni sur la carte ni dans Santé.
|
||||||
|
*
|
||||||
|
* ⚠️ **Quatre pièges, tous documentés et tous coûteux.**
|
||||||
|
*
|
||||||
|
* 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` =
|
||||||
|
* `location` dans l'Info.plist **termine l'app** — Apple : « is a fatal
|
||||||
|
* error that terminates the app ». D'où le garde-fou `backgroundModeDeclared`
|
||||||
|
* ci-dessous, qui vérifie le plist avant d'armer le drapeau.
|
||||||
|
* 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple
|
||||||
|
* décrit ce prompt comme « mostly a placeholder » et le parcours comme
|
||||||
|
* « undefined behavior » : le statut boucle jusqu'à revenir à
|
||||||
|
* `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer
|
||||||
|
* à recevoir des positions en arrière-plan.
|
||||||
|
* 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit
|
||||||
|
* de relancer les mises à jour depuis l'arrière-plan : une pause qui
|
||||||
|
* couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran.
|
||||||
|
* On garde donc le flux ouvert et on ignore les points pendant la pause.
|
||||||
|
* 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en
|
||||||
|
* arrière-plan, et les positions s'arrêtent sans la moindre erreur — un
|
||||||
|
* développeur a perdu les siennes à cause d'un rafraîchissement d'écran à
|
||||||
|
* 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un.
|
||||||
|
*/
|
||||||
|
@MainActor
|
||||||
|
final class LocationTracker: NSObject, ObservableObject {
|
||||||
|
static let shared = LocationTracker()
|
||||||
|
|
||||||
|
/// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz
|
||||||
|
/// produirait plus de 10 000 points, que la montre ne peut pas redessiner
|
||||||
|
/// à chaque rafraîchissement sans se faire suspendre (piège 4).
|
||||||
|
@Published private(set) var trackForMap: [GeoFix] = []
|
||||||
|
@Published private(set) var lastFix: GeoFix?
|
||||||
|
@Published private(set) var distanceMeters: Double = 0
|
||||||
|
@Published private(set) var ascentMeters: Double = 0
|
||||||
|
@Published private(set) var descentMeters: Double = 0
|
||||||
|
/// Rayon d'incertitude du dernier point reçu, accepté ou non — c'est la
|
||||||
|
/// jauge « qualité GPS » de l'écran.
|
||||||
|
@Published private(set) var horizontalAccuracy: Double?
|
||||||
|
@Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||||
|
@Published private(set) var isTracking = false
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var filter = RouteFilter()
|
||||||
|
private var routeBuilder: HKWorkoutRouteBuilder?
|
||||||
|
/// Points en attente d'écriture dans HealthKit.
|
||||||
|
private var pendingRoute: [CLLocation] = []
|
||||||
|
/// Positions ignorées tant que la séance est en pause : le flux reste
|
||||||
|
/// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt.
|
||||||
|
private var isPaused = false
|
||||||
|
|
||||||
|
private let maxMapPoints = 1_500
|
||||||
|
private let routeFlushThreshold = 25
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
authorizationStatus = manager.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Autorisation
|
||||||
|
|
||||||
|
/// À appeler **au premier plan**, avant de démarrer la séance.
|
||||||
|
func requestAuthorization() {
|
||||||
|
// Volontairement pas `requestAlwaysAuthorization()` : voir piège 2.
|
||||||
|
guard manager.authorizationStatus == .notDetermined else { return }
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthorized: Bool {
|
||||||
|
switch authorizationStatus {
|
||||||
|
case .authorizedWhenInUse, .authorizedAlways: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Cycle de vie
|
||||||
|
|
||||||
|
/// Démarre le suivi. **Doit être appelé pendant que l'app est au premier
|
||||||
|
/// plan** — watchOS refuse de démarrer la localisation depuis l'arrière-plan.
|
||||||
|
///
|
||||||
|
/// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via
|
||||||
|
/// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi
|
||||||
|
/// d'affichage seul, sans écriture dans Santé.
|
||||||
|
func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) {
|
||||||
|
guard !isTracking else { return }
|
||||||
|
// On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est
|
||||||
|
// encore en attente, le manager reste armé et CoreLocation délivre les
|
||||||
|
// positions dès l'acceptation. Refuser ici priverait de trace toute
|
||||||
|
// première séance, celle-là même où la demande apparaît.
|
||||||
|
if authorizationStatus == .denied || authorizationStatus == .restricted {
|
||||||
|
locationLog.error("localisation refusée par l'utilisateur : séance sans trace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.routeBuilder = routeBuilder
|
||||||
|
filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity))
|
||||||
|
trackForMap.removeAll()
|
||||||
|
pendingRoute.removeAll()
|
||||||
|
distanceMeters = 0
|
||||||
|
ascentMeters = 0
|
||||||
|
descentMeters = 0
|
||||||
|
isPaused = false
|
||||||
|
|
||||||
|
// Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` — inutile
|
||||||
|
// pour une trace. `BestForNavigation` est réservé par Apple aux
|
||||||
|
// appareils branchés : trop gourmand pour une séance au poignet.
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
|
manager.distanceFilter = kCLDistanceFilterNone
|
||||||
|
manager.activityType = Self.activityType(for: activity)
|
||||||
|
|
||||||
|
if Self.backgroundModeDeclared {
|
||||||
|
manager.allowsBackgroundLocationUpdates = true
|
||||||
|
} else {
|
||||||
|
// Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1).
|
||||||
|
// Mieux vaut une trace qui s'arrête écran éteint qu'un crash.
|
||||||
|
locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.startUpdatingLocation()
|
||||||
|
isTracking = true
|
||||||
|
locationLog.info("suivi GPS démarré")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suspend l'enregistrement **sans couper le flux** (piège 3).
|
||||||
|
func setPaused(_ paused: Bool) {
|
||||||
|
guard isTracking else { return }
|
||||||
|
isPaused = paused
|
||||||
|
if paused {
|
||||||
|
// Le point qui suivra la reprise serait à des centaines de mètres :
|
||||||
|
// on repart d'une origine neuve pour ne pas tracer la pause.
|
||||||
|
pendingRoute.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne
|
||||||
|
/// se clôt qu'après la sauvegarde du workout — voir `finishRoute(with:)`.
|
||||||
|
func stopTracking() {
|
||||||
|
guard isTracking else { return }
|
||||||
|
manager.stopUpdatingLocation()
|
||||||
|
isTracking = false
|
||||||
|
locationLog.info("suivi GPS arrete")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clôt la trace et l'associe à la séance sauvegardée.
|
||||||
|
///
|
||||||
|
/// ⚠️ **L'ordre n'est pas négociable.** Apple : « After saving the workout,
|
||||||
|
/// add any remaining locations to the route builder and call finishRoute. »
|
||||||
|
/// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne
|
||||||
|
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
|
||||||
|
/// invalidé à sa libération.
|
||||||
|
///
|
||||||
|
/// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend
|
||||||
|
/// pas l'objet **quand la montre est verrouillée** (confirmé par un
|
||||||
|
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt
|
||||||
|
/// que de la jeter — une route orpheline vaut mieux qu'une sortie sans
|
||||||
|
/// parcours. Une route ne peut être associée qu'une fois, et jamais après
|
||||||
|
/// coup.
|
||||||
|
///
|
||||||
|
/// Échoue aussi si aucune position n'a été insérée — cas normal d'une
|
||||||
|
/// séance en salle, à ne pas remonter comme une anomalie.
|
||||||
|
func finishRoute(with workout: HKWorkout?) async {
|
||||||
|
await flushPendingRoute()
|
||||||
|
guard let builder = routeBuilder else { return }
|
||||||
|
self.routeBuilder = nil
|
||||||
|
|
||||||
|
guard filter.acceptedCount > 0 else {
|
||||||
|
locationLog.info("aucune position retenue : pas de trace a clore")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await builder.finishRoute(with: workout, metadata: nil)
|
||||||
|
locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m")
|
||||||
|
} catch {
|
||||||
|
locationLog.error("finishRoute a echoue : \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Écriture HealthKit
|
||||||
|
|
||||||
|
/// Écrit les points en attente. Par lots : une écriture par position
|
||||||
|
/// gaspillerait le CPU que watchOS surveille (piège 4).
|
||||||
|
private func flushPendingRoute() async {
|
||||||
|
guard let builder = routeBuilder, !pendingRoute.isEmpty else { return }
|
||||||
|
let batch = pendingRoute
|
||||||
|
pendingRoute.removeAll()
|
||||||
|
do {
|
||||||
|
try await builder.insertRouteData(batch)
|
||||||
|
} catch {
|
||||||
|
// Les points sont perdus, pas la séance : on ne remet pas le lot
|
||||||
|
// dans la file, sinon un échec persistant la ferait enfler
|
||||||
|
// indéfiniment en mémoire.
|
||||||
|
locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Réglages par activité
|
||||||
|
|
||||||
|
/// Plafond de vitesse plausible, en m/s. Le VTT électrique descend
|
||||||
|
/// largement au-dessus d'un coureur : un seuil unique écarterait des points
|
||||||
|
/// parfaitement valides.
|
||||||
|
private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double {
|
||||||
|
switch activity {
|
||||||
|
case .cycling: return 30 // 108 km/h, descente comprise
|
||||||
|
case .running: return 12 // 43 km/h
|
||||||
|
default: return 8 // marche, randonnée
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType {
|
||||||
|
switch activity {
|
||||||
|
// `.fitness` fait désactiver le positionnement intérieur et peut
|
||||||
|
// provoquer des pauses automatiques ; Apple recommande
|
||||||
|
// `.otherNavigation` pour le vélo et le hors-route.
|
||||||
|
case .cycling: return .otherNavigation
|
||||||
|
default: return .fitness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Le plist déclare-t-il le mode de fond `location` ?
|
||||||
|
///
|
||||||
|
/// watchOS porte deux clés distinctes : `WKBackgroundModes` pour
|
||||||
|
/// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte
|
||||||
|
/// les deux, la documentation d'Apple n'étant pas univoque sur ce point.
|
||||||
|
private static var backgroundModeDeclared: Bool {
|
||||||
|
let keys = ["UIBackgroundModes", "WKBackgroundModes"]
|
||||||
|
for key in keys {
|
||||||
|
if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String],
|
||||||
|
modes.contains("location") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocationTracker: CLLocationManagerDelegate {
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||||
|
didUpdateLocations locations: [CLLocation]) {
|
||||||
|
let fixes = locations.map { GeoFix(location: $0) }
|
||||||
|
let raw = locations
|
||||||
|
Task { @MainActor in
|
||||||
|
self.ingest(fixes: fixes, raw: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||||
|
didFailWithError error: Error) {
|
||||||
|
locationLog.error("CoreLocation a echoue : \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
Task { @MainActor in
|
||||||
|
self.authorizationStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ingest(fixes: [GeoFix], raw: [CLLocation]) {
|
||||||
|
horizontalAccuracy = fixes.last?.horizontalAccuracy
|
||||||
|
guard !isPaused else { return }
|
||||||
|
|
||||||
|
for (fix, location) in zip(fixes, raw) {
|
||||||
|
guard case .accepted = filter.add(fix) else { continue }
|
||||||
|
lastFix = fix
|
||||||
|
pendingRoute.append(location)
|
||||||
|
appendToMap(fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
distanceMeters = filter.distanceM
|
||||||
|
ascentMeters = filter.ascentM
|
||||||
|
descentMeters = filter.descentM
|
||||||
|
|
||||||
|
if pendingRoute.count >= routeFlushThreshold {
|
||||||
|
Task { await flushPendingRoute() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint :
|
||||||
|
/// on garde un point sur deux, ce qui divise la densité sans déformer le
|
||||||
|
/// tracé ni faire enfler la mémoire sur une longue sortie.
|
||||||
|
private func appendToMap(_ fix: GeoFix) {
|
||||||
|
trackForMap.append(fix)
|
||||||
|
if trackForMap.count > maxMapPoints {
|
||||||
|
trackForMap = trackForMap.enumerated()
|
||||||
|
.compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GeoFix {
|
||||||
|
/// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste
|
||||||
|
/// testable hors d'un Mac.
|
||||||
|
init(location: CLLocation) {
|
||||||
|
self.init(lat: location.coordinate.latitude,
|
||||||
|
lon: location.coordinate.longitude,
|
||||||
|
altitude: location.altitude,
|
||||||
|
horizontalAccuracy: location.horizontalAccuracy,
|
||||||
|
verticalAccuracy: location.verticalAccuracy,
|
||||||
|
speed: location.speed,
|
||||||
|
timestamp: location.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/* Filtrage de la trace GPS et dénivelé, en Foundation pur.
|
||||||
|
*
|
||||||
|
* **Pourquoi ce fichier est séparé de CoreLocation.** `CLLocation` n'existe pas
|
||||||
|
* sur Linux : tant que le filtrage vit dans la classe qui parle au GPS, il ne
|
||||||
|
* se teste que sur un poignet, en marchant. Ici, `GeoFix` recopie les seuls
|
||||||
|
* champs dont le filtrage a besoin ; `LocationTracker` fait la conversion et
|
||||||
|
* reste, lui, non testable hors Xcode.
|
||||||
|
*
|
||||||
|
* **Ce que ce fichier NE fait PAS.** Il ne produit pas la distance officielle
|
||||||
|
* de la séance. HealthKit collecte `distanceWalkingRunning` / `distanceCycling`
|
||||||
|
* par fusion GPS + accéléromètre, et fait autorité — c'est déjà ce que lit
|
||||||
|
* `WorkoutManager`. La distance calculée ici sert à la carte et au découpage
|
||||||
|
* des étapes en distance, où l'on a besoin d'une valeur à chaque point plutôt
|
||||||
|
* qu'à chaque échantillon HealthKit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Un point GPS, réduit à ce dont le filtrage a besoin.
|
||||||
|
public struct GeoFix: Equatable, Sendable {
|
||||||
|
public var lat: Double
|
||||||
|
public var lon: Double
|
||||||
|
/// Mètres au-dessus du niveau de la mer.
|
||||||
|
public var altitude: Double
|
||||||
|
/// Rayon d'incertitude horizontale en mètres. **Négatif = fix invalide**,
|
||||||
|
/// convention Apple.
|
||||||
|
public var horizontalAccuracy: Double
|
||||||
|
/// Incertitude verticale. Négatif = altitude non fiable.
|
||||||
|
public var verticalAccuracy: Double
|
||||||
|
/// Vitesse instantanée en m/s. **Négatif = inconnue**, convention Apple.
|
||||||
|
public var speed: Double
|
||||||
|
public var timestamp: Date
|
||||||
|
|
||||||
|
public init(lat: Double, lon: Double, altitude: Double = 0,
|
||||||
|
horizontalAccuracy: Double = 5, verticalAccuracy: Double = 5,
|
||||||
|
speed: Double = -1, timestamp: Date) {
|
||||||
|
self.lat = lat
|
||||||
|
self.lon = lon
|
||||||
|
self.altitude = altitude
|
||||||
|
self.horizontalAccuracy = horizontalAccuracy
|
||||||
|
self.verticalAccuracy = verticalAccuracy
|
||||||
|
self.speed = speed
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pourquoi un point a été écarté. Renvoyé plutôt que jeté en silence : sur le
|
||||||
|
/// terrain, « la trace est trouée » sans plus d'explication ne se diagnostique
|
||||||
|
/// pas — la répartition des motifs, elle, se lit.
|
||||||
|
public enum FixRejection: String, Equatable, Sendable {
|
||||||
|
case invalidAccuracy // horizontalAccuracy <= 0 : fix inutilisable
|
||||||
|
case tooImprecise // au-delà du rayon toléré
|
||||||
|
case outOfOrder // arrivé après un point plus récent
|
||||||
|
case duplicate // même horodatage que le précédent
|
||||||
|
case implausibleSpeed // saut impossible entre deux points
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FixOutcome: Equatable, Sendable {
|
||||||
|
case accepted
|
||||||
|
case rejected(FixRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumule une trace propre : filtre les points douteux, cumule distance et
|
||||||
|
/// dénivelé.
|
||||||
|
///
|
||||||
|
/// `struct` mutable et sans horloge interne, comme `IntervalEngine` : rejouer
|
||||||
|
/// la même suite de points redonne le même résultat.
|
||||||
|
public struct RouteFilter: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Rayon d'incertitude maximum toléré, en mètres.
|
||||||
|
///
|
||||||
|
/// 50 m est le seuil retenu par l'exemple de trace d'Apple. Plus strict
|
||||||
|
/// perdrait les premières secondes sous couvert forestier ; plus laxiste
|
||||||
|
/// laisserait entrer les points « sautés » qui gonflent la distance.
|
||||||
|
public var maxHorizontalAccuracy: Double
|
||||||
|
|
||||||
|
/// Vitesse au-delà de laquelle un déplacement entre deux points est jugé
|
||||||
|
/// impossible, en m/s. 30 m/s ≈ 108 km/h : au-dessus, c'est une
|
||||||
|
/// téléportation GPS ou un trajet en voiture, pas une séance.
|
||||||
|
public var maxPlausibleSpeed: Double
|
||||||
|
|
||||||
|
/// Écart vertical à franchir avant de compter une montée ou une descente.
|
||||||
|
///
|
||||||
|
/// ⚠️ Sans ce seuil, le bruit de l'altimètre s'accumule : quelques
|
||||||
|
/// dizaines de centimètres d'oscillation à chaque point produisent des
|
||||||
|
/// centaines de mètres de D+ sur une sortie plate. L'hystérésis ne compte
|
||||||
|
/// que les changements de sens confirmés.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Le seuil seul ne suffit pas** — mesuré en test : un bruit alternant
|
||||||
|
/// de ±1,5 m, soit exactement l'amplitude du seuil, était compté à chaque
|
||||||
|
/// alternance et produisait 297 m de D+ sur un parcours plat. C'est
|
||||||
|
/// inhérent à tout seuil : une oscillation d'amplitude ≥ seuil passe. D'où
|
||||||
|
/// le lissage préalable ci-dessous, qui est le vrai remède.
|
||||||
|
public var elevationThreshold: Double
|
||||||
|
|
||||||
|
/// Nombre de points de la moyenne glissante appliquée à l'altitude avant
|
||||||
|
/// l'hystérésis. `1` désactive le lissage.
|
||||||
|
///
|
||||||
|
/// Le bruit alternant s'annule dans la moyenne, une vraie pente la
|
||||||
|
/// traverse. Contrepartie assumée : la moyenne **retarde** le signal d'une
|
||||||
|
/// demi-fenêtre, ce qui ampute le D+ de quelques mètres en fin de trace.
|
||||||
|
/// Sans conséquence ici — ce D+ sert l'affichage pendant la séance, alors
|
||||||
|
/// que la valeur qui fait foi est le D+ barométrique d'Apple, relu après
|
||||||
|
/// coup depuis les métadonnées HealthKit.
|
||||||
|
public var elevationSmoothing: Int
|
||||||
|
|
||||||
|
public private(set) var distanceM: Double = 0
|
||||||
|
public private(set) var ascentM: Double = 0
|
||||||
|
public private(set) var descentM: Double = 0
|
||||||
|
public private(set) var acceptedCount: Int = 0
|
||||||
|
public private(set) var rejectedCount: Int = 0
|
||||||
|
public private(set) var lastAccepted: GeoFix?
|
||||||
|
|
||||||
|
/// Altitude de référence de l'hystérésis.
|
||||||
|
private var pivotAltitude: Double?
|
||||||
|
/// Fenêtre glissante des altitudes retenues.
|
||||||
|
private var altWindow: [Double] = []
|
||||||
|
|
||||||
|
public init(maxHorizontalAccuracy: Double = 50,
|
||||||
|
maxPlausibleSpeed: Double = 30,
|
||||||
|
elevationThreshold: Double = 3,
|
||||||
|
elevationSmoothing: Int = 5) {
|
||||||
|
self.maxHorizontalAccuracy = maxHorizontalAccuracy
|
||||||
|
self.maxPlausibleSpeed = maxPlausibleSpeed
|
||||||
|
self.elevationThreshold = elevationThreshold
|
||||||
|
self.elevationSmoothing = max(1, elevationSmoothing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soumet un point. Renvoie s'il a été retenu, et pourquoi sinon.
|
||||||
|
@discardableResult
|
||||||
|
public mutating func add(_ fix: GeoFix) -> FixOutcome {
|
||||||
|
if fix.horizontalAccuracy <= 0 {
|
||||||
|
rejectedCount += 1
|
||||||
|
return .rejected(.invalidAccuracy)
|
||||||
|
}
|
||||||
|
if fix.horizontalAccuracy > maxHorizontalAccuracy {
|
||||||
|
rejectedCount += 1
|
||||||
|
return .rejected(.tooImprecise)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let previous = lastAccepted {
|
||||||
|
let dt = fix.timestamp.timeIntervalSince(previous.timestamp)
|
||||||
|
if dt == 0 {
|
||||||
|
rejectedCount += 1
|
||||||
|
return .rejected(.duplicate)
|
||||||
|
}
|
||||||
|
if dt < 0 {
|
||||||
|
// CoreLocation peut livrer des points dans le désordre après
|
||||||
|
// une reprise ; les intégrer ferait reculer la distance.
|
||||||
|
rejectedCount += 1
|
||||||
|
return .rejected(.outOfOrder)
|
||||||
|
}
|
||||||
|
let step = Self.distance(from: previous, to: fix)
|
||||||
|
if step / dt > maxPlausibleSpeed {
|
||||||
|
rejectedCount += 1
|
||||||
|
return .rejected(.implausibleSpeed)
|
||||||
|
}
|
||||||
|
distanceM += step
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulateElevation(fix)
|
||||||
|
lastAccepted = fix
|
||||||
|
acceptedCount += 1
|
||||||
|
return .accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance orthodromique entre deux points, en mètres (formule de
|
||||||
|
/// haversine). Volontairement 2D : c'est la convention de toutes les
|
||||||
|
/// plateformes, et intégrer la pente gonflerait la distance par rapport à
|
||||||
|
/// ce qu'affichent Santé, Strava ou la montre.
|
||||||
|
public static func distance(from a: GeoFix, to b: GeoFix) -> Double {
|
||||||
|
// Rayon moyen terrestre WGS84.
|
||||||
|
let radius = 6_371_008.8
|
||||||
|
let φ1 = a.lat * .pi / 180
|
||||||
|
let φ2 = b.lat * .pi / 180
|
||||||
|
let dφ = (b.lat - a.lat) * .pi / 180
|
||||||
|
let dλ = (b.lon - a.lon) * .pi / 180
|
||||||
|
let h = sin(dφ / 2) * sin(dφ / 2)
|
||||||
|
+ cos(φ1) * cos(φ2) * sin(dλ / 2) * sin(dλ / 2)
|
||||||
|
return 2 * radius * asin(min(1, sqrt(h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hystérésis du dénivelé : on ne valide un sens qu'après avoir franchi le
|
||||||
|
/// seuil depuis le dernier point de bascule.
|
||||||
|
private mutating func accumulateElevation(_ fix: GeoFix) {
|
||||||
|
// Une altitude sans incertitude fiable ne doit pas nourrir le D+ : le
|
||||||
|
// GPS seul dérive de plusieurs mètres, l'altimètre barométrique non.
|
||||||
|
guard fix.verticalAccuracy > 0 else { return }
|
||||||
|
|
||||||
|
// Lissage d'abord : le bruit alternant s'annule dans la moyenne. On
|
||||||
|
// n'attend pas que la fenêtre soit pleine — sinon les premiers points
|
||||||
|
// seraient perdus, et avec eux le début de la première montée.
|
||||||
|
altWindow.append(fix.altitude)
|
||||||
|
if altWindow.count > elevationSmoothing { altWindow.removeFirst() }
|
||||||
|
let smoothed = altWindow.reduce(0, +) / Double(altWindow.count)
|
||||||
|
|
||||||
|
guard let pivot = pivotAltitude else {
|
||||||
|
pivotAltitude = smoothed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let delta = smoothed - pivot
|
||||||
|
if delta >= elevationThreshold {
|
||||||
|
ascentM += delta
|
||||||
|
pivotAltitude = smoothed
|
||||||
|
} else if delta <= -elevationThreshold {
|
||||||
|
descentM += -delta
|
||||||
|
pivotAltitude = smoothed
|
||||||
|
}
|
||||||
|
// Entre les deux : oscillation, on ne bouge pas le pivot.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,8 +49,18 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
||||||
// sur la vitesse moyenne dérivée de la distance.
|
// sur la vitesse moyenne dérivée de la distance.
|
||||||
let speedType = Self.speedType(for: activityType)
|
let speedType = Self.speedType(for: activityType)
|
||||||
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
// La trace exige d'être demandée en lecture ET en écriture — Apple :
|
||||||
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
// « 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 {
|
do {
|
||||||
try await healthStore.requestAuthorization(toShare: share, read: read)
|
try await healthStore.requestAuthorization(toShare: share, read: read)
|
||||||
@@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
return
|
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()
|
let config = HKWorkoutConfiguration()
|
||||||
config.activityType = activityType
|
config.activityType = activityType
|
||||||
config.locationType = locationType
|
config.locationType = locationType
|
||||||
@@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
statusMessage = nil
|
statusMessage = nil
|
||||||
workoutLog.info("beginCollection OK, isRunning=true")
|
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 {
|
} catch {
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
@@ -264,14 +293,26 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
let paused = (toState == .paused)
|
let paused = (toState == .paused)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isPaused = paused
|
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)
|
ConnectivityManager.shared.sendPaused(paused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard toState == .ended else { return }
|
guard toState == .ended else { return }
|
||||||
let builder = workoutSession.associatedWorkoutBuilder()
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task {
|
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)
|
try? await builder.endCollection(at: date)
|
||||||
_ = try? await builder.finishWorkout()
|
// ⚠️ `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
|
Task { @MainActor in
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
|
|||||||
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../ios/App/CoachWatch/RouteFilter.swift
|
||||||
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CoachModel
|
||||||
|
|
||||||
|
/* Filtrage de la trace GPS.
|
||||||
|
*
|
||||||
|
* Les cas testés ne sont pas théoriques : ce sont les quatre façons dont une
|
||||||
|
* trace se salit réellement — acquisition GPS des premières secondes, couvert
|
||||||
|
* forestier, points livrés dans le désordre après une reprise, et bruit
|
||||||
|
* d'altimètre sur terrain plat.
|
||||||
|
*/
|
||||||
|
final class RouteFilterTests: XCTestCase {
|
||||||
|
|
||||||
|
private let t0 = Date(timeIntervalSince1970: 1_755_000_000)
|
||||||
|
|
||||||
|
private func fix(_ lat: Double, _ lon: Double,
|
||||||
|
at seconds: TimeInterval,
|
||||||
|
alt: Double = 600,
|
||||||
|
hAcc: Double = 5,
|
||||||
|
vAcc: Double = 3) -> GeoFix {
|
||||||
|
GeoFix(lat: lat, lon: lon, altitude: alt,
|
||||||
|
horizontalAccuracy: hAcc, verticalAccuracy: vAcc,
|
||||||
|
speed: -1, timestamp: t0.addingTimeInterval(seconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Distance
|
||||||
|
|
||||||
|
func testHaversineMatchesAKnownDistance() {
|
||||||
|
// Blonay → Vevey, deux points relevés sur la carte. La distance réelle
|
||||||
|
// à vol d'oiseau est d'environ 3,4 km.
|
||||||
|
let blonay = fix(46.4667, 6.9167, at: 0)
|
||||||
|
let vevey = fix(46.4628, 6.8419, at: 60)
|
||||||
|
let d = RouteFilter.distance(from: blonay, to: vevey)
|
||||||
|
XCTAssertEqual(d, 5760, accuracy: 200,
|
||||||
|
"Écart Blonay–Vevey en longitude : ~5,8 km")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDistanceIsZeroForTheSamePoint() {
|
||||||
|
let a = fix(46.4667, 6.9167, at: 0)
|
||||||
|
XCTAssertEqual(RouteFilter.distance(from: a, to: a), 0, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDistanceAccumulatesAlongATrack() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
// Cinq points espacés d'environ 100 m vers l'est.
|
||||||
|
for i in 0..<5 {
|
||||||
|
let lon = 6.9167 + Double(i) * 0.0013
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, lon, at: Double(i) * 30)), .accepted)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(filter.acceptedCount, 5)
|
||||||
|
XCTAssertEqual(filter.distanceM, 400, accuracy: 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Rejets
|
||||||
|
|
||||||
|
func testFixWithNegativeAccuracyIsRejected() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
// Convention Apple : une précision négative signale un fix inutilisable.
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: -1)),
|
||||||
|
.rejected(.invalidAccuracy))
|
||||||
|
XCTAssertEqual(filter.acceptedCount, 0)
|
||||||
|
XCTAssertEqual(filter.distanceM, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImpreciseFixesAreRejected() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
// Les premières secondes d'une séance : le GPS n'a pas encore convergé.
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: 165)),
|
||||||
|
.rejected(.tooImprecise))
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 3, hAcc: 65)),
|
||||||
|
.rejected(.tooImprecise))
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 8, hAcc: 12)), .accepted)
|
||||||
|
XCTAssertEqual(filter.rejectedCount, 2)
|
||||||
|
XCTAssertEqual(filter.acceptedCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTeleportationIsRejected() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||||
|
// Point sauté de ~5,8 km en une seconde : impossible à pied.
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4628, 6.8419, at: 1)),
|
||||||
|
.rejected(.implausibleSpeed))
|
||||||
|
XCTAssertEqual(filter.distanceM, 0, accuracy: 0.001,
|
||||||
|
"Un saut GPS ne doit pas gonfler la distance")
|
||||||
|
// Le point suivant, plausible, repart du dernier point ACCEPTÉ.
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9180, at: 10)), .accepted)
|
||||||
|
XCTAssertEqual(filter.distanceM, 100, accuracy: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutOfOrderAndDuplicateFixesAreRejected() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 100)), .accepted)
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 100)), .rejected(.duplicate))
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 90)), .rejected(.outOfOrder))
|
||||||
|
XCTAssertEqual(filter.acceptedCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Dénivelé
|
||||||
|
|
||||||
|
func testFlatNoisyTerrainProducesNoElevationGain() {
|
||||||
|
// Le piège central : ±1,5 m de bruit d'altimètre sur 200 points. Sans
|
||||||
|
// hystérésis, on cumulerait des centaines de mètres de D+ sur du plat.
|
||||||
|
var filter = RouteFilter()
|
||||||
|
for i in 0..<200 {
|
||||||
|
let noise = (i % 2 == 0) ? 1.5 : -1.5
|
||||||
|
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0002,
|
||||||
|
at: Double(i) * 5, alt: 600 + noise))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001,
|
||||||
|
"Le bruit d'altimètre ne doit pas produire de D+")
|
||||||
|
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRealClimbIsCounted() {
|
||||||
|
// Cadence réaliste : un point par seconde, +0,5 m par point — soit une
|
||||||
|
// montée de 100 m en 3 min 20, ce qui est déjà raide.
|
||||||
|
var filter = RouteFilter()
|
||||||
|
for i in 0...200 {
|
||||||
|
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||||
|
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||||
|
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDescentIsCountedSeparately() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
for i in 0...200 { // 600 → 700
|
||||||
|
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||||
|
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||||
|
}
|
||||||
|
for i in 1...200 { // 700 → 600
|
||||||
|
_ = filter.add(fix(46.4667, 6.9267 + Double(i) * 0.00005,
|
||||||
|
at: 200 + Double(i), alt: 700 - Double(i) * 0.5))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||||
|
XCTAssertEqual(filter.descentM, 100, accuracy: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmoothingClipsASharpPeak() {
|
||||||
|
// Comportement assumé, pas un bug : une pointe franchie en quelques
|
||||||
|
// points est arrondie par la moyenne glissante, donc sous-comptée. Ce
|
||||||
|
// test existe pour que la perte soit connue et surveillée — le D+ qui
|
||||||
|
// fait foi reste celui, barométrique, qu'Apple écrit dans les
|
||||||
|
// métadonnées de la séance.
|
||||||
|
var filter = RouteFilter()
|
||||||
|
for i in 0...10 {
|
||||||
|
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||||
|
at: Double(i) * 20, alt: 600 + Double(i) * 10))
|
||||||
|
}
|
||||||
|
for i in 1...10 {
|
||||||
|
_ = filter.add(fix(46.4667, 6.9217 + Double(i) * 0.0005,
|
||||||
|
at: 200 + Double(i) * 20, alt: 700 - Double(i) * 10))
|
||||||
|
}
|
||||||
|
XCTAssertLessThan(filter.ascentM, 100)
|
||||||
|
XCTAssertLessThan(filter.descentM, 100)
|
||||||
|
XCTAssertGreaterThan(filter.ascentM, 60,
|
||||||
|
"L'écrêtage doit rester partiel, pas effacer la montée")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAltitudeWithoutVerticalAccuracyIsIgnored() {
|
||||||
|
// Le GPS seul dérive verticalement de plusieurs mètres. Apple signale
|
||||||
|
// une altitude non fiable par une incertitude négative.
|
||||||
|
var filter = RouteFilter()
|
||||||
|
for i in 0...20 {
|
||||||
|
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||||
|
at: Double(i) * 20,
|
||||||
|
alt: 600 + Double(i) * 5, vAcc: -1))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(filter.acceptedCount, 21,
|
||||||
|
"Le point reste valide pour la trace, seule l'altitude est écartée")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Robustesse
|
||||||
|
|
||||||
|
func testFirstFixEstablishesTheOriginWithoutDistance() {
|
||||||
|
var filter = RouteFilter()
|
||||||
|
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||||
|
XCTAssertEqual(filter.distanceM, 0)
|
||||||
|
XCTAssertNotNil(filter.lastAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplayingTheSameTrackGivesTheSameResult() {
|
||||||
|
let track = (0..<50).map { i in
|
||||||
|
fix(46.4667 + Double(i) * 0.0001, 6.9167 + Double(i) * 0.0003,
|
||||||
|
at: Double(i) * 4, alt: 600 + Double(i) * 2)
|
||||||
|
}
|
||||||
|
var a = RouteFilter(), b = RouteFilter()
|
||||||
|
for f in track { a.add(f) }
|
||||||
|
for f in track { b.add(f) }
|
||||||
|
XCTAssertEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThresholdsAreConfigurable() {
|
||||||
|
// Une sortie à VTT électrique dépasse allègrement les 30 km/h : le
|
||||||
|
// seuil doit pouvoir s'ajuster sans toucher au filtre.
|
||||||
|
var strict = RouteFilter(maxPlausibleSpeed: 5)
|
||||||
|
strict.add(fix(46.4667, 6.9167, at: 0))
|
||||||
|
XCTAssertEqual(strict.add(fix(46.4667, 6.9180, at: 1)),
|
||||||
|
.rejected(.implausibleSpeed))
|
||||||
|
|
||||||
|
var loose = RouteFilter(maxPlausibleSpeed: 200)
|
||||||
|
loose.add(fix(46.4667, 6.9167, at: 0))
|
||||||
|
XCTAssertEqual(loose.add(fix(46.4667, 6.9180, at: 1)), .accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user